Skip to content
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

Make library comply to official JSON schema repository test-suites #19

Merged
merged 27 commits into from
Jul 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
7dc6105
Add schema-test-suite repository as submodule
OptimumCode Jul 25, 2023
c283dee
Move schema test suites
OptimumCode Jul 26, 2023
616e33f
Implement test suites base. Add test suite for draft7
OptimumCode Jul 26, 2023
62370a0
Init submodules
OptimumCode Jul 26, 2023
646a59b
Correct the equailty check for numbers
OptimumCode Jul 26, 2023
9dbc2a7
Correct enum assertion to correctly work with numbers
OptimumCode Jul 26, 2023
2613504
Correct type to correctly treat numbers with zero fraction part as in…
OptimumCode Jul 26, 2023
09d449d
Handle engineering number format
OptimumCode Jul 26, 2023
27fcd20
Treat numbers with zero fraction as valid integers
OptimumCode Jul 26, 2023
6a9107a
Correct unique items assertion to correctly work with numbers
OptimumCode Jul 26, 2023
c6b2ce3
Correct multipleOf assertion to work with small numbers
OptimumCode Jul 26, 2023
a91d079
Correct number comparison
OptimumCode Jul 26, 2023
26d43d4
Reformat code. Correct detekt errors
OptimumCode Jul 26, 2023
dd1d149
Fix incorrect precision computation
OptimumCode Jul 26, 2023
ff1ce0a
Use linked maps to keep reproducable order
OptimumCode Jul 27, 2023
e62c586
Exclude unsupported functionality from test-suites
OptimumCode Jul 27, 2023
640f122
Add proper support for unicode characters
OptimumCode Jul 27, 2023
9fa6d4d
Load all if-then-else assertions because they might be referenced
OptimumCode Jul 27, 2023
e501479
Correct work with empty path segment in JSON pointer
OptimumCode Jul 27, 2023
f5174a6
Add quotation for special characters when adding segment to pointer
OptimumCode Jul 27, 2023
2fc0a7b
Correct uri resolution
OptimumCode Jul 28, 2023
a8ab2e6
Add resolution for test suites for nodejs test run
OptimumCode Jul 28, 2023
8b2156c
Remove todo comment. Create an issue for that instead
OptimumCode Jul 28, 2023
b667649
Correct test name to correctly work on windows
OptimumCode Jul 28, 2023
a663df4
Correct formatting
OptimumCode Jul 28, 2023
ea6828a
Use chars instead of string for quotation
OptimumCode Jul 28, 2023
7036f8a
Add note about test suite compliance
OptimumCode Jul 28, 2023
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
2 changes: 2 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ jobs:
steps:
- name: 'Checkout Repository'
uses: actions/checkout@v3
with:
submodules: true
- uses: actions/setup-java@v3
with:
distribution: temurin
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ jobs:
steps:
- name: 'Checkout Repository'
uses: actions/checkout@v3
with:
submodules: true
- uses: actions/setup-java@v3
with:
distribution: temurin
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/snapshot_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ jobs:
steps:
- name: 'Checkout Repository'
uses: actions/checkout@v3
with:
submodules: true
- uses: actions/setup-java@v3
with:
distribution: temurin
Expand Down
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "schema-test-suite"]
path = test-suites/schema-test-suite
url = [email protected]:json-schema-org/JSON-Schema-Test-Suite.git
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,13 @@ val valid = schema.validate(elementToValidate, errors::add)
| | oneOf | Supported |
| | not | Supported |

## Compliance to JSON schema test suites

This library uses official [JSON schema test suites](https://github.com/json-schema-org/JSON-Schema-Test-Suite)
as a part of the CI to make sure the validation meet the expected behavior.
Not everything is supported right now but the missing functionality might be added in the future.
The test are located [here](test-suites).

## Future plans

- [x] Add `$schema` property validation (if not set the latest supported will be used)
Expand Down
9 changes: 7 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ kotlin = "1.8.22"
kotest = "5.5.4"
detekt = "1.23.0"
ktlint = "0.50.0"
okio = "3.4.0"
serialization = "1.5.1"

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

[libraries]
kotlin-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.5.1" }
kotlin-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
kotlin-serialization-json-okio = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json-okio", version.ref = "serialization" }
kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" }
kotest-framework-engine = { module = "io.kotest:kotest-framework-engine", version.ref = "kotest" }
kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" }
uri = { group = "com.eygraber", name = "uri-kmp", version = "0.0.12" }
uri = { group = "com.eygraber", name = "uri-kmp", version = "0.0.12" }
okio-common = { group = "com.squareup.okio", name = "okio", version.ref = "okio" }
okio-nodefilesystem = { group = "com.squareup.okio", name = "okio-nodefilesystem", version.ref = "okio" }
4 changes: 3 additions & 1 deletion settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
rootProject.name = "json-schema-validator"
rootProject.name = "json-schema-validator"

include(":test-suites")
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,7 @@ public sealed class JsonPointer(
buildString {
val pointer = [email protected]()
append(pointer)
if (!pointer.endsWith(SEPARATOR)) {
append(SEPARATOR)
}
append(SEPARATOR)
append(index)
},
)
Expand All @@ -53,10 +51,14 @@ public sealed class JsonPointer(
buildString {
val pointer = [email protected]()
append(pointer)
if (!pointer.endsWith(SEPARATOR)) {
append(SEPARATOR)
append(SEPARATOR)
for (ch in property) {
when (ch) {
QUOTATION -> append(QUOTATION).append(QUOTATION_ESCAPE)
SEPARATOR -> append(QUOTATION).append(SEPARATOR_ESCAPE)
else -> append(ch)
}
}
append(property)
},
)

Expand Down Expand Up @@ -87,6 +89,8 @@ public sealed class JsonPointer(
public companion object {
internal const val SEPARATOR: Char = '/'
internal const val QUOTATION: Char = '~'
internal const val QUOTATION_ESCAPE: Char = '0'
internal const val SEPARATOR_ESCAPE: Char = '1'

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

private fun StringBuilder.appendEscaped(ch: Char) {
val result = when (ch) {
'0' -> JsonPointer.QUOTATION
'1' -> JsonPointer.SEPARATOR
JsonPointer.QUOTATION_ESCAPE -> JsonPointer.QUOTATION
JsonPointer.SEPARATOR_ESCAPE -> JsonPointer.SEPARATOR
else -> {
append(JsonPointer.QUOTATION)
ch
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,8 @@ public operator fun JsonPointer.plus(otherPointer: JsonPointer): JsonPointer {
}
return JsonPointer(
buildString {
val pointer = [email protected]()
append(pointer)
if (pointer.endsWith(JsonPointer.SEPARATOR)) {
setLength(length - 1)
}
val other = otherPointer.toString()
append(other)
append([email protected]())
append(otherPointer.toString())
},
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,8 @@ private data class DefaultLoadingContext(
private val baseId: Uri,
override val schemaPath: JsonPointer = JsonPointer.ROOT,
val additionalIDs: Set<IdWithLocation> = linkedSetOf(IdWithLocation(baseId, schemaPath)),
val references: MutableMap<RefId, AssertionWithPath> = hashMapOf(),
val usedRef: MutableSet<ReferenceLocation> = hashSetOf(),
val references: MutableMap<RefId, AssertionWithPath> = linkedMapOf(),
val usedRef: MutableSet<ReferenceLocation> = linkedSetOf(),
) : LoadingContext {
override fun at(property: String): DefaultLoadingContext {
return copy(schemaPath = schemaPath / property)
Expand Down Expand Up @@ -237,7 +237,7 @@ private data class DefaultLoadingContext(
copy(
additionalIDs = additionalIDs.run {
this + IdWithLocation(
baseId.buildUpon().encodedPath(additionalId.path).build(),
additionalIDs.resolvePath(additionalId.path),
schemaPath,
)
},
Expand All @@ -255,7 +255,10 @@ private data class DefaultLoadingContext(
val refUri = Uri.parse(refId).buildUpon().build()
return when {
refUri.isAbsolute -> refUri.buildRefId()
!refUri.path.isNullOrBlank() -> baseId.buildUpon().encodedPath(refUri.path).buildRefId()
// the ref is absolute and should be resolved from current base URI host:port part
refId.startsWith('/') -> additionalIDs.last().id.buildUpon().encodedPath(refUri.path).buildRefId()
// in this case the ref must be resolved from the current base ID
!refUri.path.isNullOrBlank() -> additionalIDs.resolvePath(refUri.path).buildRefId()
refUri.fragment != null -> additionalIDs.last().id.buildUpon().encodedFragment(refUri.fragment).buildRefId()
else -> throw IllegalArgumentException("invalid reference $refId")
}.also { usedRef += ReferenceLocation(schemaPath, it) }
Expand All @@ -271,7 +274,7 @@ private data class DefaultLoadingContext(
when {
!id.path.isNullOrBlank() -> register(
// register JSON schema by related path
baseId.buildUpon().encodedPath(id.path).buildRefId(),
additionalIDs.resolvePath(id.path).buildRefId(),
assertion,
)

Expand All @@ -294,6 +297,24 @@ private data class DefaultLoadingContext(
}
}

private fun Set<IdWithLocation>.resolvePath(path: String?): Uri {
return last().id.appendPathToParent(requireNotNull(path) { "path is null" })
}
private fun Uri.appendPathToParent(path: String): Uri {
val hasLastEmptySegment = toString().endsWith('/')
return if (hasLastEmptySegment) {
buildUpon() // don't need to drop anything. just add the path because / in the end means empty segment
} else {
buildUpon()
.path(null) // reset path in builder
.apply {
pathSegments.asSequence()
.take(pathSegments.size - 1) // drop last path segment
.forEach(this::appendPath)
}
}.appendEncodedPath(path)
.build()
}
private fun Uri.buildRefId(): RefId = RefId(this)

private fun Builder.buildRefId(): RefId = build().buildRefId()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ package io.github.optimumcode.json.schema.internal.factories.array
import io.github.optimumcode.json.schema.internal.JsonSchemaAssertion
import io.github.optimumcode.json.schema.internal.LoadingContext
import io.github.optimumcode.json.schema.internal.factories.AbstractAssertionFactory
import io.github.optimumcode.json.schema.internal.util.integerOrNull
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.intOrNull

@Suppress("unused")
internal object MaxItemsAssertionFactory : AbstractAssertionFactory("maxItems") {
override fun createFromProperty(element: JsonElement, context: LoadingContext): JsonSchemaAssertion {
require(element is JsonPrimitive && !element.isString) { "$property must be an integer" }
val maxItemsValue = requireNotNull(element.intOrNull) { "$property must be a valid integer" }
val maxItemsValue = requireNotNull(element.integerOrNull) { "$property must be a valid integer" }
require(maxItemsValue >= 0) { "$property must be a non-negative integer" }
return ArrayLengthAssertion(
context.schemaPath,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ package io.github.optimumcode.json.schema.internal.factories.array
import io.github.optimumcode.json.schema.internal.JsonSchemaAssertion
import io.github.optimumcode.json.schema.internal.LoadingContext
import io.github.optimumcode.json.schema.internal.factories.AbstractAssertionFactory
import io.github.optimumcode.json.schema.internal.util.integerOrNull
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.intOrNull

@Suppress("unused")
internal object MinItemsAssertionFactory : AbstractAssertionFactory("minItems") {
override fun createFromProperty(element: JsonElement, context: LoadingContext): JsonSchemaAssertion {
require(element is JsonPrimitive && !element.isString) { "$property must be an integer" }
val maxItemsValue = requireNotNull(element.intOrNull) { "$property must be a valid integer" }
val maxItemsValue = requireNotNull(element.integerOrNull) { "$property must be a valid integer" }
require(maxItemsValue >= 0) { "$property must be a non-negative integer" }
return ArrayLengthAssertion(
context.schemaPath,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import io.github.optimumcode.json.schema.internal.JsonSchemaAssertion
import io.github.optimumcode.json.schema.internal.LoadingContext
import io.github.optimumcode.json.schema.internal.TrueSchemaAssertion
import io.github.optimumcode.json.schema.internal.factories.AbstractAssertionFactory
import io.github.optimumcode.json.schema.internal.util.areEqual
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
Expand Down Expand Up @@ -36,21 +37,34 @@ private class UniqueItemsAssertion(
if (element.size < 2) {
return true
}
val uniqueItems = element.mapTo(linkedSetOf()) { it }
var duplicates: MutableList<JsonElement>? = null
val uniqueItems = buildList {
element.forEach { el ->
if (none { areEqual(it, el) }) {
add(el)
} else {
if (duplicates == null) {
duplicates = mutableListOf()
}
duplicates?.add(el)
}
}
}
val uniqueItemsCount = uniqueItems.size
if (uniqueItemsCount == element.size) {
return true
}
uniqueItems.clear()
errorCollector.onError(
ValidationError(
schemaPath = path,
objectPath = context.objectPath,
message = "array contains duplicate values: ${element.asSequence().filter(uniqueItems::add).joinToString(
prefix = "[",
postfix = "]",
separator = ",",
)}",
message = "array contains duplicate values: ${
duplicates?.joinToString(
prefix = "[",
postfix = "]",
separator = ",",
)
}",
),
)
return false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,34 +5,37 @@ import io.github.optimumcode.json.schema.internal.AssertionContext
import io.github.optimumcode.json.schema.internal.AssertionFactory
import io.github.optimumcode.json.schema.internal.JsonSchemaAssertion
import io.github.optimumcode.json.schema.internal.LoadingContext
import io.github.optimumcode.json.schema.internal.TrueSchemaAssertion
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject

internal object IfThenElseAssertionFactory : AssertionFactory {
private const val ifProperty: String = "if"
private const val thenProperty: String = "then"
private const val elseProperty: String = "else"
private const val IF_PROPERTY: String = "if"
private const val THEN_PROPERTY: String = "then"
private const val ELSE_PROPERTY: String = "else"

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

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

val thenAssertion: JsonSchemaAssertion? = loadOptionalAssertion(element, thenProperty, context)
val elseAssertion: JsonSchemaAssertion? = loadOptionalAssertion(element, elseProperty, context)
val thenAssertion: JsonSchemaAssertion? = loadOptionalAssertion(element, THEN_PROPERTY, context)
val elseAssertion: JsonSchemaAssertion? = loadOptionalAssertion(element, ELSE_PROPERTY, context)

require(thenAssertion != null || elseAssertion != null) {
"either $thenProperty or $elseProperty must be specified"
return when {
ifAssertion == null -> TrueSchemaAssertion // no if -> no effect
thenAssertion == null && elseAssertion == null -> TrueSchemaAssertion // only if - no effect
else -> IfThenElseAssertion(ifAssertion, thenAssertion, elseAssertion)
}
return IfThenElseAssertion(ifAssertion, thenAssertion, elseAssertion)
}

private fun loadOptionalAssertion(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import io.github.optimumcode.json.schema.internal.AssertionContext
import io.github.optimumcode.json.schema.internal.JsonSchemaAssertion
import io.github.optimumcode.json.schema.internal.LoadingContext
import io.github.optimumcode.json.schema.internal.factories.AbstractAssertionFactory
import io.github.optimumcode.json.schema.internal.util.areEqual
import kotlinx.serialization.json.JsonElement

@Suppress("unused")
Expand All @@ -21,7 +22,7 @@ private class ConstAssertion(
private val constValue: JsonElement,
) : JsonSchemaAssertion {
override fun validate(element: JsonElement, context: AssertionContext, errorCollector: ErrorCollector): Boolean {
if (element == constValue) {
if (areEqual(element, constValue)) {
return true
}
errorCollector.onError(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import io.github.optimumcode.json.schema.internal.AssertionContext
import io.github.optimumcode.json.schema.internal.JsonSchemaAssertion
import io.github.optimumcode.json.schema.internal.LoadingContext
import io.github.optimumcode.json.schema.internal.factories.AbstractAssertionFactory
import io.github.optimumcode.json.schema.internal.util.areEqual
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement

Expand All @@ -29,7 +30,7 @@ private class EnumAssertion(
}

override fun validate(element: JsonElement, context: AssertionContext, errorCollector: ErrorCollector): Boolean {
if (possibleElements.contains(element)) {
if (possibleElements.any { areEqual(it, element) }) {
return true
}
errorCollector.onError(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import io.github.optimumcode.json.schema.internal.AssertionContext
import io.github.optimumcode.json.schema.internal.JsonSchemaAssertion
import io.github.optimumcode.json.schema.internal.LoadingContext
import io.github.optimumcode.json.schema.internal.factories.AbstractAssertionFactory
import io.github.optimumcode.json.schema.internal.util.parseNumberParts
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
Expand All @@ -23,7 +24,7 @@ internal object TypeAssertionFactory : AbstractAssertionFactory("type") {
"string" to { it is JsonPrimitive && it.isString },
"boolean" to { it is JsonPrimitive && !it.isString && it.booleanOrNull != null },
"number" to { it is JsonPrimitive && !it.isString && (it.doubleOrNull != null || it.longOrNull != null) },
"integer" to { it is JsonPrimitive && !it.isString && it.longOrNull != null },
"integer" to { it is JsonPrimitive && !it.isString && parseNumberParts(it)?.fractional == 0L },
"array" to { it is JsonArray },
"object" to { it is JsonObject },
).mapValues { Validation(it.key, it.value) }
Expand Down
Loading