Skip to content

Commit 39f1e94

Browse files
authored
Add support for Draft 2020-12 (#36)
Resolves #35
1 parent b178db3 commit 39f1e94

32 files changed

+776
-207
lines changed

api/json-schema-validator.api

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ public final class io/github/optimumcode/json/schema/JsonSchemaStream {
6363
public final class io/github/optimumcode/json/schema/SchemaType : java/lang/Enum {
6464
public static final field Companion Lio/github/optimumcode/json/schema/SchemaType$Companion;
6565
public static final field DRAFT_2019_09 Lio/github/optimumcode/json/schema/SchemaType;
66+
public static final field DRAFT_2020_12 Lio/github/optimumcode/json/schema/SchemaType;
6667
public static final field DRAFT_7 Lio/github/optimumcode/json/schema/SchemaType;
6768
public static final fun find (Ljava/lang/String;)Lio/github/optimumcode/json/schema/SchemaType;
6869
public static fun valueOf (Ljava/lang/String;)Lio/github/optimumcode/json/schema/SchemaType;
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
@file:Suppress("ktlint:standard:filename")
2+
3+
package io.github.optimumcode.json.pointer.internal
4+
5+
import io.github.optimumcode.json.pointer.EmptyPointer
6+
import io.github.optimumcode.json.pointer.JsonPointer
7+
import io.github.optimumcode.json.pointer.SegmentPointer
8+
9+
internal val JsonPointer.length: Int
10+
get() {
11+
if (this is EmptyPointer) {
12+
return 0
13+
}
14+
var length = 0
15+
var segment: JsonPointer? = this
16+
while (segment != null) {
17+
if (segment is SegmentPointer) {
18+
length += 1
19+
}
20+
segment = segment.next
21+
}
22+
return length
23+
}
24+
25+
internal fun JsonPointer.dropLast(): JsonPointer? {
26+
if (this is EmptyPointer) {
27+
return null
28+
}
29+
val fullPath = toString()
30+
val lastPathPart = fullPath.lastIndexOf('/')
31+
if (lastPathPart == 0) {
32+
return EmptyPointer
33+
}
34+
return JsonPointer.compile(fullPath.substring(0, lastPathPart))
35+
}

src/commonMain/kotlin/io/github/optimumcode/json/schema/SchemaType.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package io.github.optimumcode.json.schema
33
import com.eygraber.uri.Uri
44
import io.github.optimumcode.json.schema.internal.SchemaLoaderConfig
55
import io.github.optimumcode.json.schema.internal.config.Draft201909SchemaLoaderConfig
6+
import io.github.optimumcode.json.schema.internal.config.Draft202012SchemaLoaderConfig
67
import io.github.optimumcode.json.schema.internal.config.Draft7SchemaLoaderConfig
78
import kotlin.jvm.JvmStatic
89

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

1719
public companion object {

src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/AssertionContext.kt

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@ package io.github.optimumcode.json.schema.internal
33
import io.github.optimumcode.json.pointer.JsonPointer
44
import io.github.optimumcode.json.pointer.div
55
import io.github.optimumcode.json.pointer.get
6+
import io.github.optimumcode.json.pointer.internal.dropLast
7+
import io.github.optimumcode.json.pointer.internal.length
8+
import io.github.optimumcode.json.pointer.startsWith
69
import kotlin.jvm.JvmStatic
710
import kotlin.reflect.KClass
811
import kotlin.reflect.cast
912

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

23+
fun resolveDynamicRef(refId: RefId, refPath: JsonPointer): Pair<JsonPointer, JsonSchemaAssertion>
24+
1925
/**
2026
* Discards collected annotations
2127
*/
@@ -53,6 +59,10 @@ internal interface AssertionContext {
5359
* Returns recursive root for current state of the validation
5460
*/
5561
fun getRecursiveRoot(): JsonSchemaAssertion?
62+
63+
fun pushSchemaPath(path: JsonPointer)
64+
65+
fun popSchemaPath()
5666
}
5767

5868
internal fun interface Aggregator<T : Any> {
@@ -108,11 +118,13 @@ internal class AnnotationKey<T : Any> private constructor(
108118
}
109119
}
110120

121+
@Suppress("detekt:TooManyFunctions")
111122
internal data class DefaultAssertionContext(
112123
override val objectPath: JsonPointer,
113124
private val references: Map<RefId, AssertionWithPath>,
114125
private val parent: DefaultAssertionContext? = null,
115126
private var recursiveRoot: JsonSchemaAssertion? = null,
127+
private val schemaPathsStack: ArrayDeque<JsonPointer> = ArrayDeque(),
116128
) : AssertionContext {
117129
private lateinit var _annotations: MutableMap<AnnotationKey<*>, Any>
118130
private lateinit var _aggregatedAnnotations: MutableMap<AnnotationKey<*>, Any>
@@ -156,6 +168,44 @@ internal data class DefaultAssertionContext(
156168
return resolvedRef.schemaPath to resolvedRef.assertion
157169
}
158170

171+
override fun resolveDynamicRef(refId: RefId, refPath: JsonPointer): Pair<JsonPointer, JsonSchemaAssertion> {
172+
val originalRef = requireNotNull(references[refId]) { "$refId is not found" }
173+
if (!originalRef.dynamic) {
174+
return originalRef.schemaPath to originalRef.assertion
175+
}
176+
val fragment = refId.fragment
177+
val possibleDynamicRefs: MutableList<AssertionWithPath> = references.asSequence()
178+
.filter { (id, link) ->
179+
link.dynamic && id.fragment == fragment && id != refId
180+
}.map { it.value }.toMutableList()
181+
possibleDynamicRefs.sortBy { it.schemaPath.length }
182+
183+
val resolvedDynamicRef = findMostOuterRef(possibleDynamicRefs)
184+
// If no outer anchor found use the original ref
185+
?: possibleDynamicRefs.firstOrNull()
186+
?: originalRef
187+
return resolvedDynamicRef.schemaPath to resolvedDynamicRef.assertion
188+
}
189+
190+
@Suppress("detekt:NestedBlockDepth")
191+
private fun findMostOuterRef(possibleRefs: List<AssertionWithPath>): AssertionWithPath? {
192+
// Try to find the most outer anchor to use
193+
// Check every schema in the current chain
194+
// If not matches - take the most outer by location
195+
for (schemaPath in schemaPathsStack) {
196+
var currPath: JsonPointer = schemaPath
197+
while (currPath != JsonPointer.ROOT) {
198+
for (dynamicRef in possibleRefs) {
199+
if (dynamicRef.schemaPath.startsWith(currPath)) {
200+
return dynamicRef
201+
}
202+
}
203+
currPath = currPath.dropLast() ?: break
204+
}
205+
}
206+
return null
207+
}
208+
159209
override fun resetAnnotations() {
160210
if (::_annotations.isInitialized && _annotations.isNotEmpty()) {
161211
_annotations.clear()
@@ -198,6 +248,14 @@ internal data class DefaultAssertionContext(
198248
return recursiveRoot
199249
}
200250

251+
override fun pushSchemaPath(path: JsonPointer) {
252+
schemaPathsStack.addLast(path)
253+
}
254+
255+
override fun popSchemaPath() {
256+
schemaPathsStack.removeLast()
257+
}
258+
201259
private inline fun aggregateAnnotations(
202260
source: MutableMap<AnnotationKey<*>, Any>,
203261
destination: () -> MutableMap<AnnotationKey<*>, Any>,

src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/JsonSchemaRoot.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package io.github.optimumcode.json.schema.internal
22

3+
import io.github.optimumcode.json.pointer.JsonPointer
34
import io.github.optimumcode.json.schema.ErrorCollector
45
import kotlinx.serialization.json.JsonElement
56

67
internal class JsonSchemaRoot(
8+
private val schemaPath: JsonPointer,
79
private val assertions: Collection<JsonSchemaAssertion>,
810
private val canBeReferencedRecursively: Boolean,
911
) : JsonSchemaAssertion {
@@ -15,10 +17,12 @@ internal class JsonSchemaRoot(
1517
context.resetRecursiveRoot()
1618
}
1719
var result = true
20+
context.pushSchemaPath(schemaPath)
1821
assertions.forEach {
1922
val valid = it.validate(element, context, errorCollector)
2023
result = result and valid
2124
}
25+
context.popSchemaPath()
2226
// According to spec the annotations should not be applied if element does not match the schema
2327
if (result) {
2428
context.applyAnnotations()

src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/KeyWord.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ internal enum class KeyWord {
1616
*/
1717
ANCHOR,
1818

19+
/**
20+
* Keyword that is used to define dynamic anchor to be referenced by dynamic refs
21+
*/
22+
DYNAMIC_ANCHOR,
23+
1924
/**
2025
* Keyword for definitions in current JSON schema
2126
*/

src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/RecursiveRefSchemaAssertion.kt

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,8 @@ internal class RecursiveRefSchemaAssertion(
1515

1616
override fun validate(element: JsonElement, context: AssertionContext, errorCollector: ErrorCollector): Boolean {
1717
return context.getRecursiveRoot()?.validate(element, context, errorCollector) ?: run {
18-
// This part is pretty similar to RefSchemaAssertion,
19-
// but I am not sure if it should be extracted into a base class for now
2018
if (!::refAssertion.isInitialized) {
21-
val resolved = context.resolveRef(refId)
19+
val resolved = context.resolveDynamicRef(refId, basePath)
2220
refIdPath = resolved.first
2321
refAssertion = resolved.second
2422
}

src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/SchemaLoader.kt

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,13 @@ internal class SchemaLoader {
3333
context.usedRef,
3434
)
3535
val usedRefs = context.usedRef.map { it.refId }.toSet()
36+
val dynamicRefs = context.references.asSequence()
37+
.filter { it.value.dynamic }
38+
.map { it.key }
39+
.toSet()
3640
// pre-filter references to get rid of unused references
3741
val usedReferencesWithPath: Map<RefId, AssertionWithPath> = context.references.asSequence()
38-
.filter { it.key in usedRefs }
42+
.filter { it.key in usedRefs || it.key in dynamicRefs }
3943
.associate { it.key to it.value }
4044
return JsonSchema(schemaAssertion, usedReferencesWithPath)
4145
}
@@ -121,7 +125,11 @@ private fun loadSchema(
121125
null
122126
}
123127
if (refAssertion != null && !referenceFactory.allowOverriding) {
124-
JsonSchemaRoot(listOf(refAssertion), contextWithAdditionalID.recursiveResolution)
128+
JsonSchemaRoot(
129+
contextWithAdditionalID.schemaPath,
130+
listOf(refAssertion),
131+
contextWithAdditionalID.recursiveResolution,
132+
)
125133
} else {
126134
loadJsonSchemaRoot(contextWithAdditionalID, schemaDefinition, refAssertion)
127135
}
@@ -131,11 +139,29 @@ private fun loadSchema(
131139
}.apply {
132140
loadDefinitions(schemaDefinition, contextWithAdditionalID)
133141
context.register(additionalId, this)
134-
val anchorProperty: String? = context.config.keywordResolver.resolve(KeyWord.ANCHOR)
135-
if (anchorProperty != null && schemaDefinition is JsonObject) {
136-
schemaDefinition.getString(anchorProperty)?.also {
137-
contextWithAdditionalID.registerByAnchor(it, this)
138-
}
142+
registerWithAnchor(
143+
context.config.keywordResolver.resolve(KeyWord.ANCHOR),
144+
schemaDefinition,
145+
contextWithAdditionalID,
146+
)
147+
registerWithAnchor(
148+
context.config.keywordResolver.resolve(KeyWord.DYNAMIC_ANCHOR),
149+
schemaDefinition,
150+
contextWithAdditionalID,
151+
dynamic = true,
152+
)
153+
}
154+
}
155+
156+
private fun JsonSchemaAssertion.registerWithAnchor(
157+
anchorProperty: String?,
158+
schemaDefinition: JsonElement,
159+
contextWithAdditionalID: DefaultLoadingContext,
160+
dynamic: Boolean = false,
161+
) {
162+
if (anchorProperty != null && schemaDefinition is JsonObject) {
163+
schemaDefinition.getString(anchorProperty)?.also {
164+
contextWithAdditionalID.registerByAnchor(it, this, dynamic)
139165
}
140166
}
141167
}
@@ -158,6 +184,7 @@ private fun loadJsonSchemaRoot(
158184
addAll(assertions)
159185
}
160186
return JsonSchemaRoot(
187+
context.schemaPath,
161188
result,
162189
context.recursiveResolution,
163190
)
@@ -187,6 +214,7 @@ internal data class IdWithLocation(
187214
internal data class AssertionWithPath(
188215
val assertion: JsonSchemaAssertion,
189216
val schemaPath: JsonPointer,
217+
val dynamic: Boolean,
190218
)
191219

192220
private data class DefaultLoadingContext(
@@ -213,9 +241,9 @@ private data class DefaultLoadingContext(
213241
(element is JsonPrimitive && element.booleanOrNull != null)
214242
)
215243

216-
fun register(id: Uri?, assertion: JsonSchemaAssertion) {
244+
fun register(id: Uri?, assertion: JsonSchemaAssertion, dynamic: Boolean = false) {
217245
if (id != null) {
218-
registerById(id, assertion)
246+
registerById(id, assertion, dynamic)
219247
}
220248
for ((baseId, location) in additionalIDs) {
221249
val relativePointer = location.relative(schemaPath)
@@ -227,18 +255,18 @@ private data class DefaultLoadingContext(
227255
// and we register it using Empty pointer
228256
continue
229257
}
230-
register(referenceId, assertion)
258+
register(referenceId, assertion, dynamic = false)
231259
}
232260
}
233261

234262
/**
235263
* [anchor] is a plain text that will be transformed into a URI fragment
236264
* It must match [ANCHOR_REGEX] otherwise [IllegalArgumentException] will be thrown
237265
*/
238-
fun registerByAnchor(anchor: String, assertion: JsonSchemaAssertion) {
266+
fun registerByAnchor(anchor: String, assertion: JsonSchemaAssertion, dynamic: Boolean) {
239267
require(ANCHOR_REGEX.matches(anchor)) { "$anchor must match the format ${ANCHOR_REGEX.pattern}" }
240268
val refId = additionalIDs.last().id.buildUpon().fragment(anchor).buildRefId()
241-
register(refId, assertion)
269+
register(refId, assertion, dynamic)
242270
}
243271

244272
fun addId(additionalId: Uri): DefaultLoadingContext {
@@ -289,21 +317,24 @@ private data class DefaultLoadingContext(
289317
private fun registerById(
290318
id: Uri,
291319
assertion: JsonSchemaAssertion,
320+
dynamic: Boolean,
292321
) {
293322
when {
294-
id.isAbsolute -> register(id.buildRefId(), assertion) // register JSON schema by absolute URI
323+
id.isAbsolute -> register(id.buildRefId(), assertion, dynamic) // register JSON schema by absolute URI
295324
id.isRelative ->
296325
when {
297326
!id.path.isNullOrBlank() -> register(
298327
// register JSON schema by related path
299328
additionalIDs.resolvePath(id.path).buildRefId(),
300329
assertion,
330+
dynamic,
301331
)
302332

303333
!id.fragment.isNullOrBlank() -> register(
304334
// register JSON schema by fragment
305335
additionalIDs.last().id.buildUpon().encodedFragment(id.fragment).buildRefId(),
306336
assertion,
337+
dynamic,
307338
)
308339
}
309340
}
@@ -312,8 +343,9 @@ private data class DefaultLoadingContext(
312343
private fun register(
313344
referenceId: RefId,
314345
assertion: JsonSchemaAssertion,
346+
dynamic: Boolean,
315347
) {
316-
references.put(referenceId, AssertionWithPath(assertion, schemaPath))?.apply {
348+
references.put(referenceId, AssertionWithPath(assertion, schemaPath, dynamic))?.apply {
317349
throw IllegalStateException("duplicated definition $referenceId")
318350
}
319351
}

src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/SchemaLoaderConfig.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ internal interface ReferenceFactory {
4545
data class Recursive(
4646
val property: String,
4747
val refId: RefId,
48-
val relativePath: String,
4948
) : RefHolder()
5049
}
5150
}

0 commit comments

Comments
 (0)