commonMain.io.github.optimumcode.json.schema.internal.SchemaLoader.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of json-schema-validator Show documentation
Show all versions of json-schema-validator Show documentation
Multiplatform Kotlin implementation of JSON schema validator
package io.github.optimumcode.json.schema.internal
import com.eygraber.uri.Builder
import com.eygraber.uri.Uri
import io.github.optimumcode.json.pointer.JsonPointer
import io.github.optimumcode.json.pointer.div
import io.github.optimumcode.json.pointer.get
import io.github.optimumcode.json.pointer.relative
import io.github.optimumcode.json.schema.FormatValidator
import io.github.optimumcode.json.schema.JsonSchema
import io.github.optimumcode.json.schema.JsonSchemaLoader
import io.github.optimumcode.json.schema.SchemaOption
import io.github.optimumcode.json.schema.SchemaType
import io.github.optimumcode.json.schema.extension.ExternalAssertionFactory
import io.github.optimumcode.json.schema.findSchemaType
import io.github.optimumcode.json.schema.internal.ReferenceFactory.RefHolder
import io.github.optimumcode.json.schema.internal.ReferenceFactory.RefHolder.Recursive
import io.github.optimumcode.json.schema.internal.ReferenceFactory.RefHolder.Simple
import io.github.optimumcode.json.schema.internal.ReferenceValidator.PointerWithBaseId
import io.github.optimumcode.json.schema.internal.ReferenceValidator.ReferenceLocation
import io.github.optimumcode.json.schema.internal.SchemaLoaderConfig.Options
import io.github.optimumcode.json.schema.internal.SchemaLoaderConfig.Vocabulary
import io.github.optimumcode.json.schema.internal.factories.ExternalAssertionFactoryAdapter
import io.github.optimumcode.json.schema.internal.util.getString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.booleanOrNull
private const val SCHEMA_PROPERTY: String = "\$schema"
internal class SchemaLoader : JsonSchemaLoader {
private val references: MutableMap = linkedMapOf()
private val usedRefs: MutableSet = linkedSetOf()
private val extensionFactories: MutableMap = linkedMapOf()
private val customMetaSchemas: MutableMap> = linkedMapOf()
private val customFormats: MutableMap = linkedMapOf()
private val schemaOptions: MutableMap, Any> = linkedMapOf()
override fun register(
schema: JsonElement,
draft: SchemaType?,
): JsonSchemaLoader =
apply {
loadSchemaData(schema, createParameters(draft))
}
override fun register(
schema: String,
draft: SchemaType?,
): JsonSchemaLoader =
run {
val schemaElement: JsonElement = Json.parseToJsonElement(schema)
register(schemaElement, draft)
}
override fun register(
schema: JsonElement,
remoteUri: Uri,
draft: SchemaType?,
): JsonSchemaLoader =
apply {
loadSchemaData(
schema,
createParameters(draft),
remoteUri,
)
}
override fun withExtensions(
externalFactory: ExternalAssertionFactory,
vararg otherExternalFactories: ExternalAssertionFactory,
): JsonSchemaLoader =
apply {
addExtensionFactory(externalFactory)
for (extFactory in otherExternalFactories) {
addExtensionFactory(extFactory)
}
}
override fun withExtensions(externalFactories: Iterable): JsonSchemaLoader =
apply {
for (extFactory in externalFactories) {
addExtensionFactory(extFactory)
}
}
override fun withCustomFormat(
format: String,
formatValidator: FormatValidator,
): JsonSchemaLoader =
apply {
val key = format.lowercase()
require(customFormats.put(key, formatValidator) == null) {
"format '$key' already registered"
}
}
override fun withCustomFormats(formats: Map): JsonSchemaLoader =
apply {
for ((format, validator) in formats) {
withCustomFormat(format, validator)
}
}
override fun withSchemaOption(
option: SchemaOption,
value: T,
): JsonSchemaLoader =
apply {
schemaOptions[option] = value
}
override fun fromDefinition(
schema: String,
draft: SchemaType?,
): JsonSchema {
val schemaElement: JsonElement = Json.parseToJsonElement(schema)
return fromJsonElement(schemaElement, draft)
}
override fun fromJsonElement(
schemaElement: JsonElement,
draft: SchemaType?,
): JsonSchema {
val assertion: JsonSchemaAssertion =
loadSchemaData(
schemaElement,
createParameters(draft),
)
validateReferences(references, usedRefs)
return createSchema(
LoadResult(
assertion,
references.toMutableMap(),
usedRefs.mapTo(hashSetOf()) { it.refId },
),
)
}
private fun createParameters(draft: SchemaType?): LoadingParameters =
LoadingParameters(
defaultType = draft,
references = references,
usedRefs = usedRefs,
extensionFactories = extensionFactories.values,
customFormats = customFormats,
schemaOptions = schemaOptions,
registerMetaSchema = { uri, type, vocab ->
val prev = customMetaSchemas.put(uri, type to vocab)
require(prev == null) { "duplicated meta-schema with uri '$uri'" }
},
resolveCustomVocabulary = { customMetaSchemas[it]?.second },
resolveCustomMetaSchemaType = { customMetaSchemas[it]?.first },
)
private fun addExtensionFactory(extensionFactory: ExternalAssertionFactory) {
val matchedDrafts = mutableMapOf>()
for (schemaType in SchemaType.entries) {
val match =
schemaType.config.allFactories.find { it.property.equals(extensionFactory.keywordName, ignoreCase = true) }
if (match == null) {
continue
}
matchedDrafts
.getOrPut(
match.property,
::ArrayList,
).add(schemaType)
}
if (matchedDrafts.isNotEmpty()) {
error(
"external factory with keyword '${extensionFactory.keywordName}' " +
"overlaps with ${matchedDrafts.entries.joinToString { (property, drafts) ->
"'$property' keyword in $drafts draft(s)"
}}",
)
}
val duplicate = extensionFactories.keys.find { it.equals(extensionFactory.keywordName, ignoreCase = true) }
check(duplicate == null) { "duplicated extension factory with keyword '$duplicate'" }
extensionFactories[extensionFactory.keywordName] = ExternalAssertionFactoryAdapter(extensionFactory)
}
}
internal object IsolatedLoader {
fun fromDefinition(
schema: String,
draft: SchemaType?,
): JsonSchema {
val schemaElement: JsonElement = Json.parseToJsonElement(schema)
return fromJsonElement(schemaElement, draft)
}
fun fromJsonElement(
schemaElement: JsonElement,
draft: SchemaType?,
): JsonSchema {
val references: MutableMap = linkedMapOf()
val usedRefs: MutableSet = hashSetOf()
val assertion: JsonSchemaAssertion = loadSchemaData(schemaElement, LoadingParameters(draft, references, usedRefs))
validateReferences(references, usedRefs)
return createSchema(LoadResult(assertion, references, usedRefs.mapTo(hashSetOf()) { it.refId }))
}
}
@Suppress("detekt:LongParameterList")
private class LoadingParameters(
val defaultType: SchemaType?,
val references: MutableMap,
val usedRefs: MutableSet,
val extensionFactories: Collection = emptySet(),
val customFormats: Map = emptyMap(),
val schemaOptions: Map, Any> = emptyMap(),
val resolveCustomMetaSchemaType: (Uri) -> SchemaType? = { null },
val resolveCustomVocabulary: (Uri) -> Vocabulary? = { null },
val registerMetaSchema: (Uri, SchemaType, Vocabulary) -> Unit = { _, _, _ -> },
)
private fun loadSchemaData(
schemaDefinition: JsonElement,
parameters: LoadingParameters,
externalUri: Uri? = null,
): JsonSchemaAssertion {
val schema: Uri? = extractSchema(schemaDefinition)?.let(Uri::parse)
val schemaType: SchemaType = resolveSchemaType(schema, parameters.defaultType, parameters.resolveCustomMetaSchemaType)
val baseId: Uri = extractID(schemaDefinition, schemaType.config) ?: externalUri ?: Uri.EMPTY
val schemaVocabulary: Vocabulary? =
schemaType.config.createVocabulary(schemaDefinition)?.also {
parameters.registerMetaSchema(baseId, schemaType, it)
}
val vocabulary: Vocabulary =
schemaVocabulary
?: schema?.let(parameters.resolveCustomVocabulary)
?: schemaType.config.defaultVocabulary
val assertionFactories =
schemaType.config.factories(schemaDefinition, vocabulary, Options(parameters.schemaOptions)).let {
if (parameters.extensionFactories.isEmpty()) {
it
} else {
it + parameters.extensionFactories
}
}
val isolatedReferences: MutableMap = linkedMapOf()
val context =
defaultLoadingContext(
baseId,
schemaType.config,
assertionFactories,
references = isolatedReferences,
customFormats = parameters.customFormats,
).let {
if (externalUri != null && baseId != externalUri) {
// The external URI is added as the first one
// as it should not be used to calculate ids
// inside the schema
it.copy(additionalIDs = setOf(IdWithLocation(externalUri, JsonPointer.ROOT)) + it.additionalIDs)
} else {
it
}
}
val schemaAssertion = loadSchema(schemaDefinition, context)
parameters.references.putAll(isolatedReferences)
parameters.usedRefs.addAll(context.usedRef)
return schemaAssertion
}
private fun validateReferences(
references: Map,
usedRefs: Set,
) {
ReferenceValidator.validateReferences(
references.mapValues { it.value.run { PointerWithBaseId(this.scopeId, schemaPath) } },
usedRefs,
)
}
private fun createSchema(result: LoadResult): JsonSchema {
val dynamicRefs =
result.references
.asSequence()
.filter { it.value.dynamic }
.map { it.key }
.toSet()
// pre-filter references to get rid of unused references
val usedReferencesWithPath: Map =
result.references
.asSequence()
.filter { it.key in result.usedRefs || it.key in dynamicRefs }
.associate { it.key to it.value }
return JsonSchema(result.assertion, DefaultReferenceResolver(usedReferencesWithPath))
}
private class LoadResult(
val assertion: JsonSchemaAssertion,
val references: Map,
val usedRefs: Set,
)
private fun resolveSchemaType(
schema: Uri?,
defaultType: SchemaType?,
resolveCustomMetaSchemaType: (Uri) -> SchemaType?,
): SchemaType {
val schemaType: SchemaType? =
schema?.let {
findSchemaType(it)
?: resolveCustomMetaSchemaType(it)
?: throw IllegalArgumentException("unsupported schema type $it")
}
return schemaType ?: defaultType ?: SchemaType.entries.last()
}
private fun extractSchema(schemaDefinition: JsonElement): String? =
if (schemaDefinition is JsonObject) {
schemaDefinition[SCHEMA_PROPERTY]?.let {
require(it is JsonPrimitive && it.isString) { "$SCHEMA_PROPERTY must be a string" }
it.content
}
} else {
null
}
private fun loadDefinitions(
schemaDefinition: JsonElement,
context: DefaultLoadingContext,
) {
if (schemaDefinition !is JsonObject) {
return
}
val (definitionsProperty, definitionsElement: JsonElement?) =
context.config.keywordResolver.run {
resolve(KeyWord.DEFINITIONS)
?.let {
it to schemaDefinition[it]
}?.takeIf { it.second != null }
?: resolve(KeyWord.COMPATIBILITY_DEFINITIONS)
?.let {
it to schemaDefinition[it]
}?.takeIf { it.second != null }
} ?: return
if (definitionsElement == null) {
return
}
require(definitionsElement is JsonObject) { "$definitionsProperty must be an object" }
val definitionsContext = context.at(definitionsProperty)
for ((name, element) in definitionsElement) {
loadSchema(element, definitionsContext.at(name))
}
}
private fun extractID(
schemaDefinition: JsonElement,
config: SchemaLoaderConfig,
): Uri? =
when (schemaDefinition) {
is JsonObject -> {
val idProperty = config.keywordResolver.resolve(KeyWord.ID)
idProperty
?.let(schemaDefinition::getString)
?.let {
requireNotNull(Uri.parseOrNull(it)) { "invalid $idProperty: $it" }
}
}
else -> null
}
private fun loadSchema(
schemaDefinition: JsonElement,
context: DefaultLoadingContext,
): JsonSchemaAssertion {
require(context.isJsonSchema(schemaDefinition)) {
"schema must be either a valid JSON object or boolean"
}
val additionalId: Uri? = extractID(schemaDefinition, context.config)
val contextWithAdditionalID = additionalId?.let(context::addId) ?: context
val referenceFactory = context.config.referenceFactory
return when (schemaDefinition) {
is JsonPrimitive ->
if (schemaDefinition.boolean) {
TrueSchemaAssertion
} else {
FalseSchemaAssertion(path = context.schemaPath)
}
is JsonObject -> {
// If a new ID scope is introduced we must check whether we still should try to recursively resolve refs
if (additionalId != null) {
contextWithAdditionalID.updateRecursiveResolution(schemaDefinition)
}
val refLoadingContext = if (referenceFactory.resolveRefPriorId) contextWithAdditionalID else context
val extractedRef: RefHolder? = referenceFactory.extractRef(schemaDefinition, refLoadingContext)
val refAssertion: JsonSchemaAssertion? =
if (extractedRef != null) {
loadRefAssertion(extractedRef, refLoadingContext)
} else {
null
}
if (refAssertion != null && !referenceFactory.allowOverriding) {
JsonSchemaRoot(
contextWithAdditionalID.additionalIDs.last().id,
contextWithAdditionalID.schemaPath,
listOf(refAssertion),
contextWithAdditionalID.recursiveResolution,
)
} else {
loadJsonSchemaRoot(contextWithAdditionalID, schemaDefinition, refAssertion)
}
}
// should never happen
else -> throw IllegalArgumentException("schema must be either a valid JSON object or boolean")
}.apply {
loadDefinitions(schemaDefinition, contextWithAdditionalID)
context.register(additionalId, this)
registerWithAnchor(
context.config.keywordResolver.resolve(KeyWord.ANCHOR),
schemaDefinition,
contextWithAdditionalID,
)
registerWithAnchor(
context.config.keywordResolver.resolve(KeyWord.DYNAMIC_ANCHOR),
schemaDefinition,
contextWithAdditionalID,
dynamic = true,
)
}
}
private fun JsonSchemaAssertion.registerWithAnchor(
anchorProperty: String?,
schemaDefinition: JsonElement,
contextWithAdditionalID: DefaultLoadingContext,
dynamic: Boolean = false,
) {
if (anchorProperty != null && schemaDefinition is JsonObject) {
schemaDefinition.getString(anchorProperty)?.also {
contextWithAdditionalID.registerByAnchor(it, this, dynamic)
}
}
}
private fun loadJsonSchemaRoot(
context: DefaultLoadingContext,
schemaDefinition: JsonElement,
refAssertion: JsonSchemaAssertion?,
): JsonSchemaRoot {
val assertions =
context.assertionFactories
.filter { it.isApplicable(schemaDefinition) }
.map {
it.create(
schemaDefinition,
// we register id to be used for future schema registration
context,
)
}
val result =
buildList(assertions.size + (refAssertion?.let { 1 } ?: 0)) {
refAssertion?.also(this::add)
addAll(assertions)
}
return JsonSchemaRoot(
context.additionalIDs.last().id,
context.schemaPath,
result,
context.recursiveResolution,
)
}
private fun loadRefAssertion(
refHolder: RefHolder,
context: DefaultLoadingContext,
): JsonSchemaAssertion =
when (refHolder) {
is Simple -> RefSchemaAssertion(context.schemaPath / refHolder.property, refHolder.refId)
is Recursive ->
RecursiveRefSchemaAssertion(
context.schemaPath / refHolder.property,
refHolder.refId,
)
}
/**
* Used to identify the [location] where this [id] was defined
*/
internal data class IdWithLocation(
val id: Uri,
val location: JsonPointer,
)
/**
* Used to map JSON schema [assertion] with its [schemaPath]
*/
internal data class AssertionWithPath(
val assertion: JsonSchemaAssertion,
val schemaPath: JsonPointer,
val dynamic: Boolean,
val scopeId: Uri,
)
private data class DefaultLoadingContext(
override val baseId: Uri,
var recursiveResolution: Boolean = false,
override val schemaPath: JsonPointer = JsonPointer.ROOT,
val additionalIDs: Set = linkedSetOf(IdWithLocation(baseId, schemaPath)),
val references: MutableMap = linkedMapOf(),
val usedRef: MutableSet = linkedSetOf(),
val config: SchemaLoaderConfig,
val assertionFactories: List,
override val customFormatValidators: Map,
) : LoadingContext,
SchemaLoaderContext {
override fun at(property: String): DefaultLoadingContext = copy(schemaPath = schemaPath / property)
override fun at(index: Int): DefaultLoadingContext = copy(schemaPath = schemaPath[index])
override fun schemaFrom(element: JsonElement): JsonSchemaAssertion = loadSchema(element, this)
override fun isJsonSchema(element: JsonElement): Boolean =
(
element is JsonObject ||
(element is JsonPrimitive && element.booleanOrNull != null)
)
fun register(
id: Uri?,
assertion: JsonSchemaAssertion,
dynamic: Boolean = false,
) {
if (id != null) {
registerById(id, assertion, dynamic)
}
for ((baseId, location) in additionalIDs) {
val relativePointer = location.relative(schemaPath)
val referenceId: RefId =
baseId
.buildUpon()
.encodedFragment(relativePointer.toString())
.buildRefId()
if (referenceId.uri == id) {
// this happens when the root schema has ID,
// and we register it using Empty pointer
continue
}
register(referenceId, assertion, dynamic = false)
}
}
/**
* [anchor] is a plain text that will be transformed into a URI fragment
* It must match [ANCHOR_REGEX] otherwise [IllegalArgumentException] will be thrown
*/
fun registerByAnchor(
anchor: String,
assertion: JsonSchemaAssertion,
dynamic: Boolean,
) {
require(ANCHOR_REGEX.matches(anchor)) { "$anchor must match the format ${ANCHOR_REGEX.pattern}" }
val refId =
additionalIDs
.last()
.id
.buildUpon()
.fragment(anchor)
.buildRefId()
register(refId, assertion, dynamic)
}
fun addId(additionalId: Uri): DefaultLoadingContext =
when {
additionalId.isAbsolute -> copy(additionalIDs = additionalIDs + IdWithLocation(additionalId, schemaPath))
additionalId.isRelative && !additionalId.path.isNullOrBlank() ->
copy(
additionalIDs =
additionalIDs.run {
this +
IdWithLocation(
additionalIDs.resolvePath(additionalId.path),
schemaPath,
)
},
)
else -> this
}
override fun ref(refId: String): RefId {
// library parsed fragment as empty if # is in the URI
// But when we build URI for definition we use [Uri.Builder]
// That builder does not set the fragment if it is empty
// Because of that inconsistency we use builder here as well
val refUri = Uri.parse(refId).buildUpon().build()
return when {
refUri.isAbsolute -> refUri.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)
.run {
if (refUri.fragment.isNullOrBlank()) {
this
} else {
buildUpon().encodedFragment(refUri.fragment).build()
}
}.buildRefId()
refUri.fragment != null ->
additionalIDs
.last()
.id
.buildUpon()
.encodedFragment(refUri.fragment)
.buildRefId()
else -> throw IllegalArgumentException("invalid reference '$refId'")
}.also { usedRef += ReferenceLocation(schemaPath, it) }
}
fun updateRecursiveResolution(schemaDefinition: JsonObject) {
recursiveResolution = config.referenceFactory.recursiveResolutionEnabled(schemaDefinition)
}
private fun registerById(
id: Uri,
assertion: JsonSchemaAssertion,
dynamic: Boolean,
) {
when {
id.isAbsolute -> register(id.buildRefId(), assertion, dynamic) // register JSON schema by absolute URI
id.encodedPath.let { it != null && it.startsWith('/') } ->
register(
additionalIDs.resolvePath(id.encodedPath).buildRefId(),
assertion,
dynamic,
)
id.isRelative ->
when {
// For root schema we should not apply any transformations to ID
schemaPath === JsonPointer.ROOT && !id.path.isNullOrBlank() ->
// Empty URI is used to normalize the path
// Instead of 'path/segment' it will result '/path/segment
register(Uri.EMPTY.appendPathToParent(id.path!!).buildRefId(), assertion, dynamic)
!id.path.isNullOrBlank() ->
register(
// register JSON schema by related path
additionalIDs.resolvePath(id.path).buildRefId(),
assertion,
dynamic,
)
!id.fragment.isNullOrBlank() ->
register(
// register JSON schema by fragment
additionalIDs
.last()
.id
.buildUpon()
.encodedFragment(id.fragment)
.buildRefId(),
assertion,
dynamic,
)
}
}
}
private fun register(
referenceId: RefId,
assertion: JsonSchemaAssertion,
dynamic: Boolean,
) {
references.put(referenceId, AssertionWithPath(assertion, schemaPath, dynamic, additionalIDs.last().id))?.apply {
throw IllegalStateException("duplicated definition $referenceId")
}
}
}
private fun Set.resolvePath(path: String?): Uri =
last().id.appendPathToParent(
requireNotNull(path) {
"path is null"
},
)
private fun Uri.appendPathToParent(path: String): Uri {
if (path.startsWith('/')) {
return buildUpon()
.encodedPath(path)
.build()
}
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 {
if (pathSegments.isEmpty()) return@apply
pathSegments
.asSequence()
.take(pathSegments.size - 1) // drop last path segment
.forEach(this::appendPath)
}
}.appendEncodedPath(path)
.build()
}
private val ANCHOR_REGEX: Regex = "^[A-Za-z][A-Za-z0-9-_:.]*$".toRegex()
private fun Uri.buildRefId(): RefId = RefId(this)
private fun Builder.buildRefId(): RefId = build().buildRefId()
private fun defaultLoadingContext(
baseId: Uri,
config: SchemaLoaderConfig,
assertionFactories: List,
references: MutableMap,
customFormats: Map,
): DefaultLoadingContext =
DefaultLoadingContext(
baseId,
references = references,
config = config,
assertionFactories = assertionFactories,
customFormatValidators = customFormats,
)