From 33722fb686c3d9d7490846f63e94b84ac86b744b Mon Sep 17 00:00:00 2001 From: Oleg Smirnov Date: Tue, 3 Sep 2024 19:09:45 +0400 Subject: [PATCH] Add module to wrap kotlin objects for validation --- .../api/json-schema-validator-objects.api | 13 + .../build.gradle.kts | 134 ++++++++++ .../json/schema/objects/wrapper/Wrappers.kt | 187 ++++++++++++++ .../schema/objects/wrapper/WrappersTest.kt | 235 ++++++++++++++++++ settings.gradle.kts | 3 +- 5 files changed, 571 insertions(+), 1 deletion(-) create mode 100644 json-schema-validator-objects/api/json-schema-validator-objects.api create mode 100644 json-schema-validator-objects/build.gradle.kts create mode 100644 json-schema-validator-objects/src/commonMain/kotlin/io/github/optimumcode/json/schema/objects/wrapper/Wrappers.kt create mode 100644 json-schema-validator-objects/src/commonTest/kotlin/io/github/optimumcode/json/schema/objects/wrapper/WrappersTest.kt diff --git a/json-schema-validator-objects/api/json-schema-validator-objects.api b/json-schema-validator-objects/api/json-schema-validator-objects.api new file mode 100644 index 0000000..6f863a7 --- /dev/null +++ b/json-schema-validator-objects/api/json-schema-validator-objects.api @@ -0,0 +1,13 @@ +public final class io/github/optimumcode/json/schema/objects/wrapper/ObjectWrappers { + public static final fun wrapAsElement (Ljava/lang/Object;Lio/github/optimumcode/json/schema/objects/wrapper/WrappingConfiguration;)Lio/github/optimumcode/json/schema/model/AbstractElement; + public static synthetic fun wrapAsElement$default (Ljava/lang/Object;Lio/github/optimumcode/json/schema/objects/wrapper/WrappingConfiguration;ILjava/lang/Object;)Lio/github/optimumcode/json/schema/model/AbstractElement; + public static final fun wrappingConfiguration ()Lio/github/optimumcode/json/schema/objects/wrapper/WrappingConfiguration; + public static final fun wrappingConfiguration (Z)Lio/github/optimumcode/json/schema/objects/wrapper/WrappingConfiguration; + public static synthetic fun wrappingConfiguration$default (ZILjava/lang/Object;)Lio/github/optimumcode/json/schema/objects/wrapper/WrappingConfiguration; +} + +public final class io/github/optimumcode/json/schema/objects/wrapper/WrappingConfiguration { + public fun ()V + public final fun getAllowSets ()Z +} + diff --git a/json-schema-validator-objects/build.gradle.kts b/json-schema-validator-objects/build.gradle.kts new file mode 100644 index 0000000..cd67b13 --- /dev/null +++ b/json-schema-validator-objects/build.gradle.kts @@ -0,0 +1,134 @@ +@file:OptIn(ExperimentalWasmDsl::class) + +import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.plugin.KotlinTarget +import org.jetbrains.kotlin.gradle.plugin.KotlinTargetWithTests +import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl +import org.jlleitschuh.gradle.ktlint.reporter.ReporterType + +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) + alias(libs.plugins.kotlin.dokka) + convention.publication +} + +kotlin { + explicitApi() + + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + freeCompilerArgs.add("-opt-in=io.github.optimumcode.json.schema.ExperimentalApi") + } + jvmToolchain(11) + jvm { + testRuns["test"].executionTask.configure { + useJUnitPlatform() + } + } + js(IR) { + browser() + generateTypeScriptDefinitions() + nodejs() + } + wasmJs { + // The wasmJsBrowserTest prints all executed tests as one unformatted string + // Have not found a way to suppress printing all this into console + browser() + nodejs() + } + + applyDefaultHierarchyTemplate() + + val macOsTargets = + listOf( + macosX64(), + macosArm64(), + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ) + + val linuxTargets = + listOf( + linuxX64(), + linuxArm64(), + ) + + val windowsTargets = + listOf( + mingwX64(), + ) + + sourceSets { + commonMain { + dependencies { + api(projects.jsonSchemaValidator) + } + } + + commonTest { + dependencies { + implementation(libs.kotest.assertions.core) + implementation(libs.kotest.framework.engine) + implementation(kotlin("test-common")) + implementation(kotlin("test-annotations-common")) + } + } + jvmTest { + dependencies { + implementation(libs.kotest.runner.junit5) + } + } + } + + 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")) + dependsOn(tasks.getByName("wasmJsTest")) + } + } +} + +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/json-schema-validator-objects/src/commonMain/kotlin/io/github/optimumcode/json/schema/objects/wrapper/Wrappers.kt b/json-schema-validator-objects/src/commonMain/kotlin/io/github/optimumcode/json/schema/objects/wrapper/Wrappers.kt new file mode 100644 index 0000000..01de26b --- /dev/null +++ b/json-schema-validator-objects/src/commonMain/kotlin/io/github/optimumcode/json/schema/objects/wrapper/Wrappers.kt @@ -0,0 +1,187 @@ +@file:JvmName("ObjectWrappers") + +package io.github.optimumcode.json.schema.objects.wrapper + +import io.github.optimumcode.json.schema.ExperimentalApi +import io.github.optimumcode.json.schema.model.AbstractElement +import io.github.optimumcode.json.schema.model.ArrayElement +import io.github.optimumcode.json.schema.model.ObjectElement +import io.github.optimumcode.json.schema.model.PrimitiveElement +import kotlin.jvm.JvmInline +import kotlin.jvm.JvmName +import kotlin.jvm.JvmOverloads + +@ExperimentalApi +public class WrappingConfiguration internal constructor( + public val allowSets: Boolean = false, +) + +@ExperimentalApi +@JvmOverloads +public fun wrappingConfiguration(allowSets: Boolean = false): WrappingConfiguration = WrappingConfiguration(allowSets) + +/** + * Returns an [AbstractElement] produced by converting the [obj] value. + * The [configuration] allows conversion customization. + * + * # The supported types + * + * ## Simple values: + * * [String] + * * [Byte] + * * [Short] + * * [Int] + * * [Long] + * * [Float] + * * [Double] + * * [Boolean] + * * `null` + * + * ## Structures: + * * [Map] -> keys MUST have a [String] type, values MUST be one of the supported types + * * [List] -> elements MUST be one of the supported types + * * [Array] -> elements MUST be one of the supported types + * + * If [WrappingConfiguration.allowSets] is enabled [Set] is also converted to [ArrayElement]. + * Please be aware that in order to have consistent verification results + * the [Set] must be one of the ORDERED types, e.g. [LinkedHashSet]. + */ +@ExperimentalApi +public fun wrapAsElement( + obj: Any?, + configuration: WrappingConfiguration = WrappingConfiguration(), +): AbstractElement { + if (obj == null) { + return NullWrapper + } + return when { + obj is Map<*, *> -> checkKeysAndWrap(obj, configuration) + obj is List<*> -> ListWrapper(obj.map { wrapAsElement(it, configuration) }) + obj is Array<*> -> ListWrapper(obj.map { wrapAsElement(it, configuration) }) + obj is Set<*> && configuration.allowSets -> + ListWrapper(obj.map { wrapAsElement(it, configuration) }) + obj is String || obj is Number || obj is Boolean -> PrimitiveWrapper(numberToSupportedTypeOrOriginal(obj)) + else -> error("unsupported type to wrap: ${obj::class}") + } +} + +private fun numberToSupportedTypeOrOriginal(obj: Any): Any = + when (obj) { + !is Number -> obj + is Double, is Long -> obj + is Byte, is Short, is Int -> obj.toLong() + is Float -> obj.toDoubleSafe() + else -> error("unsupported number type: ${obj::class}") + } + +private fun Float.toDoubleSafe(): Double { + val double = toDouble() + // in some cases the conversion from float to double + // can introduce a difference between numbers. (e.g. 42.2f -> 42.2) + // In this case, the only way (at the moment) is to try parsing + // the double from float converted to string + val floatAsString = toString() + if (double.toString() == floatAsString) { + return double + } + return floatAsString.toDouble() +} + +private fun checkKeysAndWrap( + map: Map<*, *>, + configuration: WrappingConfiguration, +): ObjectWrapper { + if (map.isEmpty()) { + return ObjectWrapper(emptyMap()) + } + + require(map.keys.all { it is String }) { + val notStrings = + map.keys.asSequence().filterNot { it is String }.mapTo(hashSetOf()) { key -> + key?.let { it::class.simpleName } ?: "null" + }.joinToString() + "map keys must be strings, found: $notStrings" + } + + @Suppress("UNCHECKED_CAST") + val elementsMap = + map.mapValues { (_, value) -> + wrapAsElement(value, configuration) + } as Map + return ObjectWrapper(elementsMap) +} + +@JvmInline +private value class ObjectWrapper( + private val map: Map, +) : ObjectElement { + override val keys: Set + get() = map.keys + + override fun get(key: String): AbstractElement? = map[key] + + override fun contains(key: String): Boolean = map.containsKey(key) + + override val size: Int + get() = map.size + + override fun iterator(): Iterator> = + map.asSequence().map { (key, value) -> key to value }.iterator() + + override fun toString(): String = map.toString() +} + +@JvmInline +private value class ListWrapper( + private val list: List, +) : ArrayElement { + override fun iterator(): Iterator = list.iterator() + + override fun get(index: Int): AbstractElement = list[index] + + override val size: Int + get() = list.size + + override fun toString(): String = list.toString() +} + +@JvmInline +private value class PrimitiveWrapper( + private val value: Any, +) : PrimitiveElement { + override val isNull: Boolean + get() = false + override val isString: Boolean + get() = value is String + override val isBoolean: Boolean + get() = value is Boolean + override val isNumber: Boolean + get() = value is Number + override val longOrNull: Long? + get() = value as? Long + override val doubleOrNull: Double? + get() = value as? Double + override val content: String + get() = value.toString() + + override fun toString(): String = value.toString() +} + +private data object NullWrapper : PrimitiveElement { + override val isNull: Boolean + get() = true + override val isString: Boolean + get() = false + override val isBoolean: Boolean + get() = false + override val isNumber: Boolean + get() = false + override val longOrNull: Long? + get() = null + override val doubleOrNull: Double? + get() = null + override val content: String + get() = "null" + + override fun toString(): String = "null" +} \ No newline at end of file diff --git a/json-schema-validator-objects/src/commonTest/kotlin/io/github/optimumcode/json/schema/objects/wrapper/WrappersTest.kt b/json-schema-validator-objects/src/commonTest/kotlin/io/github/optimumcode/json/schema/objects/wrapper/WrappersTest.kt new file mode 100644 index 0000000..e13d9d5 --- /dev/null +++ b/json-schema-validator-objects/src/commonTest/kotlin/io/github/optimumcode/json/schema/objects/wrapper/WrappersTest.kt @@ -0,0 +1,235 @@ +package io.github.optimumcode.json.schema.objects.wrapper + +import io.github.optimumcode.json.schema.model.ArrayElement +import io.github.optimumcode.json.schema.model.ObjectElement +import io.github.optimumcode.json.schema.model.PrimitiveElement +import io.kotest.assertions.assertSoftly +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldStartWith +import io.kotest.matchers.types.shouldBeInstanceOf + +class WrappersTest : FunSpec() { + init { + fun Any?.str(): String = + when (this) { + is Array<*> -> this.contentToString() + else -> toString() + } + + fun Any?.type(): String = this?.let { "(${it::class.simpleName}) " } ?: "" + + mapOf( + emptyMap() to ObjectElement::class, + listOf() to ArrayElement::class, + emptyArray() to ArrayElement::class, + "test" to PrimitiveElement::class, + 42 to PrimitiveElement::class, + 42L to PrimitiveElement::class, + 42.2 to PrimitiveElement::class, + 42.2f to PrimitiveElement::class, + true to PrimitiveElement::class, + null to PrimitiveElement::class, + ).forEach { (obj, wrapperClass) -> + test("element ${obj.str()} ${obj.type()}is wrapped into ${wrapperClass.simpleName}") { + wrapperClass.isInstance(wrapAsElement(obj)).shouldBeTrue() + } + } + + test("primitive wrapper for null") { + wrapAsElement(null).shouldBeInstanceOf { + assertSoftly { + it.isString.shouldBeFalse() + it.isNumber.shouldBeFalse() + it.isBoolean.shouldBeFalse() + it.isNull.shouldBeTrue() + it.content shouldBe "null" + it.longOrNull.shouldBeNull() + it.doubleOrNull.shouldBeNull() + } + } + } + + test("primitive wrapper for boolean") { + wrapAsElement(true).shouldBeInstanceOf { + assertSoftly { + it.isString.shouldBeFalse() + it.isNumber.shouldBeFalse() + it.isBoolean.shouldBeTrue() + it.isNull.shouldBeFalse() + it.content shouldBe "true" + it.longOrNull.shouldBeNull() + it.doubleOrNull.shouldBeNull() + } + } + } + + test("primitive wrapper for number") { + wrapAsElement(42).shouldBeInstanceOf { + assertSoftly { + it.isString.shouldBeFalse() + it.isNumber.shouldBeTrue() + it.isBoolean.shouldBeFalse() + it.isNull.shouldBeFalse() + it.content shouldBe "42" + it.longOrNull shouldBe 42L + it.doubleOrNull.shouldBeNull() + } + } + } + + test("primitive wrapper for string") { + wrapAsElement("42").shouldBeInstanceOf { + assertSoftly { + it.isString.shouldBeTrue() + it.isNumber.shouldBeFalse() + it.isBoolean.shouldBeFalse() + it.isNull.shouldBeFalse() + it.content shouldBe "42" + it.longOrNull.shouldBeNull() + it.doubleOrNull.shouldBeNull() + } + } + } + + test("object wrapper") { + wrapAsElement( + buildMap { + put("a", "hello") + put("b", listOf()) + put("c", mapOf()) + put("d", null) + }, + ).shouldBeInstanceOf { + assertSoftly { + it.size shouldBe 4 + it.keys shouldContainExactly setOf("a", "b", "c", "d") + it["a"].shouldBeInstanceOf() + it["b"].shouldBeInstanceOf() + it["c"].shouldBeInstanceOf() + it["d"].shouldBeInstanceOf { + it.isNull.shouldBeTrue() + } + it["e"].shouldBeNull() + ("a" in it).shouldBeTrue() + ("e" in it).shouldBeFalse() + } + } + } + + test("array wrapper") { + wrapAsElement( + buildList { + add("hello") + add(mapOf()) + add(listOf()) + add(null) + }, + ).shouldBeInstanceOf { + assertSoftly { + it.size shouldBe 4 + it[0].shouldBeInstanceOf() + it[1].shouldBeInstanceOf() + it[2].shouldBeInstanceOf() + it[3].shouldBeInstanceOf { + it.isNull.shouldBeTrue() + } + } + } + } + + test("set is not allowed by default") { + shouldThrow { + wrapAsElement(setOf("a")) + }.message.shouldStartWith("unsupported type to wrap:") + } + + test("set is allowed if configuration is provided") { + val element = + shouldNotThrowAny { + wrapAsElement( + setOf("a"), + wrappingConfiguration( + allowSets = true, + ), + ) + } + element.shouldBeInstanceOf { + it.size shouldBe 1 + it[0].shouldBeInstanceOf() + } + } + + mapOf(42 to "Int", null to "null").forEach { (key, type) -> + test("map with key '${key.str()}' ${key.type()} is not allowed") { + shouldThrow { + wrapAsElement( + mapOf(key to "test"), + ) + }.message.shouldBe("map keys must be strings, found: $type") + } + } + + mapOf( + 42.toByte() to 42L, + 42.toShort() to 42L, + 42 to 42L, + 42L to 42L, + ).forEach { (originalNumber, convertedNumber) -> + val name = + "integer number $originalNumber ${originalNumber.type()}" + + "converted to $convertedNumber ${convertedNumber.type()}" + test(name) { + wrapAsElement(originalNumber).shouldBeInstanceOf { + it.longOrNull.shouldNotBeNull() + .shouldBe(convertedNumber) + it.doubleOrNull.shouldBeNull() + } + } + } + + mapOf( + 42.2f to 42.2, + 42.5f to 42.5, + 42.5 to 42.5, + ).forEach { (originalNumber, convertedNumber) -> + val name = + "floating number $originalNumber ${originalNumber.type()}" + + "converted to $convertedNumber ${convertedNumber.type()}" + test(name) { + wrapAsElement(originalNumber).shouldBeInstanceOf { + it.doubleOrNull.shouldNotBeNull() + .shouldBe(convertedNumber) + it.longOrNull.shouldBeNull() + } + } + } + + test("other number implementations are not allowed") { + shouldThrow { + wrapAsElement( + object : Number() { + override fun toByte(): Byte = TODO("Not yet implemented") + + override fun toDouble(): Double = TODO("Not yet implemented") + + override fun toFloat(): Float = TODO("Not yet implemented") + + override fun toInt(): Int = TODO("Not yet implemented") + + override fun toLong(): Long = TODO("Not yet implemented") + + override fun toShort(): Short = TODO("Not yet implemented") + }, + ) + }.message.shouldStartWith("unsupported number type:") + } + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 6925b47..65290fc 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,4 +4,5 @@ rootProject.name = "json-schema-validator-root" include(":test-suites") include(":benchmark") -include(":json-schema-validator") \ No newline at end of file +include(":json-schema-validator") +include(":json-schema-validator-objects") \ No newline at end of file