Skip to content

Commit 119f2a3

Browse files
authored
Make library comply to official JSON schema repository test-suites (#19)
Resolves #15
1 parent 9bfbddf commit 119f2a3

File tree

45 files changed

+894
-68
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+894
-68
lines changed

.github/workflows/check.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ jobs:
1818
steps:
1919
- name: 'Checkout Repository'
2020
uses: actions/checkout@v3
21+
with:
22+
submodules: true
2123
- uses: actions/setup-java@v3
2224
with:
2325
distribution: temurin

.github/workflows/release.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ jobs:
3131
steps:
3232
- name: 'Checkout Repository'
3333
uses: actions/checkout@v3
34+
with:
35+
submodules: true
3436
- uses: actions/setup-java@v3
3537
with:
3638
distribution: temurin

.github/workflows/snapshot_release.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ jobs:
1111
steps:
1212
- name: 'Checkout Repository'
1313
uses: actions/checkout@v3
14+
with:
15+
submodules: true
1416
- uses: actions/setup-java@v3
1517
with:
1618
distribution: temurin

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[submodule "schema-test-suite"]
2+
path = test-suites/schema-test-suite
3+
url = [email protected]:json-schema-org/JSON-Schema-Test-Suite.git

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,13 @@ val valid = schema.validate(elementToValidate, errors::add)
167167
| | oneOf | Supported |
168168
| | not | Supported |
169169

170+
## Compliance to JSON schema test suites
171+
172+
This library uses official [JSON schema test suites](https://github.com/json-schema-org/JSON-Schema-Test-Suite)
173+
as a part of the CI to make sure the validation meet the expected behavior.
174+
Not everything is supported right now but the missing functionality might be added in the future.
175+
The test are located [here](test-suites).
176+
170177
## Future plans
171178

172179
- [x] Add `$schema` property validation (if not set the latest supported will be used)

gradle/libs.versions.toml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ kotlin = "1.8.22"
33
kotest = "5.5.4"
44
detekt = "1.23.0"
55
ktlint = "0.50.0"
6+
okio = "3.4.0"
7+
serialization = "1.5.1"
68

79
[plugins]
810
kotlin-mutliplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
@@ -16,8 +18,11 @@ kotlin-binaryCompatibility = { id = "org.jetbrains.kotlinx.binary-compatibility-
1618
nexus-publish = { id = "io.github.gradle-nexus.publish-plugin", version = "1.3.0" }
1719

1820
[libraries]
19-
kotlin-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.5.1" }
21+
kotlin-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
22+
kotlin-serialization-json-okio = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json-okio", version.ref = "serialization" }
2023
kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" }
2124
kotest-framework-engine = { module = "io.kotest:kotest-framework-engine", version.ref = "kotest" }
2225
kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" }
23-
uri = { group = "com.eygraber", name = "uri-kmp", version = "0.0.12" }
26+
uri = { group = "com.eygraber", name = "uri-kmp", version = "0.0.12" }
27+
okio-common = { group = "com.squareup.okio", name = "okio", version.ref = "okio" }
28+
okio-nodefilesystem = { group = "com.squareup.okio", name = "okio-nodefilesystem", version.ref = "okio" }

settings.gradle.kts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
rootProject.name = "json-schema-validator"
1+
rootProject.name = "json-schema-validator"
2+
3+
include(":test-suites")

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

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,7 @@ public sealed class JsonPointer(
3333
buildString {
3434
val pointer = this@JsonPointer.toString()
3535
append(pointer)
36-
if (!pointer.endsWith(SEPARATOR)) {
37-
append(SEPARATOR)
38-
}
36+
append(SEPARATOR)
3937
append(index)
4038
},
4139
)
@@ -53,10 +51,14 @@ public sealed class JsonPointer(
5351
buildString {
5452
val pointer = this@JsonPointer.toString()
5553
append(pointer)
56-
if (!pointer.endsWith(SEPARATOR)) {
57-
append(SEPARATOR)
54+
append(SEPARATOR)
55+
for (ch in property) {
56+
when (ch) {
57+
QUOTATION -> append(QUOTATION).append(QUOTATION_ESCAPE)
58+
SEPARATOR -> append(QUOTATION).append(SEPARATOR_ESCAPE)
59+
else -> append(ch)
60+
}
5861
}
59-
append(property)
6062
},
6163
)
6264

@@ -87,6 +89,8 @@ public sealed class JsonPointer(
8789
public companion object {
8890
internal const val SEPARATOR: Char = '/'
8991
internal const val QUOTATION: Char = '~'
92+
internal const val QUOTATION_ESCAPE: Char = '0'
93+
internal const val SEPARATOR_ESCAPE: Char = '1'
9094

9195
/**
9296
* An empty [JsonPointer]. The empty JSON pointer corresponds to the current JSON element.s
@@ -205,8 +209,8 @@ private fun StringBuilder.appendEscapedSegment(expr: String, start: Int, offset:
205209

206210
private fun StringBuilder.appendEscaped(ch: Char) {
207211
val result = when (ch) {
208-
'0' -> JsonPointer.QUOTATION
209-
'1' -> JsonPointer.SEPARATOR
212+
JsonPointer.QUOTATION_ESCAPE -> JsonPointer.QUOTATION
213+
JsonPointer.SEPARATOR_ESCAPE -> JsonPointer.SEPARATOR
210214
else -> {
211215
append(JsonPointer.QUOTATION)
212216
ch

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

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,8 @@ public operator fun JsonPointer.plus(otherPointer: JsonPointer): JsonPointer {
5757
}
5858
return JsonPointer(
5959
buildString {
60-
val pointer = this@plus.toString()
61-
append(pointer)
62-
if (pointer.endsWith(JsonPointer.SEPARATOR)) {
63-
setLength(length - 1)
64-
}
65-
val other = otherPointer.toString()
66-
append(other)
60+
append(this@plus.toString())
61+
append(otherPointer.toString())
6762
},
6863
)
6964
}

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

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,8 @@ private data class DefaultLoadingContext(
195195
private val baseId: Uri,
196196
override val schemaPath: JsonPointer = JsonPointer.ROOT,
197197
val additionalIDs: Set<IdWithLocation> = linkedSetOf(IdWithLocation(baseId, schemaPath)),
198-
val references: MutableMap<RefId, AssertionWithPath> = hashMapOf(),
199-
val usedRef: MutableSet<ReferenceLocation> = hashSetOf(),
198+
val references: MutableMap<RefId, AssertionWithPath> = linkedMapOf(),
199+
val usedRef: MutableSet<ReferenceLocation> = linkedSetOf(),
200200
) : LoadingContext {
201201
override fun at(property: String): DefaultLoadingContext {
202202
return copy(schemaPath = schemaPath / property)
@@ -237,7 +237,7 @@ private data class DefaultLoadingContext(
237237
copy(
238238
additionalIDs = additionalIDs.run {
239239
this + IdWithLocation(
240-
baseId.buildUpon().encodedPath(additionalId.path).build(),
240+
additionalIDs.resolvePath(additionalId.path),
241241
schemaPath,
242242
)
243243
},
@@ -255,7 +255,10 @@ private data class DefaultLoadingContext(
255255
val refUri = Uri.parse(refId).buildUpon().build()
256256
return when {
257257
refUri.isAbsolute -> refUri.buildRefId()
258-
!refUri.path.isNullOrBlank() -> baseId.buildUpon().encodedPath(refUri.path).buildRefId()
258+
// the ref is absolute and should be resolved from current base URI host:port part
259+
refId.startsWith('/') -> additionalIDs.last().id.buildUpon().encodedPath(refUri.path).buildRefId()
260+
// in this case the ref must be resolved from the current base ID
261+
!refUri.path.isNullOrBlank() -> additionalIDs.resolvePath(refUri.path).buildRefId()
259262
refUri.fragment != null -> additionalIDs.last().id.buildUpon().encodedFragment(refUri.fragment).buildRefId()
260263
else -> throw IllegalArgumentException("invalid reference $refId")
261264
}.also { usedRef += ReferenceLocation(schemaPath, it) }
@@ -271,7 +274,7 @@ private data class DefaultLoadingContext(
271274
when {
272275
!id.path.isNullOrBlank() -> register(
273276
// register JSON schema by related path
274-
baseId.buildUpon().encodedPath(id.path).buildRefId(),
277+
additionalIDs.resolvePath(id.path).buildRefId(),
275278
assertion,
276279
)
277280

@@ -294,6 +297,24 @@ private data class DefaultLoadingContext(
294297
}
295298
}
296299

300+
private fun Set<IdWithLocation>.resolvePath(path: String?): Uri {
301+
return last().id.appendPathToParent(requireNotNull(path) { "path is null" })
302+
}
303+
private fun Uri.appendPathToParent(path: String): Uri {
304+
val hasLastEmptySegment = toString().endsWith('/')
305+
return if (hasLastEmptySegment) {
306+
buildUpon() // don't need to drop anything. just add the path because / in the end means empty segment
307+
} else {
308+
buildUpon()
309+
.path(null) // reset path in builder
310+
.apply {
311+
pathSegments.asSequence()
312+
.take(pathSegments.size - 1) // drop last path segment
313+
.forEach(this::appendPath)
314+
}
315+
}.appendEncodedPath(path)
316+
.build()
317+
}
297318
private fun Uri.buildRefId(): RefId = RefId(this)
298319

299320
private fun Builder.buildRefId(): RefId = build().buildRefId()

src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/array/MaxItemsAssertionFactory.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@ package io.github.optimumcode.json.schema.internal.factories.array
33
import io.github.optimumcode.json.schema.internal.JsonSchemaAssertion
44
import io.github.optimumcode.json.schema.internal.LoadingContext
55
import io.github.optimumcode.json.schema.internal.factories.AbstractAssertionFactory
6+
import io.github.optimumcode.json.schema.internal.util.integerOrNull
67
import kotlinx.serialization.json.JsonElement
78
import kotlinx.serialization.json.JsonPrimitive
8-
import kotlinx.serialization.json.intOrNull
99

1010
@Suppress("unused")
1111
internal object MaxItemsAssertionFactory : AbstractAssertionFactory("maxItems") {
1212
override fun createFromProperty(element: JsonElement, context: LoadingContext): JsonSchemaAssertion {
1313
require(element is JsonPrimitive && !element.isString) { "$property must be an integer" }
14-
val maxItemsValue = requireNotNull(element.intOrNull) { "$property must be a valid integer" }
14+
val maxItemsValue = requireNotNull(element.integerOrNull) { "$property must be a valid integer" }
1515
require(maxItemsValue >= 0) { "$property must be a non-negative integer" }
1616
return ArrayLengthAssertion(
1717
context.schemaPath,

src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/array/MinItemsAssertionFactory.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@ package io.github.optimumcode.json.schema.internal.factories.array
33
import io.github.optimumcode.json.schema.internal.JsonSchemaAssertion
44
import io.github.optimumcode.json.schema.internal.LoadingContext
55
import io.github.optimumcode.json.schema.internal.factories.AbstractAssertionFactory
6+
import io.github.optimumcode.json.schema.internal.util.integerOrNull
67
import kotlinx.serialization.json.JsonElement
78
import kotlinx.serialization.json.JsonPrimitive
8-
import kotlinx.serialization.json.intOrNull
99

1010
@Suppress("unused")
1111
internal object MinItemsAssertionFactory : AbstractAssertionFactory("minItems") {
1212
override fun createFromProperty(element: JsonElement, context: LoadingContext): JsonSchemaAssertion {
1313
require(element is JsonPrimitive && !element.isString) { "$property must be an integer" }
14-
val maxItemsValue = requireNotNull(element.intOrNull) { "$property must be a valid integer" }
14+
val maxItemsValue = requireNotNull(element.integerOrNull) { "$property must be a valid integer" }
1515
require(maxItemsValue >= 0) { "$property must be a non-negative integer" }
1616
return ArrayLengthAssertion(
1717
context.schemaPath,

src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/array/UniqueItemsAssertionFactory.kt

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import io.github.optimumcode.json.schema.internal.JsonSchemaAssertion
88
import io.github.optimumcode.json.schema.internal.LoadingContext
99
import io.github.optimumcode.json.schema.internal.TrueSchemaAssertion
1010
import io.github.optimumcode.json.schema.internal.factories.AbstractAssertionFactory
11+
import io.github.optimumcode.json.schema.internal.util.areEqual
1112
import kotlinx.serialization.json.JsonArray
1213
import kotlinx.serialization.json.JsonElement
1314
import kotlinx.serialization.json.JsonPrimitive
@@ -36,21 +37,34 @@ private class UniqueItemsAssertion(
3637
if (element.size < 2) {
3738
return true
3839
}
39-
val uniqueItems = element.mapTo(linkedSetOf()) { it }
40+
var duplicates: MutableList<JsonElement>? = null
41+
val uniqueItems = buildList {
42+
element.forEach { el ->
43+
if (none { areEqual(it, el) }) {
44+
add(el)
45+
} else {
46+
if (duplicates == null) {
47+
duplicates = mutableListOf()
48+
}
49+
duplicates?.add(el)
50+
}
51+
}
52+
}
4053
val uniqueItemsCount = uniqueItems.size
4154
if (uniqueItemsCount == element.size) {
4255
return true
4356
}
44-
uniqueItems.clear()
4557
errorCollector.onError(
4658
ValidationError(
4759
schemaPath = path,
4860
objectPath = context.objectPath,
49-
message = "array contains duplicate values: ${element.asSequence().filter(uniqueItems::add).joinToString(
50-
prefix = "[",
51-
postfix = "]",
52-
separator = ",",
53-
)}",
61+
message = "array contains duplicate values: ${
62+
duplicates?.joinToString(
63+
prefix = "[",
64+
postfix = "]",
65+
separator = ",",
66+
)
67+
}",
5468
),
5569
)
5670
return false

src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/condition/IfThenElseAssertionFactory.kt

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,34 +5,37 @@ import io.github.optimumcode.json.schema.internal.AssertionContext
55
import io.github.optimumcode.json.schema.internal.AssertionFactory
66
import io.github.optimumcode.json.schema.internal.JsonSchemaAssertion
77
import io.github.optimumcode.json.schema.internal.LoadingContext
8+
import io.github.optimumcode.json.schema.internal.TrueSchemaAssertion
89
import kotlinx.serialization.json.JsonElement
910
import kotlinx.serialization.json.JsonObject
1011

1112
internal object IfThenElseAssertionFactory : AssertionFactory {
12-
private const val ifProperty: String = "if"
13-
private const val thenProperty: String = "then"
14-
private const val elseProperty: String = "else"
13+
private const val IF_PROPERTY: String = "if"
14+
private const val THEN_PROPERTY: String = "then"
15+
private const val ELSE_PROPERTY: String = "else"
1516

1617
override fun isApplicable(element: JsonElement): Boolean {
1718
return element is JsonObject && element.run {
18-
// there is not point to extract the assertion when only `if` is present
19-
containsKey(ifProperty) && (containsKey(thenProperty) || containsKey(elseProperty))
19+
// we need to load all definitions because they can be referenced
20+
containsKey(IF_PROPERTY) || containsKey(THEN_PROPERTY) || containsKey(ELSE_PROPERTY)
2021
}
2122
}
2223

2324
override fun create(element: JsonElement, context: LoadingContext): JsonSchemaAssertion {
2425
require(element is JsonObject) { "cannot extract properties from ${element::class.simpleName}" }
25-
val ifElement = requireNotNull(element[ifProperty]) { "no property $ifProperty found in element $element" }
26-
require(context.isJsonSchema(ifElement)) { "$ifProperty must be a valid JSON schema" }
27-
val ifAssertion: JsonSchemaAssertion = context.at(ifProperty).schemaFrom(ifElement)
26+
val ifElement: JsonElement? = element[IF_PROPERTY]?.apply {
27+
require(context.isJsonSchema(this)) { "$IF_PROPERTY must be a valid JSON schema" }
28+
}
29+
val ifAssertion: JsonSchemaAssertion? = ifElement?.let(context.at(IF_PROPERTY)::schemaFrom)
2830

29-
val thenAssertion: JsonSchemaAssertion? = loadOptionalAssertion(element, thenProperty, context)
30-
val elseAssertion: JsonSchemaAssertion? = loadOptionalAssertion(element, elseProperty, context)
31+
val thenAssertion: JsonSchemaAssertion? = loadOptionalAssertion(element, THEN_PROPERTY, context)
32+
val elseAssertion: JsonSchemaAssertion? = loadOptionalAssertion(element, ELSE_PROPERTY, context)
3133

32-
require(thenAssertion != null || elseAssertion != null) {
33-
"either $thenProperty or $elseProperty must be specified"
34+
return when {
35+
ifAssertion == null -> TrueSchemaAssertion // no if -> no effect
36+
thenAssertion == null && elseAssertion == null -> TrueSchemaAssertion // only if - no effect
37+
else -> IfThenElseAssertion(ifAssertion, thenAssertion, elseAssertion)
3438
}
35-
return IfThenElseAssertion(ifAssertion, thenAssertion, elseAssertion)
3639
}
3740

3841
private fun loadOptionalAssertion(

src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/general/ConstAssertionFactory.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import io.github.optimumcode.json.schema.internal.AssertionContext
77
import io.github.optimumcode.json.schema.internal.JsonSchemaAssertion
88
import io.github.optimumcode.json.schema.internal.LoadingContext
99
import io.github.optimumcode.json.schema.internal.factories.AbstractAssertionFactory
10+
import io.github.optimumcode.json.schema.internal.util.areEqual
1011
import kotlinx.serialization.json.JsonElement
1112

1213
@Suppress("unused")
@@ -21,7 +22,7 @@ private class ConstAssertion(
2122
private val constValue: JsonElement,
2223
) : JsonSchemaAssertion {
2324
override fun validate(element: JsonElement, context: AssertionContext, errorCollector: ErrorCollector): Boolean {
24-
if (element == constValue) {
25+
if (areEqual(element, constValue)) {
2526
return true
2627
}
2728
errorCollector.onError(

src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/general/EnumAssertionFactory.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import io.github.optimumcode.json.schema.internal.AssertionContext
77
import io.github.optimumcode.json.schema.internal.JsonSchemaAssertion
88
import io.github.optimumcode.json.schema.internal.LoadingContext
99
import io.github.optimumcode.json.schema.internal.factories.AbstractAssertionFactory
10+
import io.github.optimumcode.json.schema.internal.util.areEqual
1011
import kotlinx.serialization.json.JsonArray
1112
import kotlinx.serialization.json.JsonElement
1213

@@ -29,7 +30,7 @@ private class EnumAssertion(
2930
}
3031

3132
override fun validate(element: JsonElement, context: AssertionContext, errorCollector: ErrorCollector): Boolean {
32-
if (possibleElements.contains(element)) {
33+
if (possibleElements.any { areEqual(it, element) }) {
3334
return true
3435
}
3536
errorCollector.onError(

src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/general/TypeAssertionFactory.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import io.github.optimumcode.json.schema.internal.AssertionContext
77
import io.github.optimumcode.json.schema.internal.JsonSchemaAssertion
88
import io.github.optimumcode.json.schema.internal.LoadingContext
99
import io.github.optimumcode.json.schema.internal.factories.AbstractAssertionFactory
10+
import io.github.optimumcode.json.schema.internal.util.parseNumberParts
1011
import kotlinx.serialization.json.JsonArray
1112
import kotlinx.serialization.json.JsonElement
1213
import kotlinx.serialization.json.JsonNull
@@ -23,7 +24,7 @@ internal object TypeAssertionFactory : AbstractAssertionFactory("type") {
2324
"string" to { it is JsonPrimitive && it.isString },
2425
"boolean" to { it is JsonPrimitive && !it.isString && it.booleanOrNull != null },
2526
"number" to { it is JsonPrimitive && !it.isString && (it.doubleOrNull != null || it.longOrNull != null) },
26-
"integer" to { it is JsonPrimitive && !it.isString && it.longOrNull != null },
27+
"integer" to { it is JsonPrimitive && !it.isString && parseNumberParts(it)?.fractional == 0L },
2728
"array" to { it is JsonArray },
2829
"object" to { it is JsonObject },
2930
).mapValues { Validation(it.key, it.value) }

0 commit comments

Comments
 (0)