Skip to content

Commit

Permalink
Add module to wrap kotlin objects for validation
Browse files Browse the repository at this point in the history
  • Loading branch information
OptimumCode committed Sep 7, 2024
1 parent 2cb7fdd commit 33722fb
Show file tree
Hide file tree
Showing 5 changed files with 571 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -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 <init> ()V
public final fun getAllowSets ()Z
}

134 changes: 134 additions & 0 deletions json-schema-validator-objects/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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<KotlinTarget>(
macosX64(),
macosArm64(),
iosX64(),
iosArm64(),
iosSimulatorArm64(),
)

val linuxTargets =
listOf<KotlinTarget>(
linuxX64(),
linuxArm64(),
)

val windowsTargets =
listOf<KotlinTarget>(
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<KotlinTarget>) {
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<Detekt>())
}

tasks.named("check").configure {
dependsOn(detektAllTask)
}
}
Original file line number Diff line number Diff line change
@@ -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<String, AbstractElement>
return ObjectWrapper(elementsMap)
}

@JvmInline
private value class ObjectWrapper(
private val map: Map<String, AbstractElement>,
) : ObjectElement {
override val keys: Set<String>
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<Pair<String, AbstractElement>> =
map.asSequence().map { (key, value) -> key to value }.iterator()

override fun toString(): String = map.toString()
}

@JvmInline
private value class ListWrapper(
private val list: List<AbstractElement>,
) : ArrayElement {
override fun iterator(): Iterator<AbstractElement> = 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"
}
Loading

0 comments on commit 33722fb

Please sign in to comment.