All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.io.kotest.assertions.json.schema.matchSchema.kt Maven / Gradle / Ivy

package io.kotest.assertions.json.schema

import io.kotest.assertions.json.ContainsSpec
import io.kotest.assertions.json.JsonNode
import io.kotest.assertions.json.toJsonTree
import io.kotest.common.ExperimentalKotest
import io.kotest.matchers.Matcher
import io.kotest.matchers.MatcherResult
import io.kotest.matchers.and
import io.kotest.matchers.should
import io.kotest.matchers.shouldNot
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement

@ExperimentalKotest
infix fun String?.shouldMatchSchema(schema: JsonSchema) =
   this should parseToJson.and(matchSchema(schema).contramap { it?.let(Json::parseToJsonElement) })

@ExperimentalKotest
infix fun String?.shouldNotMatchSchema(schema: JsonSchema) =
   this shouldNot parseToJson.and(matchSchema(schema).contramap { it?.let(Json::parseToJsonElement) })

@ExperimentalKotest
infix fun JsonElement.shouldMatchSchema(schema: JsonSchema) = this should matchSchema(schema)

@ExperimentalKotest
infix fun JsonElement.shouldNotMatchSchema(schema: JsonSchema) = this shouldNot matchSchema(schema)

val parseToJson = object : Matcher {
   override fun test(value: String?): MatcherResult {
      if (value == null) return MatcherResult(
         false,
         { "expected null to match schema: " },
         { "expected not to match schema, but null matched JsonNull schema" }
      )

      val parsed = runCatching {
         Json.parseToJsonElement(value)
      }

      return MatcherResult(
         parsed.isSuccess,
         { "Failed to parse actual as JSON: ${parsed.exceptionOrNull()?.message}" },
         { "Failed to parse actual as JSON: ${parsed.exceptionOrNull()?.message}" },
      )
   }
}

@ExperimentalKotest
fun matchSchema(schema: JsonSchema) = object : Matcher {
   override fun test(value: JsonElement?): MatcherResult {
      if (value == null) return MatcherResult(
         false,
         { "expected null to match schema: " },
         { "expected not to match schema, but null matched JsonNull schema" }
      )

      val tree = toJsonTree(value)
      val violations = validate("$", tree.root, schema.root)

      return MatcherResult(
         violations.isEmpty(),
         { violations.joinToString(separator = "\n") { "${it.path} => ${it.message}" } },
         { "Expected some violation against JSON schema, but everything matched" }
      )
   }
}

@ExperimentalKotest
private fun validate(
   currentPath: String,
   tree: JsonNode,
   expected: JsonSchemaElement,
): List {
   fun propertyViolation(propertyName: String, message: String) =
      listOf(SchemaViolation("$currentPath.$propertyName", message))

   fun violation(message: String) =
      listOf(SchemaViolation(currentPath, message))

   fun violationIf(conditionResult: Boolean, message: String) = if (conditionResult) violation(message) else emptyList()

   fun  violation(matcher: Matcher?, value: T) = matcher?.let {
      val matcherResult = it.test(value)
      violationIf(!matcherResult.passed(), matcherResult.failureMessage())
   } ?: emptyList()

   fun ContainsSpec.violation(tree: JsonNode.ArrayNode): List {
      val schemaViolations = tree.elements.mapIndexed { i, node ->
         validate("\t$currentPath[$i]", node, schema)
      }
      return when (val foundElements = schemaViolations.count { it.isEmpty() }) {
         0 -> violation("Expected some item to match contains-specification:") + schemaViolations.flatten()
         !in minContains..maxContains -> violation("Expected items of type ${schema.typeName()} between $minContains and $maxContains, but found $foundElements")
         else -> emptyList()
      }

   }

   fun JsonSchemaElement.violation(tree: JsonNode.ArrayNode): List =
      tree.elements.flatMapIndexed { i, node ->
         validate("$currentPath[$i]", node, this)
      }

   return when (tree) {
      is JsonNode.ArrayNode -> {
         if (expected is JsonSchema.JsonArray) {
            val sizeViolation = violationIf(
               tree.elements.size < expected.minItems || tree.elements.size > expected.maxItems,
               "Expected items between ${expected.minItems} and ${expected.maxItems}, but was ${tree.elements.size}"
            )
            val matcherViolation = violation(expected.matcher, tree.elements.asSequence())
            val containsViolation = expected.contains?.violation(tree) ?: emptyList()
            val elementTypeViolation = expected.elementType?.violation(tree) ?: emptyList()
            matcherViolation + sizeViolation + containsViolation + elementTypeViolation
         } else violation("Expected ${expected.typeName()}, but was array")
      }
      is JsonNode.ObjectNode -> {
         if (expected is JsonSchema.JsonObject) {
            val extraKeyViolations =
               if (!expected.additionalProperties)
                  tree.elements.keys
                     .filterNot { it in expected.properties.keys }
                     .flatMap {
                        propertyViolation(it, "Key undefined in schema, and schema is set to disallow extra keys")
                     }
               else emptyList()

            extraKeyViolations + expected.properties.flatMap { (propertyName, schema) ->
               val actual = tree.elements[propertyName]

               if (actual == null) {
                  if (expected.requiredProperties.contains(propertyName)) {
                     propertyViolation(propertyName, "Expected ${schema.typeName()}, but was undefined")
                  } else {
                     emptyList()
                  }
               } else validate("$currentPath.$propertyName", actual, schema)
            }
         } else violation("Expected ${expected.typeName()}, but was object")
      }

      is JsonNode.NullNode -> TODO("Check how Json schema handles null")

      is JsonNode.NumberNode ->
         when (expected) {
            is JsonSchema.JsonInteger -> {
               if (tree.content.contains(".")) violation("Expected integer, but was number")
               else violation(expected.matcher, tree.content.toLong())
            }
            is JsonSchema.JsonDecimal -> {
               violation(expected.matcher, tree.content.toDouble())
            }
            else -> violation("Expected ${expected.typeName()}, but was ${tree.type()}")
         }

      is JsonNode.StringNode ->
         if (expected is JsonSchema.JsonString) {
            violation(expected.matcher, tree.value)
         } else violation("Expected ${expected.typeName()}, but was ${tree.type()}")
      is JsonNode.BooleanNode ->
         if (!isCompatible(tree, expected))
            violation("Expected ${expected.typeName()}, but was ${tree.type()}")
         else emptyList()
   }
}

private class SchemaViolation(
   val path: String,
   message: String,
   cause: Throwable? = null
) : RuntimeException(message, cause)

private fun isCompatible(actual: JsonNode, schema: JsonSchemaElement) =
   (actual is JsonNode.BooleanNode && schema is JsonSchema.JsonBoolean) ||
      (actual is JsonNode.StringNode && schema is JsonSchema.JsonString) ||
      (actual is JsonNode.NumberNode && schema is JsonSchema.JsonNumber)





© 2015 - 2025 Weber Informatics LLC | Privacy Policy