Skip to content

Add support for Draft 2020-12 #36

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Dec 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/json-schema-validator.api
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public final class io/github/optimumcode/json/schema/JsonSchemaStream {
public final class io/github/optimumcode/json/schema/SchemaType : java/lang/Enum {
public static final field Companion Lio/github/optimumcode/json/schema/SchemaType$Companion;
public static final field DRAFT_2019_09 Lio/github/optimumcode/json/schema/SchemaType;
public static final field DRAFT_2020_12 Lio/github/optimumcode/json/schema/SchemaType;
public static final field DRAFT_7 Lio/github/optimumcode/json/schema/SchemaType;
public static final fun find (Ljava/lang/String;)Lio/github/optimumcode/json/schema/SchemaType;
public static fun valueOf (Ljava/lang/String;)Lio/github/optimumcode/json/schema/SchemaType;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
@file:Suppress("ktlint:standard:filename")

package io.github.optimumcode.json.pointer.internal

import io.github.optimumcode.json.pointer.EmptyPointer
import io.github.optimumcode.json.pointer.JsonPointer
import io.github.optimumcode.json.pointer.SegmentPointer

internal val JsonPointer.length: Int
get() {
if (this is EmptyPointer) {
return 0
}
var length = 0
var segment: JsonPointer? = this
while (segment != null) {
if (segment is SegmentPointer) {
length += 1
}
segment = segment.next
}
return length
}

internal fun JsonPointer.dropLast(): JsonPointer? {
if (this is EmptyPointer) {
return null
}
val fullPath = toString()
val lastPathPart = fullPath.lastIndexOf('/')
if (lastPathPart == 0) {
return EmptyPointer
}
return JsonPointer.compile(fullPath.substring(0, lastPathPart))
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package io.github.optimumcode.json.schema
import com.eygraber.uri.Uri
import io.github.optimumcode.json.schema.internal.SchemaLoaderConfig
import io.github.optimumcode.json.schema.internal.config.Draft201909SchemaLoaderConfig
import io.github.optimumcode.json.schema.internal.config.Draft202012SchemaLoaderConfig
import io.github.optimumcode.json.schema.internal.config.Draft7SchemaLoaderConfig
import kotlin.jvm.JvmStatic

Expand All @@ -12,6 +13,7 @@ public enum class SchemaType(
) {
DRAFT_7(Uri.parse("http://json-schema.org/draft-07/schema"), Draft7SchemaLoaderConfig),
DRAFT_2019_09(Uri.parse("https://json-schema.org/draft/2019-09/schema"), Draft201909SchemaLoaderConfig),
DRAFT_2020_12(Uri.parse("https://json-schema.org/draft/2020-12/schema"), Draft202012SchemaLoaderConfig),
;

public companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ package io.github.optimumcode.json.schema.internal
import io.github.optimumcode.json.pointer.JsonPointer
import io.github.optimumcode.json.pointer.div
import io.github.optimumcode.json.pointer.get
import io.github.optimumcode.json.pointer.internal.dropLast
import io.github.optimumcode.json.pointer.internal.length
import io.github.optimumcode.json.pointer.startsWith
import kotlin.jvm.JvmStatic
import kotlin.reflect.KClass
import kotlin.reflect.cast

@Suppress("detekt:TooManyFunctions")
internal interface AssertionContext {
val objectPath: JsonPointer
fun <T : Any> annotate(key: AnnotationKey<T>, value: T)
Expand All @@ -16,6 +20,8 @@ internal interface AssertionContext {
fun at(property: String): AssertionContext
fun resolveRef(refId: RefId): Pair<JsonPointer, JsonSchemaAssertion>

fun resolveDynamicRef(refId: RefId, refPath: JsonPointer): Pair<JsonPointer, JsonSchemaAssertion>

/**
* Discards collected annotations
*/
Expand Down Expand Up @@ -53,6 +59,10 @@ internal interface AssertionContext {
* Returns recursive root for current state of the validation
*/
fun getRecursiveRoot(): JsonSchemaAssertion?

fun pushSchemaPath(path: JsonPointer)

fun popSchemaPath()
}

internal fun interface Aggregator<T : Any> {
Expand Down Expand Up @@ -108,11 +118,13 @@ internal class AnnotationKey<T : Any> private constructor(
}
}

@Suppress("detekt:TooManyFunctions")
internal data class DefaultAssertionContext(
override val objectPath: JsonPointer,
private val references: Map<RefId, AssertionWithPath>,
private val parent: DefaultAssertionContext? = null,
private var recursiveRoot: JsonSchemaAssertion? = null,
private val schemaPathsStack: ArrayDeque<JsonPointer> = ArrayDeque(),
) : AssertionContext {
private lateinit var _annotations: MutableMap<AnnotationKey<*>, Any>
private lateinit var _aggregatedAnnotations: MutableMap<AnnotationKey<*>, Any>
Expand Down Expand Up @@ -156,6 +168,44 @@ internal data class DefaultAssertionContext(
return resolvedRef.schemaPath to resolvedRef.assertion
}

override fun resolveDynamicRef(refId: RefId, refPath: JsonPointer): Pair<JsonPointer, JsonSchemaAssertion> {
val originalRef = requireNotNull(references[refId]) { "$refId is not found" }
if (!originalRef.dynamic) {
return originalRef.schemaPath to originalRef.assertion
}
val fragment = refId.fragment
val possibleDynamicRefs: MutableList<AssertionWithPath> = references.asSequence()
.filter { (id, link) ->
link.dynamic && id.fragment == fragment && id != refId
}.map { it.value }.toMutableList()
possibleDynamicRefs.sortBy { it.schemaPath.length }

val resolvedDynamicRef = findMostOuterRef(possibleDynamicRefs)
// If no outer anchor found use the original ref
?: possibleDynamicRefs.firstOrNull()
?: originalRef
return resolvedDynamicRef.schemaPath to resolvedDynamicRef.assertion
}

@Suppress("detekt:NestedBlockDepth")
private fun findMostOuterRef(possibleRefs: List<AssertionWithPath>): AssertionWithPath? {
// Try to find the most outer anchor to use
// Check every schema in the current chain
// If not matches - take the most outer by location
for (schemaPath in schemaPathsStack) {
var currPath: JsonPointer = schemaPath
while (currPath != JsonPointer.ROOT) {
for (dynamicRef in possibleRefs) {
if (dynamicRef.schemaPath.startsWith(currPath)) {
return dynamicRef
}
}
currPath = currPath.dropLast() ?: break
}
}
return null
}

override fun resetAnnotations() {
if (::_annotations.isInitialized && _annotations.isNotEmpty()) {
_annotations.clear()
Expand Down Expand Up @@ -198,6 +248,14 @@ internal data class DefaultAssertionContext(
return recursiveRoot
}

override fun pushSchemaPath(path: JsonPointer) {
schemaPathsStack.addLast(path)
}

override fun popSchemaPath() {
schemaPathsStack.removeLast()
}

private inline fun aggregateAnnotations(
source: MutableMap<AnnotationKey<*>, Any>,
destination: () -> MutableMap<AnnotationKey<*>, Any>,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package io.github.optimumcode.json.schema.internal

import io.github.optimumcode.json.pointer.JsonPointer
import io.github.optimumcode.json.schema.ErrorCollector
import kotlinx.serialization.json.JsonElement

internal class JsonSchemaRoot(
private val schemaPath: JsonPointer,
private val assertions: Collection<JsonSchemaAssertion>,
private val canBeReferencedRecursively: Boolean,
) : JsonSchemaAssertion {
Expand All @@ -15,10 +17,12 @@ internal class JsonSchemaRoot(
context.resetRecursiveRoot()
}
var result = true
context.pushSchemaPath(schemaPath)
assertions.forEach {
val valid = it.validate(element, context, errorCollector)
result = result and valid
}
context.popSchemaPath()
// According to spec the annotations should not be applied if element does not match the schema
if (result) {
context.applyAnnotations()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ internal enum class KeyWord {
*/
ANCHOR,

/**
* Keyword that is used to define dynamic anchor to be referenced by dynamic refs
*/
DYNAMIC_ANCHOR,

/**
* Keyword for definitions in current JSON schema
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,8 @@ internal class RecursiveRefSchemaAssertion(

override fun validate(element: JsonElement, context: AssertionContext, errorCollector: ErrorCollector): Boolean {
return context.getRecursiveRoot()?.validate(element, context, errorCollector) ?: run {
// This part is pretty similar to RefSchemaAssertion,
// but I am not sure if it should be extracted into a base class for now
if (!::refAssertion.isInitialized) {
val resolved = context.resolveRef(refId)
val resolved = context.resolveDynamicRef(refId, basePath)
refIdPath = resolved.first
refAssertion = resolved.second
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,13 @@ internal class SchemaLoader {
context.usedRef,
)
val usedRefs = context.usedRef.map { it.refId }.toSet()
val dynamicRefs = context.references.asSequence()
.filter { it.value.dynamic }
.map { it.key }
.toSet()
// pre-filter references to get rid of unused references
val usedReferencesWithPath: Map<RefId, AssertionWithPath> = context.references.asSequence()
.filter { it.key in usedRefs }
.filter { it.key in usedRefs || it.key in dynamicRefs }
.associate { it.key to it.value }
return JsonSchema(schemaAssertion, usedReferencesWithPath)
}
Expand Down Expand Up @@ -121,7 +125,11 @@ private fun loadSchema(
null
}
if (refAssertion != null && !referenceFactory.allowOverriding) {
JsonSchemaRoot(listOf(refAssertion), contextWithAdditionalID.recursiveResolution)
JsonSchemaRoot(
contextWithAdditionalID.schemaPath,
listOf(refAssertion),
contextWithAdditionalID.recursiveResolution,
)
} else {
loadJsonSchemaRoot(contextWithAdditionalID, schemaDefinition, refAssertion)
}
Expand All @@ -131,11 +139,29 @@ private fun loadSchema(
}.apply {
loadDefinitions(schemaDefinition, contextWithAdditionalID)
context.register(additionalId, this)
val anchorProperty: String? = context.config.keywordResolver.resolve(KeyWord.ANCHOR)
if (anchorProperty != null && schemaDefinition is JsonObject) {
schemaDefinition.getString(anchorProperty)?.also {
contextWithAdditionalID.registerByAnchor(it, this)
}
registerWithAnchor(
context.config.keywordResolver.resolve(KeyWord.ANCHOR),
schemaDefinition,
contextWithAdditionalID,
)
registerWithAnchor(
context.config.keywordResolver.resolve(KeyWord.DYNAMIC_ANCHOR),
schemaDefinition,
contextWithAdditionalID,
dynamic = true,
)
}
}

private fun JsonSchemaAssertion.registerWithAnchor(
anchorProperty: String?,
schemaDefinition: JsonElement,
contextWithAdditionalID: DefaultLoadingContext,
dynamic: Boolean = false,
) {
if (anchorProperty != null && schemaDefinition is JsonObject) {
schemaDefinition.getString(anchorProperty)?.also {
contextWithAdditionalID.registerByAnchor(it, this, dynamic)
}
}
}
Expand All @@ -158,6 +184,7 @@ private fun loadJsonSchemaRoot(
addAll(assertions)
}
return JsonSchemaRoot(
context.schemaPath,
result,
context.recursiveResolution,
)
Expand Down Expand Up @@ -187,6 +214,7 @@ internal data class IdWithLocation(
internal data class AssertionWithPath(
val assertion: JsonSchemaAssertion,
val schemaPath: JsonPointer,
val dynamic: Boolean,
)

private data class DefaultLoadingContext(
Expand All @@ -213,9 +241,9 @@ private data class DefaultLoadingContext(
(element is JsonPrimitive && element.booleanOrNull != null)
)

fun register(id: Uri?, assertion: JsonSchemaAssertion) {
fun register(id: Uri?, assertion: JsonSchemaAssertion, dynamic: Boolean = false) {
if (id != null) {
registerById(id, assertion)
registerById(id, assertion, dynamic)
}
for ((baseId, location) in additionalIDs) {
val relativePointer = location.relative(schemaPath)
Expand All @@ -227,18 +255,18 @@ private data class DefaultLoadingContext(
// and we register it using Empty pointer
continue
}
register(referenceId, assertion)
register(referenceId, assertion, dynamic = false)
}
}

/**
* [anchor] is a plain text that will be transformed into a URI fragment
* It must match [ANCHOR_REGEX] otherwise [IllegalArgumentException] will be thrown
*/
fun registerByAnchor(anchor: String, assertion: JsonSchemaAssertion) {
fun registerByAnchor(anchor: String, assertion: JsonSchemaAssertion, dynamic: Boolean) {
require(ANCHOR_REGEX.matches(anchor)) { "$anchor must match the format ${ANCHOR_REGEX.pattern}" }
val refId = additionalIDs.last().id.buildUpon().fragment(anchor).buildRefId()
register(refId, assertion)
register(refId, assertion, dynamic)
}

fun addId(additionalId: Uri): DefaultLoadingContext {
Expand Down Expand Up @@ -289,21 +317,24 @@ private data class DefaultLoadingContext(
private fun registerById(
id: Uri,
assertion: JsonSchemaAssertion,
dynamic: Boolean,
) {
when {
id.isAbsolute -> register(id.buildRefId(), assertion) // register JSON schema by absolute URI
id.isAbsolute -> register(id.buildRefId(), assertion, dynamic) // register JSON schema by absolute URI
id.isRelative ->
when {
!id.path.isNullOrBlank() -> register(
// register JSON schema by related path
additionalIDs.resolvePath(id.path).buildRefId(),
assertion,
dynamic,
)

!id.fragment.isNullOrBlank() -> register(
// register JSON schema by fragment
additionalIDs.last().id.buildUpon().encodedFragment(id.fragment).buildRefId(),
assertion,
dynamic,
)
}
}
Expand All @@ -312,8 +343,9 @@ private data class DefaultLoadingContext(
private fun register(
referenceId: RefId,
assertion: JsonSchemaAssertion,
dynamic: Boolean,
) {
references.put(referenceId, AssertionWithPath(assertion, schemaPath))?.apply {
references.put(referenceId, AssertionWithPath(assertion, schemaPath, dynamic))?.apply {
throw IllegalStateException("duplicated definition $referenceId")
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ internal interface ReferenceFactory {
data class Recursive(
val property: String,
val refId: RefId,
val relativePath: String,
) : RefHolder()
}
}
Expand Down
Loading