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

org.http4k.contract.jsonschema.v3.AutoJsonToJsonSchema.kt Maven / Gradle / Ivy

package org.http4k.contract.jsonschema.v3

import org.http4k.contract.jsonschema.IllegalSchemaException
import org.http4k.contract.jsonschema.JsonSchema
import org.http4k.contract.jsonschema.JsonSchemaCreator
import org.http4k.contract.jsonschema.v3.SchemaModelNamer.Companion.Simple
import org.http4k.format.AutoMarshallingJson
import org.http4k.format.JsonType
import org.http4k.lens.ParamMeta
import org.http4k.lens.ParamMeta.ArrayParam
import org.http4k.lens.ParamMeta.BooleanParam
import org.http4k.lens.ParamMeta.IntegerParam
import org.http4k.lens.ParamMeta.NullParam
import org.http4k.lens.ParamMeta.NumberParam
import org.http4k.lens.ParamMeta.ObjectParam
import org.http4k.lens.ParamMeta.StringParam
import org.http4k.unquoted

class AutoJsonToJsonSchema(
    private val json: AutoMarshallingJson,
    private val fieldRetrieval: FieldRetrieval = FieldRetrieval.compose(
        SimpleLookup(
            metadataRetrievalStrategy = PrimitivesFieldMetadataRetrievalStrategy
        )
    ),
    private val modelNamer: SchemaModelNamer = Simple,
    private val refLocationPrefix: String = "components/schemas",
    private val metadataRetrieval: MetadataRetrieval = MetadataRetrieval.compose(SimpleMetadataLookup(emptyMap()))
) : JsonSchemaCreator {

    override fun toSchema(obj: Any, overrideDefinitionId: String?, refModelNamePrefix: String?): JsonSchema {
        val schema =
            json.asJsonObject(obj).toSchema(obj, overrideDefinitionId, true, refModelNamePrefix.orEmpty(), metadataRetrieval(obj))
        return JsonSchema(
            json.asJsonObject(schema),
            schema.definitions.map { it.name() to json.asJsonObject(it) }.distinctBy { it.first }.toSet()
        )
    }

    private fun NODE.toSchema(
        value: Any,
        objName: String?,
        topLevel: Boolean,
        refModelNamePrefix: String,
        metadata: FieldMetadata?
    ) =
        when (val param = json.typeOf(this).toParam()) {
            is ArrayParam -> toArraySchema("", value, false, null, refModelNamePrefix)
            ObjectParam -> toObjectOrMapSchema(objName, value, false, topLevel, metadata, refModelNamePrefix)
            else -> value.javaClass.enumConstants?.let {
                toEnumSchema("", it[0], json.typeOf(this).toParam(), it, false, null, refModelNamePrefix)
            } ?: toSchema("", param, false, metadata)
        }

    private fun NODE.toSchema(name: String, paramMeta: ParamMeta, isNullable: Boolean, metadata: FieldMetadata?) =
        SchemaNode.Primitive(name, paramMeta, isNullable, this, metadata)

    private fun NODE.toArraySchema(
        name: String,
        obj: Any,
        isNullable: Boolean,
        metadata: FieldMetadata?,
        refModelNamePrefix: String
    ): SchemaNode {
        val items = json.elements(this)
            .zip(items(obj)) { node: NODE, value: Any ->
                value.javaClass.enumConstants?.let {
                    node.toEnumSchema("", it[0], json.typeOf(node).toParam(), it, false, null, refModelNamePrefix)
                } ?: node.toSchema(
                    value,
                    null,
                    false,
                    refModelNamePrefix,
                    fieldRetrieval(FieldHolder(value), "value").metadata
                )
            }.map { it.arrayItem() }.toSet()

        val arrayItems = when (items.size) {
            0 -> EmptyArray
            1 -> items.first()
            else -> OneOfArray(items)
        }

        return SchemaNode.Array(name, isNullable, arrayItems, this, metadata)
    }

    private fun NODE.toEnumSchema(
        fieldName: String, obj: Any, param: ParamMeta,
        enumConstants: Array, isNullable: Boolean, metadata: FieldMetadata?,
        refModelNamePrefix: String
    ): SchemaNode =
        SchemaNode.Reference(
            fieldName,
            "#/$refLocationPrefix/$refModelNamePrefix${modelNamer(obj)}",
            SchemaNode.Enum(
                "$refModelNamePrefix${modelNamer(obj)}",
                param,
                isNullable,
                this,
                enumConstants.map { json.asFormatString(it).unquoted() },
                null
            ),
            metadata
        )

    private fun NODE.toObjectOrMapSchema(
        objName: String?,
        obj: Any,
        isNullable: Boolean,
        topLevel: Boolean,
        metadata: FieldMetadata?,
        refModelNamePrefix: String
    ) =
        if (obj is Map<*, *>) toMapSchema(objName, obj, isNullable, topLevel, metadata, refModelNamePrefix)
        else toObjectSchema(objName, obj, isNullable, topLevel, metadata, refModelNamePrefix)

    private fun NODE.toObjectSchema(
        objName: String?,
        obj: Any,
        isNullable: Boolean,
        topLevel: Boolean,
        metadata: FieldMetadata?,
        refModelNamePrefix: String
    ): SchemaNode {
        val properties = json.fields(this)
            .map { Triple(it.first, it.second, fieldRetrieval(obj, it.first)) }
            .map { (fieldName, field, kField) ->
                makePropertySchemaFor(
                    field,
                    fieldName,
                    kField.value,
                    kField.isNullable,
                    kField.metadata,
                    refModelNamePrefix
                )
            }.associateBy { it.name() }

        val nameToUseForRef = if (topLevel) objName ?: modelNamer(obj) else modelNamer(obj)

        return SchemaNode.Reference(
            objName
                ?: modelNamer(obj), "#/$refLocationPrefix/$refModelNamePrefix$nameToUseForRef",
            SchemaNode.Object(refModelNamePrefix + nameToUseForRef, isNullable, properties, this, metadata), null
        )
    }

    private fun NODE.toMapSchema(
        objName: String?,
        obj: Map<*, *>,
        isNullable: Boolean,
        topLevel: Boolean,
        metadata: FieldMetadata?,
        refModelNamePrefix: String
    ): SchemaNode {
        val objWithStringKeys = obj.mapKeys { it.key?.let(::toJsonKey) }
        val properties = json.fields(this)
            .map { Triple(it.first, it.second, objWithStringKeys[it.first]!!) }
            .map { (fieldName, field, value) ->
                makePropertySchemaFor(
                    field,
                    fieldName,
                    value,
                    true,
                    fieldRetrieval(FieldHolder(value), "value").metadata,
                    refModelNamePrefix
                )
            }
            .map { it.name() to it }.toMap()

        return if (topLevel && objName != null) {
            SchemaNode.Reference(
                objName, "#/$refLocationPrefix/$refModelNamePrefix$objName",
                SchemaNode.Object(refModelNamePrefix + objName, isNullable, properties, this, null), metadata
            )
        } else
            SchemaNode.MapType(
                objName ?: modelNamer(obj), isNullable,
                SchemaNode.Object(modelNamer(obj), isNullable, properties, this, null), metadata
            )
    }

    private fun makePropertySchemaFor(
        field: NODE,
        fieldName: String,
        value: Any,
        isNullable: Boolean,
        metadata: FieldMetadata?,
        refModelNamePrefix: String
    ) = when (val param = json.typeOf(field).toParam()) {
        is ArrayParam -> field.toArraySchema(fieldName, value, isNullable, metadata, refModelNamePrefix)
        ObjectParam -> field.toObjectOrMapSchema(fieldName, value, isNullable, false, metadata, refModelNamePrefix)
        else -> with(field) {
            value.javaClass.enumConstants
                ?.let { toEnumSchema(fieldName, value, param, it, isNullable, metadata, refModelNamePrefix) }
                ?: toSchema(fieldName, param, isNullable, metadata)
        }
    }

    private fun toJsonKey(it: Any): String {
        data class MapKey(val keyAsString: Any)
        return json.textValueOf(json.asJsonObject(MapKey(it)), "keyAsString")!!
    }
}

fun interface SchemaModelNamer : (Any) -> String {
    companion object {
        val Simple: SchemaModelNamer = SchemaModelNamer { it.javaClass.simpleName }
        val Full: SchemaModelNamer = SchemaModelNamer { it.javaClass.name }
        val Canonical: SchemaModelNamer = SchemaModelNamer { it.javaClass.canonicalName }
    }
}

private interface ArrayItems {
    fun definitions(): Iterable
}

private sealed interface ArrayItem : ArrayItems {
    class Array(val items: ArrayItems, val format: Any?, private val definitions: Iterable) : ArrayItem {
        @Suppress("unused")
        val type = ArrayParam(NullParam).value

        override fun definitions(): Iterable = definitions

        override fun equals(other: Any?): Boolean = when (other) {
            is Array -> this.items == other.items
            else -> false
        }

        override fun hashCode(): Int = items.hashCode()
    }

    class NonObject(paramMeta: ParamMeta, val format: Any?, private val definitions: Iterable) : ArrayItem {
        val type = paramMeta.value

        override fun equals(other: Any?): Boolean {
            if (this === other) return true
            if (javaClass != other?.javaClass) return false

            other as NonObject

            return type == other.type
        }

        override fun hashCode(): Int = type.hashCode()
        override fun definitions(): Iterable = definitions
    }

    class Ref(
        @Suppress("unused")
        val `$ref`: String,
        private val definitions: Iterable
    ) : ArrayItem {
        override fun definitions(): Iterable = definitions
        override fun equals(other: Any?): Boolean = when (other) {
            is Ref -> this.`$ref` == other.`$ref`
            else -> false
        }

        override fun hashCode(): Int = `$ref`.hashCode()
    }
}

private object EmptyArray : ArrayItems {
    override fun definitions(): Iterable = emptyList()
}

private class OneOfArray(private val schemas: Set) : ArrayItems {
    @Suppress("unused")
    val oneOf = schemas.toSet().sortedBy { it.javaClass.simpleName }

    override fun definitions() = schemas.flatMap { it.definitions() }
}

private abstract class SchemaSortingMap(private val map: MutableMap) : MutableMap by map {
    override val entries
        get() = map.toSortedMap(compareBy { sortOrder(it) }.thenBy { it }).entries

    private fun sortOrder(o1: String) = SORT_ORDER.indexOf(o1).let {
        if (it > -1) it else Int.MAX_VALUE
    }

    companion object {
        val SORT_ORDER = listOf(
            "properties",
            "items",
            "\$ref",
            "example",
            "enum",
            "additionalProperties",
            "description",
            "format",
            "default",
            "multipleOf",
            "maximum",
            "exclusiveMaximum",
            "minimum",
            "exclusiveMinimum",
            "maxLength",
            "minLength",
            "pattern",
            "maxItems",
            "minItems",
            "uniqueItems",
            "maxProperties",
            "minProperties",
            "type",
            "required",
            "title",
        )
    }
}

private class SchemaNode(
    private val name: String,
    private val paramMeta: ParamMeta,
    private val isNullable: Boolean,
    val example: Any?,
    metadata: FieldMetadata?,
    val definitions: Iterable = emptyList(),
    val arrayItem: ArrayItem
) : SchemaSortingMap(metadata?.extra?.toMutableMap() ?: mutableMapOf()) {
    init {
        this["format"] = this["format"]
        this["example"] = example
    }

    fun name() = name
    fun arrayItem(): ArrayItem = arrayItem

    companion object {
        fun Primitive(
            name: String,
            paramMeta: ParamMeta,
            isNullable: Boolean,
            example: Any?,
            metadata: FieldMetadata?
        ) =
            SchemaNode(
                name = name,
                paramMeta = paramMeta,
                isNullable = isNullable,
                example = example,
                metadata = metadata,
                arrayItem = ArrayItem.NonObject(
                    paramMeta,
                    metadata.format(), emptyList()
                )
            ).apply {
                this["type"] = paramMeta.value
                this["nullable"] = isNullable
            }

        fun Enum(
            name: String,
            paramMeta: ParamMeta,
            isNullable: Boolean,
            example: Any?,
            enum: List,
            metadata: FieldMetadata?
        ) =
            SchemaNode(
                name = name,
                paramMeta = paramMeta,
                isNullable = isNullable,
                example = example,
                metadata = metadata,
                arrayItem = ArrayItem.Ref(name, emptyList())
            ).apply {
                this["type"] = paramMeta.value
                this["nullable"] = isNullable
                this["enum"] = enum
            }

        fun Array(
            name: String,
            isNullable: Boolean,
            items: ArrayItems,
            example: Any?,
            metadata: FieldMetadata?
        ): SchemaNode {
            val paramMeta: ParamMeta =
                ArrayParam(items.definitions().map { it.paramMeta }.toSet().firstOrNull() ?: NullParam)
            return SchemaNode(
                name = name,
                paramMeta = paramMeta,
                isNullable = isNullable,
                example = example,
                metadata = metadata,
                definitions = items.definitions(),
                arrayItem = ArrayItem.Array(items, metadata.format(), items.definitions())
            ).apply {
                this["type"] = paramMeta.value
                this["nullable"] = isNullable
                this["items"] = items
            }
        }

        private fun FieldMetadata?.format() = this?.extra?.get("format")

        fun Object(
            name: String, isNullable: Boolean, properties: Map,
            example: Any?, metadata: FieldMetadata?
        ): SchemaNode {
            val paramMeta = ObjectParam
            return SchemaNode(
                name = name,
                paramMeta = paramMeta,
                isNullable = isNullable,
                example = example,
                metadata = metadata,
                definitions = properties.values.flatMap { it.definitions },
                arrayItem = ArrayItem.Ref(name, properties.values.flatMap { it.definitions })
            ).apply {
                this["type"] = paramMeta.value
                this["required"] =
                    properties.let { it.filterNot { it.value.isNullable }.takeIf { it.isNotEmpty() }?.keys?.sorted() }
                this["properties"] = properties
            }
        }

        fun Reference(
            name: String,
            ref: String,
            schemaNode: SchemaNode,
            metadata: FieldMetadata?
        ) = SchemaNode(
            name = name,
            paramMeta = ObjectParam,
            isNullable = schemaNode.isNullable,
            example = null,
            metadata = metadata,
            definitions = listOf(schemaNode) + schemaNode.definitions,
            arrayItem = ArrayItem.Ref(ref, listOf(schemaNode) + schemaNode.definitions)
        ).apply {
            this["\$ref"] = ref
        }

        fun MapType(
            name: String,
            isNullable: Boolean,
            additionalProperties: SchemaNode,
            metadata: FieldMetadata?
        ): SchemaNode {
            val paramMeta = ObjectParam
            return SchemaNode(
                name,
                paramMeta,
                isNullable,
                null,
                metadata,
                definitions = additionalProperties.definitions,
                arrayItem = ArrayItem.Ref(name, additionalProperties.definitions)
            ).apply {
                this["type"] = paramMeta.value
                this["additionalProperties"] = additionalProperties
            }
        }
    }
}

private fun items(obj: Any) = when (obj) {
    is Array<*> -> obj.asList()
    is Iterable<*> -> obj.toList()
    else -> listOf(obj)
}.filterNotNull()

private fun JsonType.toParam() = when (this) {
    JsonType.String -> StringParam
    JsonType.Integer -> IntegerParam
    JsonType.Number -> NumberParam
    JsonType.Boolean -> BooleanParam
    JsonType.Array -> ArrayParam(NullParam)
    JsonType.Object -> ObjectParam
    JsonType.Null -> throw IllegalSchemaException("Cannot use a null value in a schema!")
}

data class FieldHolder(@JvmField val value: Any)




© 2015 - 2025 Weber Informatics LLC | Privacy Policy