Skip to content

Add support for Draft 2019-09 #30

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Dec 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cd3bdd6
Introduce loader configuration. Move factories, ref creation and keyw…
OptimumCode Aug 10, 2023
850a76d
Add anchor support
OptimumCode Aug 10, 2023
de96c47
Set of supported assertion factories is retruned based on schema defi…
OptimumCode Aug 10, 2023
3b0ff99
Disable Gradle daemon
OptimumCode Aug 10, 2023
a9b6f97
Add config for draft 2019-09
OptimumCode Aug 10, 2023
881bd36
Add test suites for draft2019-09
OptimumCode Aug 23, 2023
d8058f0
Add default type if schema missing prop
OptimumCode Aug 23, 2023
e486a64
Add dependentSchemas and dependentRequired keywords
OptimumCode Dec 26, 2023
ab71f3e
Add minContains and maxContains keywords
OptimumCode Dec 26, 2023
3d7c9b3
Remove factory group because either way we use list and order is guar…
OptimumCode Dec 26, 2023
f56d2f8
Apply other keywords if ref is used
OptimumCode Dec 26, 2023
3d4c4a1
Customize canonical id resolution for refs loading
OptimumCode Dec 26, 2023
d54de6d
Add unevaluated items assertion and annotation from sub schemas
OptimumCode Dec 27, 2023
f98d7c7
Add annotation aggregation
OptimumCode Dec 28, 2023
5e951b4
Add unevaluatedProperties assertion
OptimumCode Dec 28, 2023
d5b1333
Add unit test for unevaluated properties
OptimumCode Dec 28, 2023
4658186
Disable vocabulary tests fro draft2019_09
OptimumCode Dec 29, 2023
f8157ae
Implement recursive refrence resolution
OptimumCode Dec 29, 2023
44d5172
Update api file
OptimumCode Dec 29, 2023
317b034
Correct code according to detekt alerts
OptimumCode Dec 29, 2023
db39d92
Use property instead of const name for annotations
OptimumCode Dec 29, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions api/json-schema-validator.api
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,33 @@ public final class io/github/optimumcode/json/schema/ErrorCollector$Companion {
public final class io/github/optimumcode/json/schema/JsonSchema {
public static final field Companion Lio/github/optimumcode/json/schema/JsonSchema$Companion;
public static final fun fromDefinition (Ljava/lang/String;)Lio/github/optimumcode/json/schema/JsonSchema;
public static final fun fromDefinition (Ljava/lang/String;Lio/github/optimumcode/json/schema/SchemaType;)Lio/github/optimumcode/json/schema/JsonSchema;
public static final fun fromJsonElement (Lkotlinx/serialization/json/JsonElement;)Lio/github/optimumcode/json/schema/JsonSchema;
public static final fun fromJsonElement (Lkotlinx/serialization/json/JsonElement;Lio/github/optimumcode/json/schema/SchemaType;)Lio/github/optimumcode/json/schema/JsonSchema;
public final fun validate (Lkotlinx/serialization/json/JsonElement;Lio/github/optimumcode/json/schema/ErrorCollector;)Z
}

public final class io/github/optimumcode/json/schema/JsonSchema$Companion {
public final fun fromDefinition (Ljava/lang/String;)Lio/github/optimumcode/json/schema/JsonSchema;
public final fun fromDefinition (Ljava/lang/String;Lio/github/optimumcode/json/schema/SchemaType;)Lio/github/optimumcode/json/schema/JsonSchema;
public static synthetic fun fromDefinition$default (Lio/github/optimumcode/json/schema/JsonSchema$Companion;Ljava/lang/String;Lio/github/optimumcode/json/schema/SchemaType;ILjava/lang/Object;)Lio/github/optimumcode/json/schema/JsonSchema;
public final fun fromJsonElement (Lkotlinx/serialization/json/JsonElement;)Lio/github/optimumcode/json/schema/JsonSchema;
public final fun fromJsonElement (Lkotlinx/serialization/json/JsonElement;Lio/github/optimumcode/json/schema/SchemaType;)Lio/github/optimumcode/json/schema/JsonSchema;
public static synthetic fun fromJsonElement$default (Lio/github/optimumcode/json/schema/JsonSchema$Companion;Lkotlinx/serialization/json/JsonElement;Lio/github/optimumcode/json/schema/SchemaType;ILjava/lang/Object;)Lio/github/optimumcode/json/schema/JsonSchema;
}

public final class io/github/optimumcode/json/schema/JsonSchemaStream {
public static final fun fromStream (Lio/github/optimumcode/json/schema/JsonSchema$Companion;Ljava/io/InputStream;)Lio/github/optimumcode/json/schema/JsonSchema;
}

public final class io/github/optimumcode/json/schema/SchemaType : java/lang/Enum {
public static final field DRAFT_2019_09 Lio/github/optimumcode/json/schema/SchemaType;
public static final field DRAFT_7 Lio/github/optimumcode/json/schema/SchemaType;
public static final fun find (Ljava/lang/String;)Lio/github/optimumcode/json/schema/SchemaType;
public static fun valueOf (Ljava/lang/String;)Lio/github/optimumcode/json/schema/SchemaType;
public static fun values ()[Lio/github/optimumcode/json/schema/SchemaType;
}

public final class io/github/optimumcode/json/schema/ValidationError {
public fun <init> (Lio/github/optimumcode/json/pointer/JsonPointer;Lio/github/optimumcode/json/pointer/JsonPointer;Ljava/lang/String;Ljava/util/Map;Lio/github/optimumcode/json/pointer/JsonPointer;)V
public synthetic fun <init> (Lio/github/optimumcode/json/pointer/JsonPointer;Lio/github/optimumcode/json/pointer/JsonPointer;Ljava/lang/String;Ljava/util/Map;Lio/github/optimumcode/json/pointer/JsonPointer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
Expand Down
8 changes: 4 additions & 4 deletions config/detekt/detekt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ complexity:
ignoreOverloaded: false
CyclomaticComplexMethod:
active: true
threshold: 15
threshold: 20
ignoreSingleWhenExpression: false
ignoreSimpleWhenEntries: false
ignoreNestingFunctions: false
Expand Down Expand Up @@ -156,12 +156,12 @@ complexity:
active: true
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**']
thresholdInFiles: 11
thresholdInClasses: 11
thresholdInInterfaces: 11
thresholdInClasses: 15
thresholdInInterfaces: 15
thresholdInObjects: 11
thresholdInEnums: 11
ignoreDeprecated: false
ignorePrivate: false
ignorePrivate: true
ignoreOverridden: false

coroutines:
Expand Down
1 change: 1 addition & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ kotlin.code.style=official
kotlin.js.compiler=ir
org.gradle.jvmargs=-Xmx1G
org.gradle.java.installations.auto-download=false
org.gradle.daemon=false

version=0.0.3-SNAPSHOT
group=io.github.optimumcode
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import io.github.optimumcode.json.schema.internal.RefId
import io.github.optimumcode.json.schema.internal.SchemaLoader
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlin.jvm.JvmOverloads
import kotlin.jvm.JvmStatic

/**
Expand All @@ -33,19 +34,27 @@ public class JsonSchema internal constructor(
public companion object {
/**
* Loads JSON schema from the [schema] definition
* @param defaultType expected schema draft to use when loading schema.
* If `null` draft will be defined by schema definition
* or the latest supported draft will be used
*/
@JvmStatic
public fun fromDefinition(schema: String): JsonSchema {
@JvmOverloads
public fun fromDefinition(schema: String, defaultType: SchemaType? = null): JsonSchema {
val schemaElement: JsonElement = Json.parseToJsonElement(schema)
return fromJsonElement(schemaElement)
return fromJsonElement(schemaElement, defaultType)
}

/**
* Loads JSON schema from the [schemaElement] JSON element
* @param defaultType expected schema draft to use when loading schema.
* If `null` draft will be defined by schema definition
* or the latest supported draft will be used
*/
@JvmStatic
public fun fromJsonElement(schemaElement: JsonElement): JsonSchema {
return SchemaLoader().load(schemaElement)
@JvmOverloads
public fun fromJsonElement(schemaElement: JsonElement, defaultType: SchemaType? = null): JsonSchema {
return SchemaLoader().load(schemaElement, defaultType)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
package io.github.optimumcode.json.schema.internal
package io.github.optimumcode.json.schema

import com.eygraber.uri.Uri
import io.github.optimumcode.json.schema.internal.SchemaLoaderConfig
import io.github.optimumcode.json.schema.internal.config.Draft201909SchemaLoaderConfig
import io.github.optimumcode.json.schema.internal.config.Draft7SchemaLoaderConfig
import kotlin.jvm.JvmStatic

internal enum class SchemaType(
public enum class SchemaType(
private val schemaId: Uri,
internal val config: SchemaLoaderConfig,
) {
DRAFT_7(Uri.parse("http://json-schema.org/draft-07/schema")),
DRAFT_7(Uri.parse("http://json-schema.org/draft-07/schema"), Draft7SchemaLoaderConfig),
DRAFT_2019_09(Uri.parse("https://json-schema.org/draft/2019-09/schema"), Draft201909SchemaLoaderConfig),
;

companion object {
internal companion object {
private const val HTTP_SCHEMA: String = "http"
private const val HTTPS_SCHEMA: String = "https"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,58 @@ internal interface AssertionContext {
val objectPath: JsonPointer
fun <T : Any> annotate(key: AnnotationKey<T>, value: T)
fun <T : Any> annotated(key: AnnotationKey<T>): T?
fun <T : Any> aggregatedAnnotation(key: AnnotationKey<T>): T?
fun at(index: Int): AssertionContext
fun at(property: String): AssertionContext
fun resolveRef(refId: RefId): Pair<JsonPointer, JsonSchemaAssertion>

/**
* Discards collected annotations
*/
fun resetAnnotations()

/**
* Applies collected annotations
*/
fun applyAnnotations()

/**
* Propagates aggregated annotations to parent context if it has one.
* Otherwise, does nothing
*/
fun propagateToParent()

/**
* Creates a child context with a new annotation scope.
* Current context will get the collected annotations only
* if [propagateToParent] method is called on the child context
*/
fun childContext(): AssertionContext

/**
* Sets the recursive root to the [schema] if no recursive root was set before
*/
fun setRecursiveRootIfAbsent(schema: JsonSchemaAssertion)

/**
* Resets recursive root
*/
fun resetRecursiveRoot()

/**
* Returns recursive root for current state of the validation
*/
fun getRecursiveRoot(): JsonSchemaAssertion?
}

internal fun interface Aggregator<T : Any> {
fun aggregate(a: T, b: T): T?
}

internal class AnnotationKey<T : Any> private constructor(
private val name: String,
internal val type: KClass<T>,
internal val aggregator: Aggregator<T>,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
Expand All @@ -43,19 +85,37 @@ internal class AnnotationKey<T : Any> private constructor(
override fun toString(): String = "$name(${type.simpleName})"

companion object {
internal val NOT_AGGREGATABLE: (Any, Any) -> Nothing? = { _, _ -> null }

private fun <T : Any> notAggragatable(): (T, T) -> T? = NOT_AGGREGATABLE

@JvmStatic
inline fun <reified T : Any> create(name: String): AnnotationKey<T> = create(name, T::class)

@JvmStatic
fun <T : Any> create(name: String, type: KClass<T>): AnnotationKey<T> = AnnotationKey(name, type)
inline fun <reified T : Any> createAggregatable(name: String, noinline aggregator: (T, T) -> T): AnnotationKey<T> =
createAggregatable(name, T::class, aggregator)

@JvmStatic
fun <T : Any> create(name: String, type: KClass<T>): AnnotationKey<T> = AnnotationKey(name, type, notAggragatable())

@JvmStatic
fun <T : Any> createAggregatable(
name: String,
type: KClass<T>,
aggregator: (T, T) -> T,
): AnnotationKey<T> = AnnotationKey(name, type, aggregator)
}
}

internal data class DefaultAssertionContext(
override val objectPath: JsonPointer,
private val references: Map<RefId, AssertionWithPath>,
private val parent: DefaultAssertionContext? = null,
private var recursiveRoot: JsonSchemaAssertion? = null,
) : AssertionContext {
private lateinit var _annotations: MutableMap<AnnotationKey<*>, Any>
private lateinit var _aggregatedAnnotations: MutableMap<AnnotationKey<*>, Any>
override fun <T : Any> annotate(key: AnnotationKey<T>, value: T) {
annotations()[key] = value
}
Expand All @@ -67,6 +127,24 @@ internal data class DefaultAssertionContext(
return _annotations[key]?.let { key.type.cast(it) }
}

override fun <T : Any> aggregatedAnnotation(key: AnnotationKey<T>): T? {
if (!::_aggregatedAnnotations.isInitialized && !::_annotations.isInitialized) {
return null
}
val currentLevelAnnotation: T? = annotated(key)
if (!::_aggregatedAnnotations.isInitialized) {
return currentLevelAnnotation
}
return _aggregatedAnnotations[key]?.let {
val aggregatedAnnotation: T = key.type.cast(it)
if (currentLevelAnnotation == null) {
aggregatedAnnotation
} else {
key.aggregator.aggregate(currentLevelAnnotation, aggregatedAnnotation)
}
} ?: currentLevelAnnotation
}

override fun at(index: Int): AssertionContext = copy(objectPath = objectPath[index])

override fun at(property: String): AssertionContext {
Expand All @@ -79,7 +157,70 @@ internal data class DefaultAssertionContext(
}

override fun resetAnnotations() {
annotations().clear()
if (::_annotations.isInitialized && _annotations.isNotEmpty()) {
_annotations.clear()
}
}

override fun applyAnnotations() {
if (::_annotations.isInitialized && _annotations.isNotEmpty()) {
aggregateAnnotations(_annotations) { aggregatedAnnotations() }
_annotations.clear()
}
}

override fun propagateToParent() {
if (parent == null) {
return
}
if (!::_aggregatedAnnotations.isInitialized) {
return
}
aggregateAnnotations(_aggregatedAnnotations) { parent.aggregatedAnnotations() }
}

override fun childContext(): AssertionContext {
return copy(parent = this)
}

override fun setRecursiveRootIfAbsent(schema: JsonSchemaAssertion) {
if (this.recursiveRoot != null) {
return
}
this.recursiveRoot = schema
}

override fun resetRecursiveRoot() {
this.recursiveRoot = null
}

override fun getRecursiveRoot(): JsonSchemaAssertion? {
return recursiveRoot
}

private inline fun aggregateAnnotations(
source: MutableMap<AnnotationKey<*>, Any>,
destination: () -> MutableMap<AnnotationKey<*>, Any>,
) {
source.forEach { (key, value) ->
if (key.aggregator === AnnotationKey.NOT_AGGREGATABLE) {
return@forEach
}
val aggregatedAnnotations = destination()
val oldValue: Any? = aggregatedAnnotations[key]
if (oldValue != null) {
// Probably there is a mistake in the architecture
// Need to think on how to change that to avoid unchecked cast
@Suppress("UNCHECKED_CAST")
val aggregator: Aggregator<Any> = key.aggregator as Aggregator<Any>
val aggregated = aggregator.aggregate(key.type.cast(oldValue), key.type.cast(value))
if (aggregated != null) {
aggregatedAnnotations[key] = aggregated
}
} else {
aggregatedAnnotations[key] = value
}
}
}

private fun annotations(): MutableMap<AnnotationKey<*>, Any> {
Expand All @@ -88,4 +229,11 @@ internal data class DefaultAssertionContext(
}
return _annotations
}

private fun aggregatedAnnotations(): MutableMap<AnnotationKey<*>, Any> {
if (!::_aggregatedAnnotations.isInitialized) {
_aggregatedAnnotations = hashMapOf()
}
return _aggregatedAnnotations
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,28 @@ package io.github.optimumcode.json.schema.internal
import io.github.optimumcode.json.schema.ErrorCollector
import kotlinx.serialization.json.JsonElement

internal class AssertionsCollection(
internal class JsonSchemaRoot(
private val assertions: Collection<JsonSchemaAssertion>,
private val canBeReferencedRecursively: Boolean,
) : JsonSchemaAssertion {

override fun validate(element: JsonElement, context: AssertionContext, errorCollector: ErrorCollector): Boolean {
if (canBeReferencedRecursively) {
context.setRecursiveRootIfAbsent(this)
} else {
context.resetRecursiveRoot()
}
var result = true
assertions.forEach {
val valid = it.validate(element, context, errorCollector)
result = result and valid
}
context.resetAnnotations()
// According to spec the annotations should not be applied if element does not match the schema
if (result) {
context.applyAnnotations()
} else {
context.resetAnnotations()
}
return result
}
}
Loading