Skip to content

Commit

Permalink
Make library comply to official JSON schema repository test-suites (#19)
Browse files Browse the repository at this point in the history
Resolves #15
  • Loading branch information
OptimumCode authored Jul 28, 2023
1 parent 9bfbddf commit 119f2a3
Show file tree
Hide file tree
Showing 45 changed files with 894 additions and 68 deletions.
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 = this@JsonPointer.toString()
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 = this@JsonPointer.toString()
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 = this@plus.toString()
append(pointer)
if (pointer.endsWith(JsonPointer.SEPARATOR)) {
setLength(length - 1)
}
val other = otherPointer.toString()
append(other)
append(this@plus.toString())
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

0 comments on commit 119f2a3

Please sign in to comment.