Skip to content

Commit 9bfbddf

Browse files
authored
The $id should be treated as a URI (#17)
Resolves #14
1 parent a2a8309 commit 9bfbddf

File tree

15 files changed

+481
-89
lines changed

15 files changed

+481
-89
lines changed

README.md

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,20 @@ from [kotlinx.serialization-json](https://github.com/Kotlin/kotlinx.serializatio
88

99
## Usage
1010

11+
### Supported targets
12+
13+
| Target |
14+
|-------------------|
15+
| jvm |
16+
| js |
17+
| macosX64 |
18+
| macosArm64 |
19+
| iosArm64 |
20+
| iosSimulatorArm64 |
21+
| linuxX64 |
22+
| linuxArm64 |
23+
| mingwX64 |
24+
1125
### Dependencies
1226

1327
#### Releases
@@ -112,12 +126,12 @@ val valid = schema.validate(elementToValidate, errors::add)
112126
- [Draft 7](https://json-schema.org/specification-links.html#draft-7)
113127
- Keywords
114128

115-
| Keyword | Status |
116-
|:------------|:-----------------------------------------------------------------------------------------------------------------------|
117-
| $id | Basic support. Only in root schema. Currently, it is interpreted as a string. Validation is in the future plans |
118-
| $schema | There is not validation of the $schema property at the moment |
119-
| $ref | Partially supported. Only references like _**#/path/in/schema**_ will work. The circled references validation is added |
120-
| definitions | Supported. Definitions are loaded and can be referenced |
129+
| Keyword | Status |
130+
|:------------|:----------------------------------------------------------------------------------------------------|
131+
| $id | Supported. $id in sub-schemas are collected as well and can be used in $ref |
132+
| $schema | Supported. Validates if schema is one of the supported schemas. The last supported is used if empty |
133+
| $ref | Supported (except references to schemas from another document) |
134+
| definitions | Supported. Definitions are loaded and can be referenced |
121135

122136
- Assertions
123137

@@ -156,7 +170,7 @@ val valid = schema.validate(elementToValidate, errors::add)
156170
## Future plans
157171

158172
- [x] Add `$schema` property validation (if not set the latest supported will be used)
159-
- [ ] Add proper `$id` support (for nested schemas and for referencing)
173+
- [x] Add proper `$id` support (for nested schemas and for referencing)
160174
- [ ] Add support for newer drafts
161175
- [ ] [Draft 2019-09 (Draft 8)](https://json-schema.org/specification-links.html#draft-2019-09-formerly-known-as-draft-8)
162176
- [ ] [2020-12](https://json-schema.org/specification-links.html#2020-12)

api/json-schema-validator.api

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@ public final class io/github/optimumcode/json/pointer/JsonPointer$Companion {
1717

1818
public final class io/github/optimumcode/json/pointer/JsonPointerExtensions {
1919
public static final fun at (Lkotlinx/serialization/json/JsonElement;Lio/github/optimumcode/json/pointer/JsonPointer;)Lkotlinx/serialization/json/JsonElement;
20+
public static final fun contains (Lio/github/optimumcode/json/pointer/JsonPointer;Ljava/lang/String;)Z
2021
public static final fun div (Lio/github/optimumcode/json/pointer/JsonPointer;Ljava/lang/String;)Lio/github/optimumcode/json/pointer/JsonPointer;
2122
public static final fun get (Lio/github/optimumcode/json/pointer/JsonPointer;I)Lio/github/optimumcode/json/pointer/JsonPointer;
2223
public static final fun plus (Lio/github/optimumcode/json/pointer/JsonPointer;Lio/github/optimumcode/json/pointer/JsonPointer;)Lio/github/optimumcode/json/pointer/JsonPointer;
2324
public static final fun relative (Lio/github/optimumcode/json/pointer/JsonPointer;Lio/github/optimumcode/json/pointer/JsonPointer;)Lio/github/optimumcode/json/pointer/JsonPointer;
25+
public static final fun startsWith (Lio/github/optimumcode/json/pointer/JsonPointer;Lio/github/optimumcode/json/pointer/JsonPointer;)Z
2426
}
2527

2628
public final class io/github/optimumcode/json/pointer/JsonPointerKt {

build.gradle.kts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,18 +45,12 @@ kotlin {
4545
nodejs()
4646
}
4747
ios()
48-
tvos()
49-
watchos()
5048

5149
val macOsTargets = listOf<KotlinTarget>(
5250
macosX64(),
5351
macosArm64(),
5452
iosArm64(),
5553
iosSimulatorArm64(),
56-
watchosArm32(),
57-
watchosSimulatorArm64(),
58-
tvosArm64(),
59-
tvosX64(),
6054
)
6155

6256
val linuxTargets = listOf<KotlinTarget>(
@@ -72,6 +66,7 @@ kotlin {
7266
val commonMain by getting {
7367
dependencies {
7468
api(libs.kotlin.serialization.json)
69+
implementation(libs.uri)
7570
}
7671
}
7772
val commonTest by getting {

gradle/libs.versions.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,5 @@ nexus-publish = { id = "io.github.gradle-nexus.publish-plugin", version = "1.3.0
1919
kotlin-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.5.1" }
2020
kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" }
2121
kotest-framework-engine = { module = "io.kotest:kotest-framework-engine", version.ref = "kotest" }
22-
kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" }
22+
kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" }
23+
uri = { group = "com.eygraber", name = "uri-kmp", version = "0.0.12" }

src/commonMain/kotlin/io/github/optimumcode/json/pointer/extensions.kt

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,70 @@ public fun JsonPointer.relative(other: JsonPointer): JsonPointer {
9696
}
9797
}
9898

99+
/**
100+
* Checks whether the current [JsonPointer] starts with [other].
101+
* Returns `true` if it is so, otherwise returns `false`.
102+
*
103+
* **Every [JsonPointer] starts with [JsonPointer.ROOT].**
104+
*
105+
* **[JsonPointer.ROOT] starts only with [JsonPointer.ROOT].**
106+
*
107+
* Example:
108+
* ```kotlin
109+
* JsonPointer.ROOT.startsWith(JsonPointer("/path")) // false
110+
* JsonPointer.ROOT.startsWith(JsonPointer.ROOT) // true
111+
* JsonPointer("/path").startsWith(JsonPointer.ROOT) // true
112+
* JsonPointer("/path/to/node").startsWith(JsonPointer("/path")) // true
113+
* ```
114+
*/
115+
public fun JsonPointer.startsWith(other: JsonPointer): Boolean {
116+
var primary: JsonPointer? = this
117+
var secondary: JsonPointer? = other
118+
while (primary != null && secondary != null) {
119+
if (secondary is EmptyPointer) {
120+
// secondary has finished. Means primary starts with secondary
121+
return true
122+
}
123+
if (primary is EmptyPointer) {
124+
// primary has finished but secondary is not
125+
// means primary does not start with secondary
126+
return false
127+
}
128+
primary as SegmentPointer
129+
secondary as SegmentPointer
130+
if (primary.propertyName != secondary.propertyName) {
131+
return false
132+
}
133+
primary = primary.next
134+
secondary = secondary.next
135+
}
136+
return secondary == null
137+
}
138+
139+
/**
140+
* Checks whether the [JsonPointer] contains specified [pathSegment]
141+
*
142+
* **[JsonPointer.ROOT] does not contain any path segments**
143+
*
144+
* Example:
145+
*
146+
* ```kotlin
147+
* JsonPointer.ROOT.contains("path") // false
148+
* JsonPointer("/test/path/to/node").contains("path") // true
149+
* JsonPointer("/test/path/to/node").contains("anotherPath") // false
150+
* ```
151+
*/
152+
public operator fun JsonPointer.contains(pathSegment: String): Boolean {
153+
var segment: JsonPointer? = this
154+
while (segment != null) {
155+
if (segment is SegmentPointer && segment.propertyName == pathSegment) {
156+
return true
157+
}
158+
segment = segment.next
159+
}
160+
return false
161+
}
162+
99163
/**
100164
* Extracts [JsonElement] from the current JSON element that corresponds to the specified [JsonPointer].
101165
*

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.github.optimumcode.json.schema
22

33
import io.github.optimumcode.json.pointer.JsonPointer
4+
import io.github.optimumcode.json.schema.internal.AssertionWithPath
45
import io.github.optimumcode.json.schema.internal.DefaultAssertionContext
56
import io.github.optimumcode.json.schema.internal.JsonSchemaAssertion
67
import io.github.optimumcode.json.schema.internal.RefId
@@ -15,7 +16,7 @@ import kotlin.jvm.JvmStatic
1516
*/
1617
public class JsonSchema internal constructor(
1718
private val assertion: JsonSchemaAssertion,
18-
private val references: Map<RefId, JsonSchemaAssertion>,
19+
private val references: Map<RefId, AssertionWithPath>,
1920
) {
2021
/**
2122
* Validates [value] against this [JsonSchema].

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,21 @@ internal interface AssertionContext {
99

1010
fun at(index: Int): AssertionContext
1111
fun at(property: String): AssertionContext
12-
fun resolveRef(refId: RefId): JsonSchemaAssertion
12+
fun resolveRef(refId: RefId): Pair<JsonPointer, JsonSchemaAssertion>
1313
}
1414

1515
internal data class DefaultAssertionContext(
1616
override val objectPath: JsonPointer,
17-
private val references: Map<RefId, JsonSchemaAssertion>,
17+
private val references: Map<RefId, AssertionWithPath>,
1818
) : AssertionContext {
1919
override fun at(index: Int): AssertionContext = copy(objectPath = objectPath[index])
2020

2121
override fun at(property: String): AssertionContext {
2222
return copy(objectPath = objectPath / property)
2323
}
2424

25-
override fun resolveRef(refId: RefId): JsonSchemaAssertion {
26-
return requireNotNull(references[refId]) { "$refId is not found" }
25+
override fun resolveRef(refId: RefId): Pair<JsonPointer, JsonSchemaAssertion> {
26+
val resolvedRef = requireNotNull(references[refId]) { "$refId is not found" }
27+
return resolvedRef.schemaPath to resolvedRef.assertion
2728
}
2829
}
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
package io.github.optimumcode.json.schema.internal
22

3+
import com.eygraber.uri.Uri
34
import kotlin.jvm.JvmInline
45

56
@JvmInline
6-
internal value class RefId(private val id: String) {
7+
internal value class RefId(val uri: Uri) {
78
val fragment: String
8-
get() = id.substringAfter(ROOT_REFERENCE)
9+
get() = uri.fragment ?: ""
910
}

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@ internal class RefSchemaAssertion(
1010
private val basePath: JsonPointer,
1111
private val refId: RefId,
1212
) : JsonSchemaAssertion {
13-
private val refIdPath: JsonPointer =
14-
JsonPointer(refId.fragment)
13+
private lateinit var refIdPath: JsonPointer
1514
private lateinit var refAssertion: JsonSchemaAssertion
1615

1716
override fun validate(element: JsonElement, context: AssertionContext, errorCollector: ErrorCollector): Boolean {
1817
if (!::refAssertion.isInitialized) {
19-
refAssertion = context.resolveRef(refId)
18+
val resolved = context.resolveRef(refId)
19+
refIdPath = resolved.first
20+
refAssertion = resolved.second
2021
}
2122
return refAssertion.validate(element, context) {
2223
errorCollector.onError(

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

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,58 @@
11
package io.github.optimumcode.json.schema.internal
22

33
import io.github.optimumcode.json.pointer.JsonPointer
4+
import io.github.optimumcode.json.pointer.contains
5+
import io.github.optimumcode.json.pointer.startsWith
46

57
internal object ReferenceValidator {
68
data class ReferenceLocation(
79
val schemaPath: JsonPointer,
810
val refId: RefId,
911
)
10-
fun validateReferences(references: Set<RefId>, usedRef: Set<ReferenceLocation>) {
12+
fun validateReferences(
13+
referencesWithPath: Map<RefId, JsonPointer>,
14+
usedRef: Set<ReferenceLocation>,
15+
) {
1116
val missingRefs: Map<RefId, List<ReferenceLocation>> = usedRef.asSequence()
12-
.filter { it.refId !in references }
17+
.filter { it.refId !in referencesWithPath }
1318
.groupBy { it.refId }
1419
require(missingRefs.isEmpty()) {
1520
"cannot resolve references: ${
1621
missingRefs.entries.joinToString(prefix = "{", postfix = "}") { (ref, locations) ->
17-
"\"${ref.fragment}\": ${locations.map { "\"${it.schemaPath}\"" }}"
22+
"\"${ref.uri}\": ${locations.map { "\"${it.schemaPath}\"" }}"
1823
}
1924
}"
2025
}
21-
checkCircledReferences(usedRef)
26+
checkCircledReferences(usedRef, referencesWithPath)
2227
}
2328

24-
private val alwaysRunAssertions = hashSetOf("/allOf/", "/anyOf/", "/oneOf/")
29+
private val alwaysRunAssertions = hashSetOf("allOf", "anyOf", "oneOf")
2530

26-
private fun checkCircledReferences(usedRefs: Set<ReferenceLocation>) {
27-
val locationToRef: Map<String, String> = usedRefs.associate { (schemaPath, refId) ->
28-
schemaPath.toString() to refId.fragment
31+
private fun checkCircledReferences(usedRefs: Set<ReferenceLocation>, referencesWithPath: Map<RefId, JsonPointer>) {
32+
val locationToRef: Map<JsonPointer, RefId> = usedRefs.associate { (schemaPath, refId) ->
33+
schemaPath to refId
2934
}
3035

3136
val circledReferences = hashSetOf<CircledReference>()
32-
fun checkRunAlways(path: String): Boolean {
37+
fun checkRunAlways(path: JsonPointer): Boolean {
3338
return alwaysRunAssertions.any { path.contains(it) }
3439
}
35-
for ((location, refFragment) in locationToRef) {
36-
val (otherLocation, otherRefFragment) = locationToRef.entries.find { (refKey) ->
37-
val startsWith = refKey.startsWith(refFragment)
38-
startsWith && (refKey[refFragment.length] == JsonPointer.SEPARATOR || refKey == refFragment)
40+
for ((location, refId) in locationToRef) {
41+
val schemaLocation: JsonPointer = referencesWithPath.getValue(refId)
42+
43+
val (otherLocation, otherRef) = locationToRef.entries.find { (refKey) ->
44+
refKey.startsWith(schemaLocation)
3945
} ?: continue
40-
if (!location.startsWith(otherRefFragment)) {
46+
val otherRefSchemaLocation: JsonPointer = referencesWithPath.getValue(otherRef)
47+
if (!location.startsWith(otherRefSchemaLocation)) {
4148
continue
4249
}
4350
if (checkRunAlways(location) && checkRunAlways(otherLocation)) {
4451
circledReferences += CircledReference(
4552
firstLocation = location,
46-
firstRef = refFragment,
53+
firstRef = schemaLocation,
4754
secondLocation = otherLocation,
48-
secondRef = otherRefFragment,
55+
secondRef = otherRefSchemaLocation,
4956
)
5057
}
5158
}
@@ -59,10 +66,10 @@ internal object ReferenceValidator {
5966
}
6067

6168
private class CircledReference(
62-
val firstLocation: String,
63-
val firstRef: String,
64-
val secondLocation: String,
65-
val secondRef: String,
69+
val firstLocation: JsonPointer,
70+
val firstRef: JsonPointer,
71+
val secondLocation: JsonPointer,
72+
val secondRef: JsonPointer,
6673
) {
6774
override fun equals(other: Any?): Boolean {
6875
if (this === other) return true

0 commit comments

Comments
 (0)