From 7dc61053ceb487fb7d412d63f4dc19b978ddffba Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Tue, 25 Jul 2023 14:45:30 +0400 Subject: [PATCH 01/27] Add schema-test-suite repository as submodule --- .gitmodules | 3 +++ schema-test-suite | 1 + 2 files changed, 4 insertions(+) create mode 100644 .gitmodules create mode 160000 schema-test-suite diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..c826889a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "schema-test-suite"] + path = schema-test-suite + url = git@github.com:json-schema-org/JSON-Schema-Test-Suite.git diff --git a/schema-test-suite b/schema-test-suite new file mode 160000 index 00000000..8cdfac41 --- /dev/null +++ b/schema-test-suite @@ -0,0 +1 @@ +Subproject commit 8cdfac41e37527795879e480a483997cbd6188f3 From c283dee9cfab0c99f3ca86b8c5f5a99d3aecefc2 Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Wed, 26 Jul 2023 14:40:10 +0400 Subject: [PATCH 02/27] Move schema test suites --- .gitmodules | 2 +- schema-test-suite => test-suites/schema-test-suite | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename schema-test-suite => test-suites/schema-test-suite (100%) diff --git a/.gitmodules b/.gitmodules index c826889a..0f4672a5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "schema-test-suite"] - path = schema-test-suite + path = test-suites/schema-test-suite url = git@github.com:json-schema-org/JSON-Schema-Test-Suite.git diff --git a/schema-test-suite b/test-suites/schema-test-suite similarity index 100% rename from schema-test-suite rename to test-suites/schema-test-suite From 616e33f53ef50cfc24410b313569889e09675431 Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Wed, 26 Jul 2023 14:40:47 +0400 Subject: [PATCH 03/27] Implement test suites base. Add test suite for draft7 --- gradle/libs.versions.toml | 9 +- settings.gradle.kts | 4 +- test-suites/build.gradle.kts | 135 ++++++++++++++++++ .../schema/suite/AbstractSchemaTestSuite.kt | 125 ++++++++++++++++ .../json/schema/suite/draft7/TestSuite.kt | 16 +++ .../json/schema/suite/FileSystemForTest.kt | 5 + .../json/schema/suite/FileSystemForTest.kt | 5 + .../json/schema/suite/FileSystemForTest.kt | 6 + .../json/schema/suite/FileSystemForTest.kt | 5 + .../schema/suite/AbstractSchemaTestSuite.kt | 5 + .../json/schema/suite/FileSystemForTest.kt | 5 + .../json/schema/suite/FileSystemForTest.kt | 5 + .../json/schema/suite/FileSystemForTest.kt | 5 + 13 files changed, 327 insertions(+), 3 deletions(-) create mode 100644 test-suites/build.gradle.kts create mode 100644 test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt create mode 100644 test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/draft7/TestSuite.kt create mode 100644 test-suites/src/iosSimulatorArm64Test/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt create mode 100644 test-suites/src/iosTest/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt create mode 100644 test-suites/src/jsTest/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt create mode 100644 test-suites/src/jvmTest/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt create mode 100644 test-suites/src/linuxX64Test/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt create mode 100644 test-suites/src/macosArm64Test/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt create mode 100644 test-suites/src/macosX64Test/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt create mode 100644 test-suites/src/mingwX64Test/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 01f8dd39..c711bfd1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } @@ -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" } \ No newline at end of file +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" } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 26da97bf..1ce1d901 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1,3 @@ -rootProject.name = "json-schema-validator" \ No newline at end of file +rootProject.name = "json-schema-validator" + +include(":test-suites") \ No newline at end of file diff --git a/test-suites/build.gradle.kts b/test-suites/build.gradle.kts new file mode 100644 index 00000000..446eead4 --- /dev/null +++ b/test-suites/build.gradle.kts @@ -0,0 +1,135 @@ +import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.plugin.KotlinTarget +import org.jetbrains.kotlin.gradle.plugin.KotlinTargetWithTests +import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension +import org.jetbrains.kotlin.gradle.targets.js.testing.KotlinJsTest +import org.jlleitschuh.gradle.ktlint.reporter.ReporterType + +@Suppress("DSL_SCOPE_VIOLATION") // TODO: remove when migrate to Gradle 8 +plugins { + alias(libs.plugins.kotlin.mutliplatform) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kotest.multiplatform) + alias(libs.plugins.kover) + alias(libs.plugins.detekt) + alias(libs.plugins.ktlint) +} + +repositories { + mavenCentral() +} + +kotlin { + explicitApi() + jvm { + jvmToolchain(11) + withJava() + testRuns["test"].executionTask.configure { + useJUnitPlatform() + } + } + js(IR) { + nodejs() + } + ios() + + val macOsTargets = listOf( + macosX64(), + macosArm64(), + iosArm64(), + iosSimulatorArm64(), + ) + + val linuxTargets = listOf( + linuxX64(), + linuxArm64(), + ) + + val windowsTargets = listOf( + mingwX64(), + ) + + sourceSets { + val commonTest by getting { + dependencies { + implementation(project(":")) + implementation(libs.kotest.assertions.core) + implementation(libs.kotest.framework.engine) + implementation(kotlin("test-common")) + implementation(kotlin("test-annotations-common")) + implementation(libs.okio.common) + implementation(libs.kotlin.serialization.json.okio) + } + } + val jsTest by getting { + dependencies { + implementation(libs.okio.nodefilesystem) + } + } + val jvmTest by getting { + dependencies { + implementation(libs.kotest.runner.junit5) + } + } + + // in order to support test suites for all targets I have to use the expect/actual functionality + // but some of the targets does not have test tasks (but they are in the sources) + // so I remove them to let the compiler check that all actual implementations are in place + listOf( + macOsTargets, + linuxTargets, + windowsTargets, + ).asSequence().flatten().forEach { + if (it is KotlinTargetWithTests<*, *>) { + // don't need to remove test sources from targets with tests + return@forEach + } + val sourceSetForTarget = getByName("${it.name}Test") + remove(sourceSetForTarget) + } + } + + afterEvaluate { + fun Task.dependsOnTargetTests(targets: List) { + targets.forEach { + if (it is KotlinTargetWithTests<*, *>) { + dependsOn(tasks.getByName("${it.name}Test")) + } + } + } + tasks.register("macOsAllTest") { + group = "verification" + description = "runs all tests for MacOS and IOS targets" + dependsOnTargetTests(macOsTargets) + } + tasks.register("windowsAllTest") { + group = "verification" + description = "runs all tests for Windows targets" + dependsOnTargetTests(windowsTargets) + } + tasks.register("linuxAllTest") { + group = "verification" + description = "runs all tests for Linux targets" + dependsOnTargetTests(linuxTargets) + dependsOn(tasks.getByName("jvmTest")) + dependsOn(tasks.getByName("jsTest")) + } + } +} + +ktlint { + version.set(libs.versions.ktlint) + reporters { + reporter(ReporterType.HTML) + } +} + +afterEvaluate { + val detektAllTask by tasks.register("detektAll") { + dependsOn(tasks.withType()) + } + + tasks.named("check").configure { + dependsOn(detektAllTask) + } +} \ No newline at end of file diff --git a/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt b/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt new file mode 100644 index 00000000..a193e174 --- /dev/null +++ b/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt @@ -0,0 +1,125 @@ +package io.github.optimumcode.json.schema.suite + +import io.github.optimumcode.json.schema.ErrorCollector +import io.github.optimumcode.json.schema.ErrorCollector.Companion +import io.github.optimumcode.json.schema.JsonSchema +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.withClue +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.okio.decodeFromBufferedSource +import okio.FileSystem +import okio.Path +import okio.Path.Companion.toPath +import okio.buffer +import okio.use + +/** + * This class is a base for creating a test suite run from https://github.com/json-schema-org/JSON-Schema-Test-Suite. + * That repository contains test-suites for all drafts to verify the validator. + * + * The test suites are located in _schema-test-suite/tests//_ + */ +internal fun FunSpec.runTestSuites( + /** + * This will be used to pick the directory with test suites + */ + draftName: String, + /** + * Defines whether the optional suites should be included into the run + */ + includeOptional: Boolean = false, + /** + * The test suites that should be excluded from the run. + * The file name is an identifier for a test suites. + * The test suite description is identifier for single set of tests + */ + excludeSuites: Map> = emptyMap(), + /** + * The tests that should be excluded from a test suite. + * The **description** property is a test identifier + */ + excludeTests: Map> = emptyMap(), +) { + require(draftName.isNotBlank()) { "draftName is blank" } + val testSuiteDir = TEST_SUITES_DIR / draftName + val fs = fileSystem() + require(fs.exists(testSuiteDir)) { "folder $testSuiteDir does not exist" } + + executeFromDirectory(fs, testSuiteDir, excludeSuites, excludeTests) + + if (includeOptional) { + val optionalTestSuites = testSuiteDir / "optional" + if (fs.exists(optionalTestSuites)) { + executeFromDirectory(fs, optionalTestSuites, excludeSuites, excludeTests) + } + } +} + +@OptIn(ExperimentalSerializationApi::class) +private fun FunSpec.executeFromDirectory( + fs: FileSystem, + testSuiteDir: Path, + excludeSuites: Map>, + excludeTests: Map> +) { + fs.list(testSuiteDir).forEach { testSuiteFile -> + if (fs.metadata(testSuiteFile).isDirectory) { + // skip if not a file + return@forEach + } + val testSuiteID = testSuiteFile.name.substringBeforeLast(".") + val excludeTestSuitesWithDescription: Set? = excludeSuites[testSuiteID] + if (excludeTestSuitesWithDescription?.isEmpty() == true) { + // exclude all test cases + return@forEach + } + + val testSuites: List = fs.openReadOnly(testSuiteFile).use { + Json.decodeFromBufferedSource(ListSerializer(TestSuite.serializer()), it.source().buffer()) + } + for (testSuite in testSuites) { + if (excludeTestSuitesWithDescription != null && testSuite.description in excludeTestSuitesWithDescription) { + continue + } + val excludeTestWithDescription: Set? = excludeTests[testSuite.description] + for (test in testSuite.tests) { + if (excludeTestWithDescription != null && test.description in excludeTestWithDescription) { + continue + } + test("$testSuiteID > ${testSuite.description} > ${test.description}") { + withClue(listOf(testSuite.schema, test.description, test.data)) { + val schema: JsonSchema = shouldNotThrowAny { + JsonSchema.fromJsonElement(testSuite.schema) + } + schema.validate(test.data, ErrorCollector.EMPTY) shouldBe test.valid + } + } + } + } + } +} + +@Serializable +private class TestSuite( + val description: String, + val schema: JsonElement, + val tests: List, + val comment: String? = null, +) + +@Serializable +private class SchemaTest( + val description: String, + val data: JsonElement, + val valid: Boolean, +) + +private val TEST_SUITES_DIR: Path = "schema-test-suite/tests".toPath() + +expect fun fileSystem(): FileSystem \ No newline at end of file diff --git a/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/draft7/TestSuite.kt b/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/draft7/TestSuite.kt new file mode 100644 index 00000000..c256c7ae --- /dev/null +++ b/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/draft7/TestSuite.kt @@ -0,0 +1,16 @@ +package io.github.optimumcode.json.schema.suite.draft7 + +import io.github.optimumcode.json.schema.suite.runTestSuites +import io.kotest.core.spec.style.FunSpec + +@Suppress("unused") +internal class TestSuite : FunSpec() { + init { + runTestSuites( + draftName = "draft7", + excludeSuites = mapOf( + "refRemote" to emptySet(), // remote refs are not supported + ), + ) + } +} \ No newline at end of file diff --git a/test-suites/src/iosSimulatorArm64Test/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt b/test-suites/src/iosSimulatorArm64Test/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt new file mode 100644 index 00000000..9e2ff25d --- /dev/null +++ b/test-suites/src/iosSimulatorArm64Test/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt @@ -0,0 +1,5 @@ +package io.github.optimumcode.json.schema.suite + +import okio.FileSystem + +actual fun fileSystem(): FileSystem = FileSystem.SYSTEM \ No newline at end of file diff --git a/test-suites/src/iosTest/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt b/test-suites/src/iosTest/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt new file mode 100644 index 00000000..9e2ff25d --- /dev/null +++ b/test-suites/src/iosTest/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt @@ -0,0 +1,5 @@ +package io.github.optimumcode.json.schema.suite + +import okio.FileSystem + +actual fun fileSystem(): FileSystem = FileSystem.SYSTEM \ No newline at end of file diff --git a/test-suites/src/jsTest/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt b/test-suites/src/jsTest/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt new file mode 100644 index 00000000..04d19b17 --- /dev/null +++ b/test-suites/src/jsTest/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt @@ -0,0 +1,6 @@ +package io.github.optimumcode.json.schema.suite + +import okio.FileSystem +import okio.NodeJsFileSystem + +actual fun fileSystem(): FileSystem = NodeJsFileSystem \ No newline at end of file diff --git a/test-suites/src/jvmTest/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt b/test-suites/src/jvmTest/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt new file mode 100644 index 00000000..9e2ff25d --- /dev/null +++ b/test-suites/src/jvmTest/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt @@ -0,0 +1,5 @@ +package io.github.optimumcode.json.schema.suite + +import okio.FileSystem + +actual fun fileSystem(): FileSystem = FileSystem.SYSTEM \ No newline at end of file diff --git a/test-suites/src/linuxX64Test/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt b/test-suites/src/linuxX64Test/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt new file mode 100644 index 00000000..9e2ff25d --- /dev/null +++ b/test-suites/src/linuxX64Test/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt @@ -0,0 +1,5 @@ +package io.github.optimumcode.json.schema.suite + +import okio.FileSystem + +actual fun fileSystem(): FileSystem = FileSystem.SYSTEM \ No newline at end of file diff --git a/test-suites/src/macosArm64Test/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt b/test-suites/src/macosArm64Test/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt new file mode 100644 index 00000000..9e2ff25d --- /dev/null +++ b/test-suites/src/macosArm64Test/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt @@ -0,0 +1,5 @@ +package io.github.optimumcode.json.schema.suite + +import okio.FileSystem + +actual fun fileSystem(): FileSystem = FileSystem.SYSTEM \ No newline at end of file diff --git a/test-suites/src/macosX64Test/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt b/test-suites/src/macosX64Test/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt new file mode 100644 index 00000000..9e2ff25d --- /dev/null +++ b/test-suites/src/macosX64Test/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt @@ -0,0 +1,5 @@ +package io.github.optimumcode.json.schema.suite + +import okio.FileSystem + +actual fun fileSystem(): FileSystem = FileSystem.SYSTEM \ No newline at end of file diff --git a/test-suites/src/mingwX64Test/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt b/test-suites/src/mingwX64Test/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt new file mode 100644 index 00000000..9e2ff25d --- /dev/null +++ b/test-suites/src/mingwX64Test/kotlin/io/github/optimumcode/json/schema/suite/FileSystemForTest.kt @@ -0,0 +1,5 @@ +package io.github.optimumcode.json.schema.suite + +import okio.FileSystem + +actual fun fileSystem(): FileSystem = FileSystem.SYSTEM \ No newline at end of file From 62370a0c04c15775498e562b6b70e1a6dcc231f5 Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Wed, 26 Jul 2023 15:25:53 +0400 Subject: [PATCH 04/27] Init submodules --- .github/workflows/check.yml | 2 ++ .github/workflows/release.yml | 2 ++ .github/workflows/snapshot_release.yml | 2 ++ 3 files changed, 6 insertions(+) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 90132b4d..f291dccb 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -18,6 +18,8 @@ jobs: steps: - name: 'Checkout Repository' uses: actions/checkout@v3 + with: + submodules: true - uses: actions/setup-java@v3 with: distribution: temurin diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d421cc2..eb84ea77 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,8 @@ jobs: steps: - name: 'Checkout Repository' uses: actions/checkout@v3 + with: + submodules: true - uses: actions/setup-java@v3 with: distribution: temurin diff --git a/.github/workflows/snapshot_release.yml b/.github/workflows/snapshot_release.yml index 8fcdf6a9..3ed07d54 100644 --- a/.github/workflows/snapshot_release.yml +++ b/.github/workflows/snapshot_release.yml @@ -11,6 +11,8 @@ jobs: steps: - name: 'Checkout Repository' uses: actions/checkout@v3 + with: + submodules: true - uses: actions/setup-java@v3 with: distribution: temurin From 646a59bdd7cd254f69dc12e9f1ad97e16b8c7b23 Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Wed, 26 Jul 2023 15:26:30 +0400 Subject: [PATCH 05/27] Correct the equailty check for numbers --- .../general/ConstAssertionFactory.kt | 3 +- .../internal/util/ElementEqualityUtil.kt | 82 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/general/ConstAssertionFactory.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/general/ConstAssertionFactory.kt index 67b8d671..750f0873 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/general/ConstAssertionFactory.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/general/ConstAssertionFactory.kt @@ -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") @@ -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( diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt new file mode 100644 index 00000000..1d7915d3 --- /dev/null +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt @@ -0,0 +1,82 @@ +package io.github.optimumcode.json.schema.internal.util + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +internal fun areEqual(first: JsonElement, second: JsonElement): Boolean { + if (first::class != second::class) { + return false + } + return when (first) { + is JsonObject -> areEqualObjects(first, second.jsonObject) + is JsonArray -> areEqualArrays(first, second.jsonArray) + is JsonPrimitive -> areEqualPrimitives(first, second.jsonPrimitive) + } +} + +internal fun areEqualPrimitives(first: JsonPrimitive, second: JsonPrimitive): Boolean { + if (first is JsonNull && second is JsonNull) { + return true + } + if (first.isString != second.isString) { + return false + } + return if (first.isString) { + first.content == second.content + } else { + when { + first.booleanOrNull != null || second.booleanOrNull != null -> first.content == second.content + else -> compareAsNumbers(first, second) + } + } +} + +private fun compareAsNumbers(first: JsonPrimitive, second: JsonPrimitive): Boolean { + val (firstInteger, firstFractional) = number(first) + val (secondInteger, secondFractional) = number(second) + return firstInteger == secondInteger && firstFractional == secondFractional +} + +private fun number(element: JsonPrimitive): Pair { + val integerPart = element.content.substringBefore('.') + return if (integerPart == element.content) { + integerPart.toLong() to 0L + } else { + integerPart.toLong() to element.content.substring(integerPart.length + 1).toLong() + } +} + +internal fun areEqualArrays(first: JsonArray, second: JsonArray): Boolean { + if (first.size != second.size) { + return false + } + for (i in 0 until first.size) { + if (!areEqual(first[i], second[i])) { + return false + } + } + return true +} + +internal fun areEqualObjects(first: JsonObject, second: JsonObject): Boolean { + if (first.size != second.size) { + return false + } + if (first.keys != second.keys) { + return false + } + for (key in first.keys) { + if (!areEqual(first.getValue(key), second.getValue(key))) { + return false + } + } + return true +} \ No newline at end of file From 9dbc2a72ee1856f8d231b99b9f4ef702aab40f58 Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Wed, 26 Jul 2023 15:33:50 +0400 Subject: [PATCH 06/27] Correct enum assertion to correctly work with numbers --- .../schema/internal/factories/general/EnumAssertionFactory.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/general/EnumAssertionFactory.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/general/EnumAssertionFactory.kt index 5c0cba11..33c399fb 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/general/EnumAssertionFactory.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/general/EnumAssertionFactory.kt @@ -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 @@ -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( From 2613504a5df605c70afbbf4677a8334facd33a8d Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Wed, 26 Jul 2023 15:46:28 +0400 Subject: [PATCH 07/27] Correct type to correctly treat numbers with zero fraction part as integer --- .../factories/general/TypeAssertionFactory.kt | 3 +- .../internal/util/ElementEqualityUtil.kt | 30 +++++++++++++++---- .../general/JsonSchemaTypeValidationTest.kt | 19 ++++++++++-- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/general/TypeAssertionFactory.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/general/TypeAssertionFactory.kt index dda78024..995cb359 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/general/TypeAssertionFactory.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/general/TypeAssertionFactory.kt @@ -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 @@ -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) } diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt index 1d7915d3..70010f1b 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt @@ -5,7 +5,6 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.boolean import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject @@ -40,17 +39,36 @@ internal fun areEqualPrimitives(first: JsonPrimitive, second: JsonPrimitive): Bo } private fun compareAsNumbers(first: JsonPrimitive, second: JsonPrimitive): Boolean { - val (firstInteger, firstFractional) = number(first) - val (secondInteger, secondFractional) = number(second) + val (firstInteger, firstFractional) = numberParts(first) + val (secondInteger, secondFractional) = numberParts(second) return firstInteger == secondInteger && firstFractional == secondFractional } -private fun number(element: JsonPrimitive): Pair { +internal data class NumberParts( + val integer: Long, + val fractional: Long, +) + +internal fun parseNumberParts(element: JsonPrimitive): NumberParts? { + return if (element.isString || element is JsonNull || element.booleanOrNull != null) { + null + } else { + numberParts(element) + } +} + +private fun numberParts(element: JsonPrimitive): NumberParts { val integerPart = element.content.substringBefore('.') return if (integerPart == element.content) { - integerPart.toLong() to 0L + NumberParts( + integerPart.toLong(), + 0L, + ) } else { - integerPart.toLong() to element.content.substring(integerPart.length + 1).toLong() + NumberParts( + integerPart.toLong(), + element.content.substring(integerPart.length + 1).toLong(), + ) } } diff --git a/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/general/JsonSchemaTypeValidationTest.kt b/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/general/JsonSchemaTypeValidationTest.kt index 004a5c28..b0541d7e 100644 --- a/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/general/JsonSchemaTypeValidationTest.kt +++ b/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/general/JsonSchemaTypeValidationTest.kt @@ -25,9 +25,7 @@ class JsonSchemaTypeValidationTest : FunSpec() { val possibleTypes = mapOf( "boolean" to JsonPrimitive(true), "string" to JsonPrimitive("true"), - // JsonUnquotedLiteral is used to bypass the JS conversion to integer value - // If we use JsonPrimitive(42.0) the 42.0 will be converted to 42 - "number" to JsonUnquotedLiteral("42.0"), + "number" to JsonPrimitive(42.5), "integer" to JsonPrimitive(42), "null" to JsonNull, "array" to buildJsonArray {}, @@ -185,5 +183,20 @@ class JsonSchemaTypeValidationTest : FunSpec() { ) }.message shouldBe "type must be either array or a string" } + + test("number with zero fraction is integer") { + JsonSchema.fromDefinition( + """ + { + "${KEY}schema": "http://json-schema.org/draft-07/schema#", + "type": "integer" + } + """.trimIndent(), + ).apply { + val errors = mutableListOf() + validate(JsonUnquotedLiteral("42.0"), errors::add) shouldBe true + errors shouldHaveSize 0 + } + } } } \ No newline at end of file From 09d449dbc65d7e86a3622a4edfdb534d8d43f54f Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Wed, 26 Jul 2023 16:04:03 +0400 Subject: [PATCH 08/27] Handle engineering number format --- .../internal/util/ElementEqualityUtil.kt | 8 +++++++ .../internal/util/ElementEqualityUtilTest.kt | 23 +++++++++++++++++++ test-suites/build.gradle.kts | 2 -- .../schema/suite/AbstractSchemaTestSuite.kt | 3 +-- 4 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 src/commonTest/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtilTest.kt diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt index 70010f1b..5ed63570 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt @@ -6,6 +6,7 @@ import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.double import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -57,7 +58,14 @@ internal fun parseNumberParts(element: JsonPrimitive): NumberParts? { } } +private const val E_SMALL_CHAR: Char = 'e' +private const val E_BIG_CHAR: Char = 'E' private fun numberParts(element: JsonPrimitive): NumberParts { + if (element.content.run { contains(E_SMALL_CHAR) || contains(E_BIG_CHAR) }) { + return element.double.run { + NumberParts(toLong(), rem(1.0).toLong()) + } + } val integerPart = element.content.substringBefore('.') return if (integerPart == element.content) { NumberParts( diff --git a/src/commonTest/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtilTest.kt b/src/commonTest/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtilTest.kt new file mode 100644 index 00000000..b9d85386 --- /dev/null +++ b/src/commonTest/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtilTest.kt @@ -0,0 +1,23 @@ +package io.github.optimumcode.json.schema.internal.util + +import io.kotest.assertions.assertSoftly +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.JsonUnquotedLiteral + +@Suppress("unused") +@OptIn(ExperimentalSerializationApi::class) +class ElementEqualityUtilTest : FunSpec() { + init { + test("extracts number parts from max long engineering format") { + val (integer, fraction) = parseNumberParts(JsonUnquotedLiteral("1e308")) + .shouldNotBeNull() + assertSoftly { + integer shouldBe Long.MAX_VALUE + fraction shouldBe 0L + } + } + } +} \ No newline at end of file diff --git a/test-suites/build.gradle.kts b/test-suites/build.gradle.kts index 446eead4..74cfc3ff 100644 --- a/test-suites/build.gradle.kts +++ b/test-suites/build.gradle.kts @@ -1,8 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt import org.jetbrains.kotlin.gradle.plugin.KotlinTarget import org.jetbrains.kotlin.gradle.plugin.KotlinTargetWithTests -import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension -import org.jetbrains.kotlin.gradle.targets.js.testing.KotlinJsTest import org.jlleitschuh.gradle.ktlint.reporter.ReporterType @Suppress("DSL_SCOPE_VIOLATION") // TODO: remove when migrate to Gradle 8 diff --git a/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt b/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt index a193e174..f5d4f957 100644 --- a/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt +++ b/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt @@ -1,7 +1,6 @@ package io.github.optimumcode.json.schema.suite import io.github.optimumcode.json.schema.ErrorCollector -import io.github.optimumcode.json.schema.ErrorCollector.Companion import io.github.optimumcode.json.schema.JsonSchema import io.kotest.assertions.throwables.shouldNotThrowAny import io.kotest.assertions.withClue @@ -66,7 +65,7 @@ private fun FunSpec.executeFromDirectory( fs: FileSystem, testSuiteDir: Path, excludeSuites: Map>, - excludeTests: Map> + excludeTests: Map>, ) { fs.list(testSuiteDir).forEach { testSuiteFile -> if (fs.metadata(testSuiteFile).isDirectory) { From 27fcd209c230214fac35a97ac2603f7f8bcab850 Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Wed, 26 Jul 2023 16:17:22 +0400 Subject: [PATCH 09/27] Treat numbers with zero fraction as valid integers --- .../array/MaxItemsAssertionFactory.kt | 4 +- .../array/MinItemsAssertionFactory.kt | 4 +- .../object/MaxPropertiesAssertionFactory.kt | 4 +- .../object/MinPropertiesAssertionFactory.kt | 4 +- .../string/MaxLengthAssertionFactory.kt | 4 +- .../string/MinLengthAssertionFactory.kt | 4 +- .../internal/util/ElementEqualityUtil.kt | 35 ------------- .../internal/util/ElementNumberContent.kt | 50 +++++++++++++++++++ 8 files changed, 62 insertions(+), 47 deletions(-) create mode 100644 src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementNumberContent.kt diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/array/MaxItemsAssertionFactory.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/array/MaxItemsAssertionFactory.kt index 7d44b053..11b64ab4 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/array/MaxItemsAssertionFactory.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/array/MaxItemsAssertionFactory.kt @@ -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, diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/array/MinItemsAssertionFactory.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/array/MinItemsAssertionFactory.kt index c97d5081..4f792c4e 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/array/MinItemsAssertionFactory.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/array/MinItemsAssertionFactory.kt @@ -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, diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/object/MaxPropertiesAssertionFactory.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/object/MaxPropertiesAssertionFactory.kt index 1d28397b..502d7d40 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/object/MaxPropertiesAssertionFactory.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/object/MaxPropertiesAssertionFactory.kt @@ -3,15 +3,15 @@ package io.github.optimumcode.json.schema.internal.factories.`object` 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 MaxPropertiesAssertionFactory : AbstractAssertionFactory("maxProperties") { override fun createFromProperty(element: JsonElement, context: LoadingContext): JsonSchemaAssertion { require(element is JsonPrimitive && !element.isString) { "$property must be an integer" } - val maxPropertiesValue = requireNotNull(element.intOrNull) { "$property must be a valid integer" } + val maxPropertiesValue = requireNotNull(element.integerOrNull) { "$property must be a valid integer" } require(maxPropertiesValue >= 0) { "$property must be a non-negative integer" } return PropertiesNumberAssertion( context.schemaPath, diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/object/MinPropertiesAssertionFactory.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/object/MinPropertiesAssertionFactory.kt index 9bcdd1dc..4c3a060a 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/object/MinPropertiesAssertionFactory.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/object/MinPropertiesAssertionFactory.kt @@ -3,15 +3,15 @@ package io.github.optimumcode.json.schema.internal.factories.`object` 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 MinPropertiesAssertionFactory : AbstractAssertionFactory("minProperties") { override fun createFromProperty(element: JsonElement, context: LoadingContext): JsonSchemaAssertion { require(element is JsonPrimitive && !element.isString) { "$property must be an integer" } - val minPropertiesValue = requireNotNull(element.intOrNull) { "$property must be a valid integer" } + val minPropertiesValue = requireNotNull(element.integerOrNull) { "$property must be a valid integer" } require(minPropertiesValue >= 0) { "$property must be a non-negative integer" } return PropertiesNumberAssertion( context.schemaPath, diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/string/MaxLengthAssertionFactory.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/string/MaxLengthAssertionFactory.kt index b888ec0c..4596edb6 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/string/MaxLengthAssertionFactory.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/string/MaxLengthAssertionFactory.kt @@ -3,15 +3,15 @@ package io.github.optimumcode.json.schema.internal.factories.string 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 MaxLengthAssertionFactory : AbstractAssertionFactory("maxLength") { override fun createFromProperty(element: JsonElement, context: LoadingContext): JsonSchemaAssertion { require(element is JsonPrimitive && !element.isString) { "$property must be an integer" } - val maxLength = requireNotNull(element.intOrNull) { "$property must be a valid integer" } + val maxLength = requireNotNull(element.integerOrNull) { "$property must be a valid integer" } require(maxLength >= 0) { "$property must be a non-negative integer" } return LengthAssertion(context.schemaPath, maxLength, "must be less or equal to") { a, b -> a <= b } } diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/string/MinLengthAssertionFactory.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/string/MinLengthAssertionFactory.kt index 2abecbc1..5bc7acea 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/string/MinLengthAssertionFactory.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/string/MinLengthAssertionFactory.kt @@ -3,15 +3,15 @@ package io.github.optimumcode.json.schema.internal.factories.string 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 MinLengthAssertionFactory : AbstractAssertionFactory("minLength") { override fun createFromProperty(element: JsonElement, context: LoadingContext): JsonSchemaAssertion { require(element is JsonPrimitive && !element.isString) { "$property must be an integer" } - val minLength = requireNotNull(element.intOrNull) { "$property must be a valid integer" } + val minLength = requireNotNull(element.integerOrNull) { "$property must be a valid integer" } require(minLength >= 0) { "$property must be a non-negative integer" } return LengthAssertion(context.schemaPath, minLength, "must be greater or equal to") { a, b -> a >= b } } diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt index 5ed63570..a1651cc4 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt @@ -45,41 +45,6 @@ private fun compareAsNumbers(first: JsonPrimitive, second: JsonPrimitive): Boole return firstInteger == secondInteger && firstFractional == secondFractional } -internal data class NumberParts( - val integer: Long, - val fractional: Long, -) - -internal fun parseNumberParts(element: JsonPrimitive): NumberParts? { - return if (element.isString || element is JsonNull || element.booleanOrNull != null) { - null - } else { - numberParts(element) - } -} - -private const val E_SMALL_CHAR: Char = 'e' -private const val E_BIG_CHAR: Char = 'E' -private fun numberParts(element: JsonPrimitive): NumberParts { - if (element.content.run { contains(E_SMALL_CHAR) || contains(E_BIG_CHAR) }) { - return element.double.run { - NumberParts(toLong(), rem(1.0).toLong()) - } - } - val integerPart = element.content.substringBefore('.') - return if (integerPart == element.content) { - NumberParts( - integerPart.toLong(), - 0L, - ) - } else { - NumberParts( - integerPart.toLong(), - element.content.substring(integerPart.length + 1).toLong(), - ) - } -} - internal fun areEqualArrays(first: JsonArray, second: JsonArray): Boolean { if (first.size != second.size) { return false diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementNumberContent.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementNumberContent.kt new file mode 100644 index 00000000..aea7c52b --- /dev/null +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementNumberContent.kt @@ -0,0 +1,50 @@ +package io.github.optimumcode.json.schema.internal.util + +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.double + +internal data class NumberParts( + val integer: Long, + val fractional: Long, +) + +internal fun parseNumberParts(element: JsonPrimitive): NumberParts? { + return if (element.isString || element is JsonNull || element.booleanOrNull != null) { + null + } else { + numberParts(element) + } +} + +private const val E_SMALL_CHAR: Char = 'e' +private const val E_BIG_CHAR: Char = 'E' + +/** + * This function should be used only if you are certain that the [element] is a number + */ +internal fun numberParts(element: JsonPrimitive): NumberParts { + if (element.content.run { contains(E_SMALL_CHAR) || contains(E_BIG_CHAR) }) { + return element.double.run { + NumberParts(toLong(), rem(1.0).toLong()) + } + } + val integerPart = element.content.substringBefore('.') + return if (integerPart == element.content) { + NumberParts( + integerPart.toLong(), + 0L, + ) + } else { + NumberParts( + integerPart.toLong(), + element.content.substring(integerPart.length + 1).toLong(), + ) + } +} + +internal val JsonPrimitive.integerOrNull: Int? + get() = parseNumberParts(this)?.takeIf { + it.fractional == 0L && it.integer <= Int.MAX_VALUE + }?.integer?.toInt() \ No newline at end of file From 6a9107ad649d042ca320df90b7dae1c609bc4eb2 Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Wed, 26 Jul 2023 16:30:57 +0400 Subject: [PATCH 10/27] Correct unique items assertion to correctly work with numbers --- .../array/UniqueItemsAssertionFactory.kt | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/array/UniqueItemsAssertionFactory.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/array/UniqueItemsAssertionFactory.kt index 4da6ce16..92a1aa35 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/array/UniqueItemsAssertionFactory.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/array/UniqueItemsAssertionFactory.kt @@ -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 @@ -36,21 +37,34 @@ private class UniqueItemsAssertion( if (element.size < 2) { return true } - val uniqueItems = element.mapTo(linkedSetOf()) { it } + var duplicates: MutableList? = 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 From c6b2ce3436272e2e6795b6a69dd766c51a21348a Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Wed, 26 Jul 2023 17:31:14 +0400 Subject: [PATCH 11/27] Correct multipleOf assertion to work with small numbers --- .../number/MultipleOfAssertionFactory.kt | 40 +++++++++++- .../JsonSchemaMultipleOfValidationTest.kt | 64 +++++++++++++++++++ 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/number/MultipleOfAssertionFactory.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/number/MultipleOfAssertionFactory.kt index 022e11e6..0dc97505 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/number/MultipleOfAssertionFactory.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/number/MultipleOfAssertionFactory.kt @@ -8,6 +8,11 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.doubleOrNull import kotlinx.serialization.json.longOrNull +import kotlin.math.absoluteValue +import kotlin.math.floor +import kotlin.math.log10 +import kotlin.math.max +import kotlin.math.pow @Suppress("unused") internal object MultipleOfAssertionFactory : AbstractAssertionFactory("multipleOf") { @@ -39,13 +44,42 @@ private fun isMultipleOf(a: Number, b: Number): Boolean = when (a) { } private infix fun Double.isMultipleOf(number: Number): Boolean = when (number) { - is Double -> (this % number).let { it == 0.0 && it == -0.0 } - is Long -> (this % number).let { it == 0.0 && it == -0.0 } + is Double -> isZero(rem(this, number)) + is Long -> isZero((this % number)) else -> false } private infix fun Long.isMultipleOf(number: Number): Boolean = when (number) { is Long -> this % number == 0L - is Double -> (this % number).let { it == 0.0 && it == -0.0 } + is Double -> isZero(rem(this, number)) else -> false +} + +private fun isZero(first: Double): Boolean { + return first == -0.0 || first == 0.0 +} + +private tailrec fun rem(first: Double, second: Double): Double { + return if (second < 1 && second > -1) { + val degree = floor(log10(second)) + if (first < 1 && first > -1) { + val newDegree = max(floor(log10(second)), degree) + val newPow = 10.0.pow(-newDegree) + rem((first * newPow), (second * newPow)) + } else { + val pow = 10.0.pow(-degree) + (first * pow) % (second * pow) + } + } else { + first % second + } +} + +private fun rem(first: Long, second: Double): Double { + return if (second < 1 && second > -1) { + val degree = floor(log10(second)) + first % (second * 10.0.pow(-degree)) + } else { + first % second + } } \ No newline at end of file diff --git a/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/number/JsonSchemaMultipleOfValidationTest.kt b/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/number/JsonSchemaMultipleOfValidationTest.kt index 69943ca2..5d954f05 100644 --- a/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/number/JsonSchemaMultipleOfValidationTest.kt +++ b/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/number/JsonSchemaMultipleOfValidationTest.kt @@ -1,10 +1,15 @@ package io.github.optimumcode.json.schema.assertions.number +import io.github.optimumcode.json.pointer.JsonPointer +import io.github.optimumcode.json.schema.ErrorCollector +import io.github.optimumcode.json.schema.ErrorCollector.Companion import io.github.optimumcode.json.schema.JsonSchema import io.github.optimumcode.json.schema.ValidationError import io.github.optimumcode.json.schema.base.KEY import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec +import io.kotest.core.test.TestScope +import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import kotlinx.serialization.ExperimentalSerializationApi @@ -110,5 +115,64 @@ class JsonSchemaMultipleOfValidationTest : FunSpec() { errors shouldHaveSize 0 } } + + JsonSchema.fromDefinition( + """ + { + "${KEY}schema": "http://json-schema.org/draft-07/schema#", + "multipleOf": 0.0001 + } + """.trimIndent(), + ).apply { + listOf( + JsonUnquotedLiteral("0.0075"), + JsonUnquotedLiteral("0.075"), + JsonUnquotedLiteral("0.75"), + JsonUnquotedLiteral("7.5"), + JsonUnquotedLiteral("75"), + JsonUnquotedLiteral("750"), + JsonUnquotedLiteral("12391239123"), + JsonUnquotedLiteral("-0.0075"), + JsonUnquotedLiteral("-0.075"), + JsonUnquotedLiteral("-0.75"), + JsonUnquotedLiteral("-7.5"), + JsonUnquotedLiteral("-75"), + JsonUnquotedLiteral("-750"), + JsonUnquotedLiteral("-12391239123"), + ).forEach { + test("small number $it is multiple of 0.0001") { + val errors = mutableListOf() + validate(it, errors::add) shouldBe true + errors shouldHaveSize 0 + } + } + + listOf( + JsonUnquotedLiteral("0.00001"), + JsonUnquotedLiteral("0.00011"), + JsonUnquotedLiteral("0.00751"), + JsonUnquotedLiteral("0.01751"), + JsonUnquotedLiteral("0.71751"), + JsonUnquotedLiteral("1.71751"), + JsonUnquotedLiteral("-0.00001"), + JsonUnquotedLiteral("-0.00011"), + JsonUnquotedLiteral("-0.00751"), + JsonUnquotedLiteral("-0.01751"), + JsonUnquotedLiteral("-0.71751"), + JsonUnquotedLiteral("-1.71751"), + ).forEach { + test("small number $it is not a multiple of 0.0001") { + val errors = mutableListOf() + validate(it, errors::add) shouldBe false + errors.shouldContainExactly( + ValidationError( + schemaPath = JsonPointer("/multipleOf"), + objectPath = JsonPointer.ROOT, + message = "$it is not a multiple of 0.0001", + ) + ) + } + } + } } } \ No newline at end of file From a91d079c3167f9a8b90a8aedb68d33ba0356a4dc Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Wed, 26 Jul 2023 18:07:54 +0400 Subject: [PATCH 12/27] Correct number comparison --- .../internal/util/ElementEqualityUtil.kt | 4 +-- .../internal/util/ElementNumberContent.kt | 23 +++++++++++++-- .../internal/util/ElementEqualityUtilTest.kt | 28 ++++++++++++++++++- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt index a1651cc4..5e7b9875 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt @@ -40,9 +40,7 @@ internal fun areEqualPrimitives(first: JsonPrimitive, second: JsonPrimitive): Bo } private fun compareAsNumbers(first: JsonPrimitive, second: JsonPrimitive): Boolean { - val (firstInteger, firstFractional) = numberParts(first) - val (secondInteger, secondFractional) = numberParts(second) - return firstInteger == secondInteger && firstFractional == secondFractional + return numberParts(first) == numberParts(second) } internal fun areEqualArrays(first: JsonArray, second: JsonArray): Boolean { diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementNumberContent.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementNumberContent.kt index aea7c52b..744102c2 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementNumberContent.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementNumberContent.kt @@ -4,10 +4,12 @@ import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.double +import kotlin.math.absoluteValue internal data class NumberParts( val integer: Long, val fractional: Long, + val precision: Int, ) internal fun parseNumberParts(element: JsonPrimitive): NumberParts? { @@ -27,7 +29,13 @@ private const val E_BIG_CHAR: Char = 'E' internal fun numberParts(element: JsonPrimitive): NumberParts { if (element.content.run { contains(E_SMALL_CHAR) || contains(E_BIG_CHAR) }) { return element.double.run { - NumberParts(toLong(), rem(1.0).toLong()) + var precision = 0 + var fractionalPart = rem(1.0).absoluteValue + while (fractionalPart % 1.0 > 0) { + fractionalPart *= 10.0 + precision += 1 + } + NumberParts(toLong(), fractionalPart.toLong(), precision) } } val integerPart = element.content.substringBefore('.') @@ -35,11 +43,22 @@ internal fun numberParts(element: JsonPrimitive): NumberParts { NumberParts( integerPart.toLong(), 0L, + 0, ) } else { + val fractionalPart = element.content.substring(integerPart.length + 1) + var lastNotZero = fractionalPart.length - 1 + for (i in (fractionalPart.length - 1) downTo 0) { + if (fractionalPart[i] != '0') { + lastNotZero = i + break + } + } + val fractionalSize = lastNotZero + 1 NumberParts( integerPart.toLong(), - element.content.substring(integerPart.length + 1).toLong(), + fractionalPart.substring(startIndex = 0, endIndex = fractionalSize).toLong(), + fractionalSize, ) } } diff --git a/src/commonTest/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtilTest.kt b/src/commonTest/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtilTest.kt index b9d85386..33f15d79 100644 --- a/src/commonTest/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtilTest.kt +++ b/src/commonTest/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtilTest.kt @@ -1,5 +1,6 @@ package io.github.optimumcode.json.schema.internal.util +import io.kotest.assertions.asClue import io.kotest.assertions.assertSoftly import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.nulls.shouldNotBeNull @@ -12,11 +13,36 @@ import kotlinx.serialization.json.JsonUnquotedLiteral class ElementEqualityUtilTest : FunSpec() { init { test("extracts number parts from max long engineering format") { - val (integer, fraction) = parseNumberParts(JsonUnquotedLiteral("1e308")) + val (integer, fraction, precision) = parseNumberParts(JsonUnquotedLiteral("1e308")) .shouldNotBeNull() assertSoftly { integer shouldBe Long.MAX_VALUE fraction shouldBe 0L + precision shouldBe 0 + } + } + + test("correctly compares fractional part") { + areEqualPrimitives( + JsonUnquotedLiteral("0.0075"), + JsonUnquotedLiteral("0.00075"), + ) shouldBe false + } + + listOf( + "0.00751", + "0.0075100", + "751e-5", + ).forEach { + test("correctly extract all parts from float number in format $it") { + val parts = parseNumberParts(JsonUnquotedLiteral(it)).shouldNotBeNull() + assertSoftly { + parts.asClue { p -> + p.integer shouldBe 0 + p.precision shouldBe 5 + p.fractional shouldBe 751 + } + } } } } From 26d43d4d6ceb3540119d794c96e3a3be1110bc4d Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Wed, 26 Jul 2023 18:10:06 +0400 Subject: [PATCH 13/27] Reformat code. Correct detekt errors --- .../factories/array/UniqueItemsAssertionFactory.kt | 8 ++++---- .../factories/number/MultipleOfAssertionFactory.kt | 1 - .../json/schema/internal/util/ElementEqualityUtil.kt | 1 - .../util/{ElementNumberContent.kt => NumberParts.kt} | 3 ++- .../number/JsonSchemaMultipleOfValidationTest.kt | 5 +---- 5 files changed, 7 insertions(+), 11 deletions(-) rename src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/{ElementNumberContent.kt => NumberParts.kt} (96%) diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/array/UniqueItemsAssertionFactory.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/array/UniqueItemsAssertionFactory.kt index 92a1aa35..92df0cb1 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/array/UniqueItemsAssertionFactory.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/array/UniqueItemsAssertionFactory.kt @@ -60,10 +60,10 @@ private class UniqueItemsAssertion( objectPath = context.objectPath, message = "array contains duplicate values: ${ duplicates?.joinToString( - prefix = "[", - postfix = "]", - separator = ",", - ) + prefix = "[", + postfix = "]", + separator = ",", + ) }", ), ) diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/number/MultipleOfAssertionFactory.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/number/MultipleOfAssertionFactory.kt index 0dc97505..fe62976b 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/number/MultipleOfAssertionFactory.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/number/MultipleOfAssertionFactory.kt @@ -8,7 +8,6 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.doubleOrNull import kotlinx.serialization.json.longOrNull -import kotlin.math.absoluteValue import kotlin.math.floor import kotlin.math.log10 import kotlin.math.max diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt index 5e7b9875..f835df77 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtil.kt @@ -6,7 +6,6 @@ import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.booleanOrNull -import kotlinx.serialization.json.double import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementNumberContent.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/NumberParts.kt similarity index 96% rename from src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementNumberContent.kt rename to src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/NumberParts.kt index 744102c2..ccdfb67f 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/ElementNumberContent.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/NumberParts.kt @@ -22,6 +22,7 @@ internal fun parseNumberParts(element: JsonPrimitive): NumberParts? { private const val E_SMALL_CHAR: Char = 'e' private const val E_BIG_CHAR: Char = 'E' +private const val TEN: Double = 10.0 /** * This function should be used only if you are certain that the [element] is a number @@ -32,7 +33,7 @@ internal fun numberParts(element: JsonPrimitive): NumberParts { var precision = 0 var fractionalPart = rem(1.0).absoluteValue while (fractionalPart % 1.0 > 0) { - fractionalPart *= 10.0 + fractionalPart *= TEN precision += 1 } NumberParts(toLong(), fractionalPart.toLong(), precision) diff --git a/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/number/JsonSchemaMultipleOfValidationTest.kt b/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/number/JsonSchemaMultipleOfValidationTest.kt index 5d954f05..ca48d071 100644 --- a/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/number/JsonSchemaMultipleOfValidationTest.kt +++ b/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/number/JsonSchemaMultipleOfValidationTest.kt @@ -1,14 +1,11 @@ package io.github.optimumcode.json.schema.assertions.number import io.github.optimumcode.json.pointer.JsonPointer -import io.github.optimumcode.json.schema.ErrorCollector -import io.github.optimumcode.json.schema.ErrorCollector.Companion import io.github.optimumcode.json.schema.JsonSchema import io.github.optimumcode.json.schema.ValidationError import io.github.optimumcode.json.schema.base.KEY import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec -import io.kotest.core.test.TestScope import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe @@ -169,7 +166,7 @@ class JsonSchemaMultipleOfValidationTest : FunSpec() { schemaPath = JsonPointer("/multipleOf"), objectPath = JsonPointer.ROOT, message = "$it is not a multiple of 0.0001", - ) + ), ) } } From dd1d14934f8dc3373229c6d66ec305bad280eab6 Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Wed, 26 Jul 2023 18:21:16 +0400 Subject: [PATCH 14/27] Fix incorrect precision computation --- .../json/schema/internal/util/NumberParts.kt | 6 ++++-- .../internal/util/ElementEqualityUtilTest.kt | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/NumberParts.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/NumberParts.kt index ccdfb67f..d49512c9 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/NumberParts.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/util/NumberParts.kt @@ -51,14 +51,16 @@ internal fun numberParts(element: JsonPrimitive): NumberParts { var lastNotZero = fractionalPart.length - 1 for (i in (fractionalPart.length - 1) downTo 0) { if (fractionalPart[i] != '0') { - lastNotZero = i break } + lastNotZero -= 1 } val fractionalSize = lastNotZero + 1 NumberParts( integerPart.toLong(), - fractionalPart.substring(startIndex = 0, endIndex = fractionalSize).toLong(), + fractionalPart.substring(startIndex = 0, endIndex = fractionalSize) + .takeIf { it.isNotEmpty() } + ?.toLong() ?: 0L, fractionalSize, ) } diff --git a/src/commonTest/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtilTest.kt b/src/commonTest/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtilTest.kt index 33f15d79..7da35547 100644 --- a/src/commonTest/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtilTest.kt +++ b/src/commonTest/kotlin/io/github/optimumcode/json/schema/internal/util/ElementEqualityUtilTest.kt @@ -45,5 +45,26 @@ class ElementEqualityUtilTest : FunSpec() { } } } + + listOf( + "2" to "2.0", + "0.1" to "0.1", + "0.1" to "0.100", + "1e-1" to "0.1", + ).forEach { (first, second) -> + test("numbers $first and $second are equal") { + assertSoftly { + areEqualPrimitives( + JsonUnquotedLiteral(first), + JsonUnquotedLiteral(second), + ) shouldBe true + + areEqualPrimitives( + JsonUnquotedLiteral(second), + JsonUnquotedLiteral(first), + ) shouldBe true + } + } + } } } \ No newline at end of file From ff1ce0a7d93e0461e7508778d1fc45a434c0867e Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Thu, 27 Jul 2023 12:40:03 +0400 Subject: [PATCH 15/27] Use linked maps to keep reproducable order --- .../github/optimumcode/json/schema/internal/SchemaLoader.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/SchemaLoader.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/SchemaLoader.kt index 723c863c..d9fbff84 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/SchemaLoader.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/SchemaLoader.kt @@ -195,8 +195,8 @@ private data class DefaultLoadingContext( private val baseId: Uri, override val schemaPath: JsonPointer = JsonPointer.ROOT, val additionalIDs: Set = linkedSetOf(IdWithLocation(baseId, schemaPath)), - val references: MutableMap = hashMapOf(), - val usedRef: MutableSet = hashSetOf(), + val references: MutableMap = linkedMapOf(), + val usedRef: MutableSet = linkedSetOf(), ) : LoadingContext { override fun at(property: String): DefaultLoadingContext { return copy(schemaPath = schemaPath / property) From e62c586c262e25ad1546237294d17445e691ca72 Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Thu, 27 Jul 2023 12:48:29 +0400 Subject: [PATCH 16/27] Exclude unsupported functionality from test-suites --- .../optimumcode/json/schema/suite/draft7/TestSuite.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/draft7/TestSuite.kt b/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/draft7/TestSuite.kt index c256c7ae..1b7dcfff 100644 --- a/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/draft7/TestSuite.kt +++ b/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/draft7/TestSuite.kt @@ -10,6 +10,12 @@ internal class TestSuite : FunSpec() { draftName = "draft7", excludeSuites = mapOf( "refRemote" to emptySet(), // remote refs are not supported + "definitions" to hashSetOf( + "validate definition against metaschema", // we don't have support for remote ref (metaschema is a remote ref) + ), + "ref" to hashSetOf( + "remote ref, containing refs itself", // we don't have support for remote ref + ), ), ) } From 640f122b21a6169f91dbd86ca0aa366e75843f6e Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Thu, 27 Jul 2023 13:17:46 +0400 Subject: [PATCH 17/27] Add proper support for unicode characters --- .../factories/string/LengthAssertion.kt | 6 ++++-- .../factories/string/util/UnicodeUtil.kt | 21 +++++++++++++++++++ .../JsonSchemaMaxLengthValidationTest.kt | 6 +++++- .../JsonSchemaMinLengthValidationTest.kt | 6 +++++- 4 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/string/util/UnicodeUtil.kt diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/string/LengthAssertion.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/string/LengthAssertion.kt index f198b27a..505562db 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/string/LengthAssertion.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/string/LengthAssertion.kt @@ -5,6 +5,7 @@ import io.github.optimumcode.json.schema.ErrorCollector import io.github.optimumcode.json.schema.ValidationError import io.github.optimumcode.json.schema.internal.AssertionContext import io.github.optimumcode.json.schema.internal.JsonSchemaAssertion +import io.github.optimumcode.json.schema.internal.factories.string.util.codePointCount import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull @@ -20,14 +21,15 @@ internal class LengthAssertion( return true } val content = element.contentOrNull ?: return true - if (check(content.length, lengthValue)) { + val codePointCount = content.codePointCount() + if (check(codePointCount, lengthValue)) { return true } errorCollector.onError( ValidationError( schemaPath = path, objectPath = context.objectPath, - message = "string length (${content.length}) $errorMessage $lengthValue", + message = "string length ($codePointCount) $errorMessage $lengthValue", ), ) return false diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/string/util/UnicodeUtil.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/string/util/UnicodeUtil.kt new file mode 100644 index 00000000..135c6c4a --- /dev/null +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/string/util/UnicodeUtil.kt @@ -0,0 +1,21 @@ +package io.github.optimumcode.json.schema.internal.factories.string.util + +internal fun CharSequence.codePointCount(): Int { + val endIndex = length + var index = 0 + var count = 0 + while (index < endIndex) { + val firstChar = this[index] + index++ + if (firstChar.isHighSurrogate() && index < endIndex) { + val nextChar = this[index] + if (nextChar.isLowSurrogate()) { + index++ + } + } + + count++ + } + + return count +} \ No newline at end of file diff --git a/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/string/JsonSchemaMaxLengthValidationTest.kt b/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/string/JsonSchemaMaxLengthValidationTest.kt index db015020..a7261d4a 100644 --- a/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/string/JsonSchemaMaxLengthValidationTest.kt +++ b/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/string/JsonSchemaMaxLengthValidationTest.kt @@ -4,6 +4,7 @@ import io.github.optimumcode.json.pointer.JsonPointer import io.github.optimumcode.json.schema.JsonSchema import io.github.optimumcode.json.schema.ValidationError import io.github.optimumcode.json.schema.base.KEY +import io.github.optimumcode.json.schema.internal.factories.string.util.codePointCount import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldHaveSize @@ -32,6 +33,8 @@ class JsonSchemaMaxLengthValidationTest : FunSpec() { "╒", "V", "", + "💩".repeat(20), + "💩", ) for (str in validStrings) { test("'$str' passes validation") { @@ -46,6 +49,7 @@ class JsonSchemaMaxLengthValidationTest : FunSpec() { "EFDzZMRawYGD9eNfknAUB", "⌻ⲝ⣞ℤ⸍⠗⠜ↈ✋☧⾛✩ⓥ⇩⡽⚘\u20FC◭┐⥸⒗", "⠺⪒⑸⋶⥠⇀⨑⨋ⅸ⥼\u245F⏇Ⓙⴷ⻘⢢≧\u20C8⬫⡜⸁", + "💩".repeat(21), ) for (str in invalidStrings) { test("'$str' does not pass validation") { @@ -56,7 +60,7 @@ class JsonSchemaMaxLengthValidationTest : FunSpec() { ValidationError( schemaPath = JsonPointer("/maxLength"), objectPath = JsonPointer.ROOT, - message = "string length (${str.length}) must be less or equal to 20", + message = "string length (${str.codePointCount()}) must be less or equal to 20", ), ) } diff --git a/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/string/JsonSchemaMinLengthValidationTest.kt b/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/string/JsonSchemaMinLengthValidationTest.kt index bded9c5e..fe825671 100644 --- a/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/string/JsonSchemaMinLengthValidationTest.kt +++ b/src/commonTest/kotlin/io/github/optimumcode/json/schema/assertions/string/JsonSchemaMinLengthValidationTest.kt @@ -4,6 +4,7 @@ import io.github.optimumcode.json.pointer.JsonPointer import io.github.optimumcode.json.schema.JsonSchema import io.github.optimumcode.json.schema.ValidationError import io.github.optimumcode.json.schema.base.KEY +import io.github.optimumcode.json.schema.internal.factories.string.util.codePointCount import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldHaveSize @@ -31,6 +32,8 @@ class JsonSchemaMinLengthValidationTest : FunSpec() { "JpEblYiJE57H70qGNXs", "ⅵ┡\u243A⢻␀⁾⡪∛⫑⏽", "Si1kaAhdpS", + "💩".repeat(11), + "💩".repeat(10), ) for (str in validStrings) { test("'$str' passes validation") { @@ -47,6 +50,7 @@ class JsonSchemaMinLengthValidationTest : FunSpec() { " ⍘↽♔⚪➕ⷰ➖", "⧦", "", + "💩".repeat(9), ) for (str in invalidStrings) { test("'$str' does not pass validation") { @@ -57,7 +61,7 @@ class JsonSchemaMinLengthValidationTest : FunSpec() { ValidationError( schemaPath = JsonPointer("/minLength"), objectPath = JsonPointer.ROOT, - message = "string length (${str.length}) must be greater or equal to 10", + message = "string length (${str.codePointCount()}) must be greater or equal to 10", ), ) } From 9fa6d4d49ad958ea6a4ef0d0f93f071a46ec4486 Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Thu, 27 Jul 2023 13:29:57 +0400 Subject: [PATCH 18/27] Load all if-then-else assertions because they might be referenced --- .../condition/IfThenElseAssertionFactory.kt | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/condition/IfThenElseAssertionFactory.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/condition/IfThenElseAssertionFactory.kt index 42d9e0b9..cb995151 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/condition/IfThenElseAssertionFactory.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/condition/IfThenElseAssertionFactory.kt @@ -5,34 +5,38 @@ 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 +// TODO: all parts must be loaded separately 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( From e501479f288d1455c5beed6c89e9b7243838895a Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Thu, 27 Jul 2023 15:22:19 +0400 Subject: [PATCH 19/27] Correct work with empty path segment in JSON pointer --- .../optimumcode/json/pointer/JsonPointer.kt | 8 ++--- .../optimumcode/json/pointer/extensions.kt | 9 ++--- .../json/pointer/JsonPointerExtensionsTest.kt | 25 ++++++++++++++ .../json/pointer/JsonPointerTest.kt | 33 +++++++++++++++++++ 4 files changed, 62 insertions(+), 13 deletions(-) diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/pointer/JsonPointer.kt b/src/commonMain/kotlin/io/github/optimumcode/json/pointer/JsonPointer.kt index 948487ba..87244a25 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/pointer/JsonPointer.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/pointer/JsonPointer.kt @@ -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) }, ) @@ -53,9 +51,7 @@ public sealed class JsonPointer( buildString { val pointer = this@JsonPointer.toString() append(pointer) - if (!pointer.endsWith(SEPARATOR)) { - append(SEPARATOR) - } + append(SEPARATOR) append(property) }, ) diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/pointer/extensions.kt b/src/commonMain/kotlin/io/github/optimumcode/json/pointer/extensions.kt index bb80dad3..e36773f3 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/pointer/extensions.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/pointer/extensions.kt @@ -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()) }, ) } diff --git a/src/commonTest/kotlin/io/github/optimumcode/json/pointer/JsonPointerExtensionsTest.kt b/src/commonTest/kotlin/io/github/optimumcode/json/pointer/JsonPointerExtensionsTest.kt index 3325ed97..877ca118 100644 --- a/src/commonTest/kotlin/io/github/optimumcode/json/pointer/JsonPointerExtensionsTest.kt +++ b/src/commonTest/kotlin/io/github/optimumcode/json/pointer/JsonPointerExtensionsTest.kt @@ -18,11 +18,21 @@ class JsonPointerExtensionsTest : FunSpec() { "prop", JsonPointer("/prop"), ), + TestCase( + JsonPointer.ROOT, + "", + JsonPointer("/"), + ), TestCase( JsonPointer("/test"), "prop", JsonPointer("/test/prop"), ), + TestCase( + JsonPointer("/test"), + "", + JsonPointer("/test/"), + ), ).forEach { (initial, prop, result) -> test("$initial / $prop => $result") { (initial / prop) shouldBe result @@ -40,6 +50,11 @@ class JsonPointerExtensionsTest : FunSpec() { 0, JsonPointer("/test/0"), ), + TestCase( + JsonPointer("/test/"), + 0, + JsonPointer("/test//0"), + ), ).forEach { (initial, prop, result) -> test("$initial [ $prop ] => $result") { (initial[prop]) shouldBe result @@ -67,6 +82,11 @@ class JsonPointerExtensionsTest : FunSpec() { JsonPointer("/test2"), JsonPointer("/test1/test2"), ), + TestCase( + JsonPointer("/test1/"), + JsonPointer("/test2"), + JsonPointer("/test1//test2"), + ), ).forEach { (init, append, result) -> test("$init + $append => $result") { (init + append) shouldBe result @@ -89,6 +109,11 @@ class JsonPointerExtensionsTest : FunSpec() { JsonPointer("/test/data"), JsonPointer("/data"), ), + TestCase( + JsonPointer("/test"), + JsonPointer("/test//data"), + JsonPointer("//data") + ) ).forEach { (base, relativeToBase, relativePath) -> test("relative path from '$base' to '$relativeToBase' is '$relativePath'") { base.relative(relativeToBase) shouldBe relativePath diff --git a/src/commonTest/kotlin/io/github/optimumcode/json/pointer/JsonPointerTest.kt b/src/commonTest/kotlin/io/github/optimumcode/json/pointer/JsonPointerTest.kt index 515c7e7e..e3e59129 100644 --- a/src/commonTest/kotlin/io/github/optimumcode/json/pointer/JsonPointerTest.kt +++ b/src/commonTest/kotlin/io/github/optimumcode/json/pointer/JsonPointerTest.kt @@ -4,6 +4,7 @@ import io.kotest.assertions.asClue import io.kotest.assertions.assertSoftly import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.should import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe @@ -77,6 +78,38 @@ class JsonPointerTest : FunSpec() { val pointer = JsonPointer("/~") pointer.assertSegment(property = "~") } + + test("empty segment in the end") { + val pointer = JsonPointer("/test/") + assertSoftly { + pointer.assertSegment(property = "test") + pointer.next + .shouldNotBeNull() + .assertSegment(property = "") + pointer.toString() shouldBe "/test/" + } + } + + test("empty segment in the beginning") { + val pointer = JsonPointer("/") + assertSoftly { + pointer.assertSegment(property = "") + pointer.next.shouldNotBeNull().shouldBe(EmptyPointer) + pointer.toString() shouldBe "/" + } + } + + test("empty segment in the middle") { + val pointer = JsonPointer("/test1//test2") + assertSoftly { + pointer.assertSegment(property = "test1") + var next = pointer.next.shouldNotBeNull() + next.assertSegment(property = "") + next = next.next.shouldNotBeNull() + next.assertSegment(property = "test2") + pointer.toString() shouldBe "/test1//test2" + } + } } private fun JsonPointer.assertSegment(property: String, index: Int = -1) { From f5174a663bd9aa4accdb21436c461391dd4a4498 Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Thu, 27 Jul 2023 15:29:04 +0400 Subject: [PATCH 20/27] Add quotation for special characters when adding segment to pointer --- .../io/github/optimumcode/json/pointer/JsonPointer.kt | 8 +++++++- .../json/pointer/JsonPointerExtensionsTest.kt | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/pointer/JsonPointer.kt b/src/commonMain/kotlin/io/github/optimumcode/json/pointer/JsonPointer.kt index 87244a25..fada6edf 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/pointer/JsonPointer.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/pointer/JsonPointer.kt @@ -52,7 +52,13 @@ public sealed class JsonPointer( val pointer = this@JsonPointer.toString() append(pointer) append(SEPARATOR) - append(property) + for (ch in property) { + when (ch) { + QUOTATION -> append(QUOTATION).append("0") + SEPARATOR -> append(QUOTATION).append("1") + else -> append(ch) + } + } }, ) diff --git a/src/commonTest/kotlin/io/github/optimumcode/json/pointer/JsonPointerExtensionsTest.kt b/src/commonTest/kotlin/io/github/optimumcode/json/pointer/JsonPointerExtensionsTest.kt index 877ca118..9a323161 100644 --- a/src/commonTest/kotlin/io/github/optimumcode/json/pointer/JsonPointerExtensionsTest.kt +++ b/src/commonTest/kotlin/io/github/optimumcode/json/pointer/JsonPointerExtensionsTest.kt @@ -33,6 +33,16 @@ class JsonPointerExtensionsTest : FunSpec() { "", JsonPointer("/test/"), ), + TestCase( + JsonPointer("/test"), + "tilde~field", + JsonPointer("/test/tilde~0field"), + ), + TestCase( + JsonPointer("/test"), + "slash/field", + JsonPointer("/test/slash~1field"), + ), ).forEach { (initial, prop, result) -> test("$initial / $prop => $result") { (initial / prop) shouldBe result From 2fc0a7b619f25ddd64e54247f68d998a06e17714 Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Fri, 28 Jul 2023 10:08:48 +0400 Subject: [PATCH 21/27] Correct uri resolution --- .../json/schema/internal/SchemaLoader.kt | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/SchemaLoader.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/SchemaLoader.kt index d9fbff84..ea3c8af0 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/SchemaLoader.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/SchemaLoader.kt @@ -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, ) }, @@ -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) } @@ -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, ) @@ -294,6 +297,24 @@ private data class DefaultLoadingContext( } } +private fun Set.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() From a8ab2e6d03b1025dcbfa3de1a2b9cbf79a0f2031 Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Fri, 28 Jul 2023 10:17:04 +0400 Subject: [PATCH 22/27] Add resolution for test suites for nodejs test run --- .../schema/suite/AbstractSchemaTestSuite.kt | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt b/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt index f5d4f957..82af3b75 100644 --- a/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt +++ b/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt @@ -46,8 +46,13 @@ internal fun FunSpec.runTestSuites( excludeTests: Map> = emptyMap(), ) { require(draftName.isNotBlank()) { "draftName is blank" } - val testSuiteDir = TEST_SUITES_DIR / draftName val fs = fileSystem() + val testSuiteDir = when { + fs.exists(TEST_SUITES_DIR) -> TEST_SUITES_DIR + fs.exists(TEST_SUITES_DIR_FROM_ROOT) -> TEST_SUITES_DIR_FROM_ROOT + else -> fs.resolveRoot() ?: error("neither $TEST_SUITES_DIR or $TEST_SUITES_DIR_FROM_ROOT exist") + }.resolve(draftName) + require(fs.exists(testSuiteDir)) { "folder $testSuiteDir does not exist" } executeFromDirectory(fs, testSuiteDir, excludeSuites, excludeTests) @@ -120,5 +125,18 @@ private class SchemaTest( ) private val TEST_SUITES_DIR: Path = "schema-test-suite/tests".toPath() +private val TEST_SUITES_DIR_FROM_ROOT: Path = "test-suites".toPath() / TEST_SUITES_DIR + +/** + * This function tries to find the repo root using `build` folder as maker. + * + * This is done in order to execute NodeJS tests + */ +private fun FileSystem.resolveRoot(): Path? { + val absolutePath = canonicalize(".".toPath()) + return generateSequence(absolutePath) { + it.parent + }.find { it.name == "build" }?.parent?.resolve(TEST_SUITES_DIR_FROM_ROOT) +} expect fun fileSystem(): FileSystem \ No newline at end of file From 8b2156cc12f4d0a83b0bf2f252b0781099ceefa4 Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Fri, 28 Jul 2023 11:09:39 +0400 Subject: [PATCH 23/27] Remove todo comment. Create an issue for that instead --- .../internal/factories/condition/IfThenElseAssertionFactory.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/condition/IfThenElseAssertionFactory.kt b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/condition/IfThenElseAssertionFactory.kt index cb995151..938f200e 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/condition/IfThenElseAssertionFactory.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/factories/condition/IfThenElseAssertionFactory.kt @@ -9,7 +9,6 @@ import io.github.optimumcode.json.schema.internal.TrueSchemaAssertion import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject -// TODO: all parts must be loaded separately internal object IfThenElseAssertionFactory : AssertionFactory { private const val IF_PROPERTY: String = "if" private const val THEN_PROPERTY: String = "then" From b667649068359a5fda96ce4e7d78713d6490a8b8 Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Fri, 28 Jul 2023 11:21:33 +0400 Subject: [PATCH 24/27] Correct test name to correctly work on windows --- .../json/schema/suite/AbstractSchemaTestSuite.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt b/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt index 82af3b75..5056c69d 100644 --- a/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt +++ b/test-suites/src/commonTest/kotlin/io/github/optimumcode/json/schema/suite/AbstractSchemaTestSuite.kt @@ -87,21 +87,27 @@ private fun FunSpec.executeFromDirectory( val testSuites: List = fs.openReadOnly(testSuiteFile).use { Json.decodeFromBufferedSource(ListSerializer(TestSuite.serializer()), it.source().buffer()) } + var testSuiteIndex = -1 for (testSuite in testSuites) { + testSuiteIndex += 1 if (excludeTestSuitesWithDescription != null && testSuite.description in excludeTestSuitesWithDescription) { continue } val excludeTestWithDescription: Set? = excludeTests[testSuite.description] + var testIndex = -1 for (test in testSuite.tests) { + testIndex += 1 if (excludeTestWithDescription != null && test.description in excludeTestWithDescription) { continue } - test("$testSuiteID > ${testSuite.description} > ${test.description}") { - withClue(listOf(testSuite.schema, test.description, test.data)) { + test("$testSuiteID at index $testSuiteIndex test $testIndex") { + withClue(listOf(testSuite.description, testSuite.schema, test.description, test.data)) { val schema: JsonSchema = shouldNotThrowAny { JsonSchema.fromJsonElement(testSuite.schema) } - schema.validate(test.data, ErrorCollector.EMPTY) shouldBe test.valid + shouldNotThrowAny { + schema.validate(test.data, ErrorCollector.EMPTY) + } shouldBe test.valid } } } From a663df44ce13be38c49a135576f7883355954f69 Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Fri, 28 Jul 2023 11:34:38 +0400 Subject: [PATCH 25/27] Correct formatting --- .../optimumcode/json/pointer/JsonPointerExtensionsTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commonTest/kotlin/io/github/optimumcode/json/pointer/JsonPointerExtensionsTest.kt b/src/commonTest/kotlin/io/github/optimumcode/json/pointer/JsonPointerExtensionsTest.kt index 9a323161..1599cea0 100644 --- a/src/commonTest/kotlin/io/github/optimumcode/json/pointer/JsonPointerExtensionsTest.kt +++ b/src/commonTest/kotlin/io/github/optimumcode/json/pointer/JsonPointerExtensionsTest.kt @@ -122,8 +122,8 @@ class JsonPointerExtensionsTest : FunSpec() { TestCase( JsonPointer("/test"), JsonPointer("/test//data"), - JsonPointer("//data") - ) + JsonPointer("//data"), + ), ).forEach { (base, relativeToBase, relativePath) -> test("relative path from '$base' to '$relativeToBase' is '$relativePath'") { base.relative(relativeToBase) shouldBe relativePath From ea6828a37dbb3466835258281b4c6ed258c5667d Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Fri, 28 Jul 2023 11:47:26 +0400 Subject: [PATCH 26/27] Use chars instead of string for quotation --- .../io/github/optimumcode/json/pointer/JsonPointer.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/commonMain/kotlin/io/github/optimumcode/json/pointer/JsonPointer.kt b/src/commonMain/kotlin/io/github/optimumcode/json/pointer/JsonPointer.kt index fada6edf..cd2b9976 100644 --- a/src/commonMain/kotlin/io/github/optimumcode/json/pointer/JsonPointer.kt +++ b/src/commonMain/kotlin/io/github/optimumcode/json/pointer/JsonPointer.kt @@ -54,8 +54,8 @@ public sealed class JsonPointer( append(SEPARATOR) for (ch in property) { when (ch) { - QUOTATION -> append(QUOTATION).append("0") - SEPARATOR -> append(QUOTATION).append("1") + QUOTATION -> append(QUOTATION).append(QUOTATION_ESCAPE) + SEPARATOR -> append(QUOTATION).append(SEPARATOR_ESCAPE) else -> append(ch) } } @@ -89,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 @@ -207,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 From 7036f8a401baf09e48758a16ddb6c184253df7b8 Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Fri, 28 Jul 2023 12:05:05 +0400 Subject: [PATCH 27/27] Add note about test suite compliance --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 2c02aee1..72f8787e 100644 --- a/README.md +++ b/README.md @@ -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)