Skip to content

Commit ff947eb

Browse files
author
Oleg
committed
Add ref support
1 parent 62a3f63 commit ff947eb

38 files changed

+479
-77
lines changed

src/commonMain/kotlin/smirnov/oleg/json/pointer/extenstions.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,36 @@ operator fun JsonPointer.div(property: String): JsonPointer = JsonPointer(
2727
}
2828
)
2929

30+
operator fun JsonPointer.plus(otherPointer: JsonPointer): JsonPointer {
31+
if (this is EmptyPointer) {
32+
return otherPointer
33+
}
34+
if (otherPointer is EmptyPointer) {
35+
return this
36+
}
37+
return JsonPointer(
38+
buildString {
39+
val pointer = this@plus.toString()
40+
append(pointer)
41+
if (pointer.endsWith(JsonPointer.SEPARATOR)) {
42+
setLength(length - 1)
43+
}
44+
val other = otherPointer.toString()
45+
append(other)
46+
}
47+
)
48+
}
49+
50+
fun JsonPointer.relative(other: JsonPointer): JsonPointer {
51+
if (this is EmptyPointer) {
52+
return other
53+
}
54+
require(other !is EmptyPointer) { "empty pointer is not relative to any" }
55+
val currentValue = this.toString()
56+
val otherValue = other.toString()
57+
return JsonPointer(otherValue.substringAfter(currentValue))
58+
}
59+
3060
fun JsonElement.at(pointer: JsonPointer): JsonElement? {
3161
return when (pointer) {
3262
is EmptyPointer -> this

src/commonMain/kotlin/smirnov/oleg/json/schema/JsonSchema.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,18 @@ import kotlinx.serialization.json.JsonObject
66
import smirnov.oleg.json.pointer.JsonPointer
77
import smirnov.oleg.json.schema.internal.DefaultAssertionContext
88
import smirnov.oleg.json.schema.internal.JsonSchemaAssertion
9+
import smirnov.oleg.json.schema.internal.RefId
910
import smirnov.oleg.json.schema.internal.SchemaLoader
1011
import kotlin.jvm.JvmStatic
1112

1213
class JsonSchema internal constructor(
1314
private val baseId: String,
1415
private val assertion: JsonSchemaAssertion,
15-
private val references: Map<String, JsonSchemaAssertion>,
16+
private val references: Map<RefId, JsonSchemaAssertion>,
1617
) {
1718
fun validate(value: JsonElement, errorCollector: ErrorCollector): Boolean {
18-
return assertion.validate(value, DefaultAssertionContext(JsonPointer.ROOT), errorCollector)
19+
val context = DefaultAssertionContext(JsonPointer.ROOT, references)
20+
return assertion.validate(value, context, errorCollector)
1921
}
2022

2123
companion object {

src/commonMain/kotlin/smirnov/oleg/json/schema/ValidationError.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,9 @@ data class ValidationError(
2222
* Additional details about error
2323
*/
2424
val details: Map<String, String> = emptyMap(),
25+
26+
/**
27+
* The absolute path to triggered assertion if the $ref was used
28+
*/
29+
val absoluteLocation: JsonPointer? = null,
2530
)

src/commonMain/kotlin/smirnov/oleg/json/schema/internal/AssertionContext.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,20 @@ internal interface AssertionContext {
99

1010
fun at(index: Int): AssertionContext
1111
fun at(property: String): AssertionContext
12+
fun resolveRef(refId: RefId): JsonSchemaAssertion
1213
}
1314

1415
internal data class DefaultAssertionContext(
1516
override val objectPath: JsonPointer,
17+
private val references: Map<RefId, JsonSchemaAssertion>,
1618
) : AssertionContext {
1719
override fun at(index: Int): AssertionContext = copy(objectPath = objectPath[index])
1820

1921
override fun at(property: String): AssertionContext {
2022
return copy(objectPath = objectPath / property)
2123
}
24+
25+
override fun resolveRef(refId: RefId): JsonSchemaAssertion {
26+
return requireNotNull(references[refId]) { "$refId is not found" }
27+
}
2228
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package smirnov.oleg.json.schema.internal
2+
3+
import kotlin.jvm.JvmInline
4+
5+
@JvmInline
6+
internal value class RefId(private val id: String) {
7+
val fragment: String
8+
get() = id.substringAfter(rootReference)
9+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package smirnov.oleg.json.schema.internal
2+
3+
import kotlinx.serialization.json.JsonElement
4+
import smirnov.oleg.json.pointer.JsonPointer
5+
import smirnov.oleg.json.pointer.plus
6+
import smirnov.oleg.json.pointer.relative
7+
import smirnov.oleg.json.schema.ErrorCollector
8+
9+
internal class RefSchemaAssertion(
10+
private val basePath: JsonPointer,
11+
private val refId: RefId,
12+
) : JsonSchemaAssertion {
13+
private val refIdPath: JsonPointer = JsonPointer(refId.fragment)
14+
private lateinit var refAssertion: JsonSchemaAssertion
15+
16+
override fun validate(element: JsonElement, context: AssertionContext, errorCollector: ErrorCollector): Boolean {
17+
if (!::refAssertion.isInitialized) {
18+
refAssertion = context.resolveRef(refId)
19+
}
20+
return refAssertion.validate(element, context) {
21+
errorCollector.onError(
22+
it.copy(
23+
schemaPath = basePath + refIdPath.relative(it.schemaPath),
24+
absoluteLocation = it.absoluteLocation ?: it.schemaPath,
25+
)
26+
)
27+
}
28+
}
29+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package smirnov.oleg.json.schema.internal
2+
3+
import smirnov.oleg.json.pointer.JsonPointer
4+
5+
internal object ReferenceValidator {
6+
data class ReferenceLocation(
7+
val schemaPath: JsonPointer,
8+
val refId: RefId,
9+
)
10+
fun validateReferences(references: Set<RefId>, usedRef: Set<ReferenceLocation>) {
11+
val missingRefs: Map<RefId, List<ReferenceLocation>> = usedRef.asSequence()
12+
.filter { it.refId !in references }
13+
.groupBy { it.refId }
14+
require(missingRefs.isEmpty()) {
15+
"cannot resolve references: ${
16+
missingRefs.entries.joinToString(prefix = "{", postfix = "}") { (ref, locations) ->
17+
"\"${ref.fragment}\": ${locations.map { "\"${it.schemaPath}\"" }}"
18+
}
19+
}"
20+
}
21+
checkCircledReferences(usedRef)
22+
}
23+
24+
private val alwaysRunAssertions = hashSetOf("/allOf/", "/anyOf/", "/oneOf/")
25+
26+
private fun checkCircledReferences(usedRefs: Set<ReferenceLocation>) {
27+
val locationToRef: Map<String, String> = usedRefs.associate { (schemaPath, refId) ->
28+
schemaPath.toString() to refId.fragment
29+
}
30+
31+
val circledReferences = hashSetOf<CircledReference>()
32+
fun checkRunAlways(path: String): Boolean {
33+
return alwaysRunAssertions.any { path.contains(it) }
34+
}
35+
for ((location, refFragment) in locationToRef) {
36+
val (otherLocation, otherRefFragment) = locationToRef.entries.find { it.key.startsWith(refFragment) }
37+
?: continue
38+
if (!location.startsWith(otherRefFragment)) {
39+
continue
40+
}
41+
if (checkRunAlways(location) && checkRunAlways(otherLocation)) {
42+
circledReferences += CircledReference(
43+
firstLocation = location,
44+
firstRef = refFragment,
45+
secondLocation = otherLocation,
46+
secondRef = otherRefFragment,
47+
)
48+
}
49+
}
50+
require(circledReferences.isEmpty()) {
51+
"circled references: ${
52+
circledReferences.joinToString {
53+
"${it.firstLocation} ref to ${it.firstRef} and ${it.secondLocation} ref to ${it.secondRef}"
54+
}
55+
}"
56+
}
57+
}
58+
59+
private class CircledReference(
60+
val firstLocation: String,
61+
val firstRef: String,
62+
val secondLocation: String,
63+
val secondRef: String,
64+
) {
65+
override fun equals(other: Any?): Boolean {
66+
if (this === other) return true
67+
if (other == null || this::class != other::class) return false
68+
69+
other as CircledReference
70+
71+
return (firstLocation == other.firstLocation
72+
&& firstRef == other.firstRef
73+
&& secondLocation == other.secondLocation
74+
&& secondRef == other.secondRef)
75+
|| (firstLocation == other.secondLocation
76+
&& firstRef == other.secondRef
77+
&& secondLocation == other.firstLocation
78+
&& secondRef == other.firstRef)
79+
}
80+
81+
override fun hashCode(): Int {
82+
return firstLocation.hashCode() +
83+
firstRef.hashCode() +
84+
secondLocation.hashCode() +
85+
secondRef.hashCode()
86+
}
87+
}
88+
}

src/commonMain/kotlin/smirnov/oleg/json/schema/internal/SchemaLoader.kt

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import smirnov.oleg.json.pointer.JsonPointer
99
import smirnov.oleg.json.pointer.div
1010
import smirnov.oleg.json.pointer.get
1111
import smirnov.oleg.json.schema.JsonSchema
12+
import smirnov.oleg.json.schema.internal.ReferenceValidator.ReferenceLocation
1213
import smirnov.oleg.json.schema.internal.factories.array.ContainsAssertionFactory
1314
import smirnov.oleg.json.schema.internal.factories.array.ItemsAssertionFactory
1415
import smirnov.oleg.json.schema.internal.factories.array.MaxItemsAssertionFactory
@@ -67,15 +68,17 @@ private val factories: List<AssertionFactory> = listOf(
6768

6869
private const val DEFINITIONS_PROPERTY: String = "definitions"
6970
private const val ID_PROPERTY: String = "\$id"
71+
private const val REF_PROPERTY: String = "\$ref"
7072

71-
private const val rootReference = '#'
73+
internal const val rootReference = '#'
7274

7375
class SchemaLoader {
7476
fun load(schemaDefinition: JsonElement): JsonSchema {
7577
val baseId = extractBaseID(schemaDefinition)
7678
val context = defaultLoadingContext(baseId)
7779
loadDefinitions(schemaDefinition, context)
7880
val schemaAssertion = loadSchema(schemaDefinition, context)
81+
ReferenceValidator.validateReferences(context.references.keys, context.usedRef)
7982
return JsonSchema(baseId, schemaAssertion, context.references)
8083
}
8184

@@ -118,17 +121,34 @@ private fun loadSchema(
118121
FalseSchemaAssertion(path = context.schemaPath)
119122
}
120123

121-
else -> factories.filter { it.isApplicable(schemaDefinition) }
122-
.map {
123-
it.create(schemaDefinition, context)
124-
}.let(::AssertionsCollection)
124+
is JsonObject -> {
125+
if (schemaDefinition.containsKey(REF_PROPERTY)) {
126+
loadRefAssertion(schemaDefinition, context)
127+
} else {
128+
factories.filter { it.isApplicable(schemaDefinition) }
129+
.map {
130+
it.create(schemaDefinition, context)
131+
}.let(::AssertionsCollection)
132+
}
133+
}
134+
// should never happen
135+
else -> throw IllegalArgumentException("schema must be either a valid JSON object or boolean")
125136
}.apply(context::register)
126137
}
127138

139+
private fun loadRefAssertion(definition: JsonObject, context: DefaultLoadingContext): JsonSchemaAssertion {
140+
val refElement = requireNotNull(definition[REF_PROPERTY]) { "$REF_PROPERTY is not set" }
141+
require(refElement is JsonPrimitive && refElement.isString) { "$REF_PROPERTY must be a string" }
142+
val refValue = refElement.content
143+
val refId: RefId = context.ref(refValue)
144+
return RefSchemaAssertion(context.schemaPath / REF_PROPERTY, refId)
145+
}
146+
128147
private data class DefaultLoadingContext(
129148
private val baseId: String,
130149
override val schemaPath: JsonPointer = JsonPointer.ROOT,
131-
val references: MutableMap<String, JsonSchemaAssertion> = hashMapOf(),
150+
val references: MutableMap<RefId, JsonSchemaAssertion> = hashMapOf(),
151+
val usedRef: MutableSet<ReferenceLocation> = hashSetOf(),
132152
) : LoadingContext {
133153
override fun at(property: String): DefaultLoadingContext {
134154
return copy(schemaPath = schemaPath / property)
@@ -143,11 +163,17 @@ private data class DefaultLoadingContext(
143163
|| (element is JsonPrimitive && element.booleanOrNull != null))
144164

145165
fun register(assertion: JsonSchemaAssertion) {
146-
val referenceId = "$baseId$rootReference$schemaPath"
166+
val referenceId = buildRefId("$rootReference$schemaPath")
147167
references.put(referenceId, assertion)?.apply {
148168
throw IllegalStateException("duplicated definition $referenceId")
149169
}
150170
}
171+
172+
fun ref(refId: String): RefId {
173+
return buildRefId(refId).also { usedRef += ReferenceLocation(schemaPath, it) }
174+
}
175+
176+
private fun buildRefId(path: String): RefId = RefId("$baseId$path")
151177
}
152178

153179
private fun defaultLoadingContext(baseId: String): DefaultLoadingContext = DefaultLoadingContext(baseId)

src/commonTest/kotlin/smirnov/oleg/json/schema/JsonSchemaTest.kt

Lines changed: 0 additions & 36 deletions
This file was deleted.

src/commonTest/kotlin/smirnov/oleg/json/schema/assertions/array/JsonSchemaContainsValidationTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import kotlinx.serialization.json.buildJsonArray
1212
import kotlinx.serialization.json.buildJsonObject
1313
import smirnov.oleg.json.pointer.JsonPointer
1414
import smirnov.oleg.json.schema.JsonSchema
15-
import smirnov.oleg.json.schema.KEY
15+
import smirnov.oleg.json.schema.base.KEY
1616
import smirnov.oleg.json.schema.ValidationError
1717

1818
@Suppress("unused")

0 commit comments

Comments
 (0)