org.http4k.contract.openapi.v3.AutoJsonToJsonSchema.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of http4k-contract Show documentation
Show all versions of http4k-contract Show documentation
http4k typesafe HTTP contracts and OpenApi support
package org.http4k.contract.openapi.v3
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
import org.http4k.util.IllegalSchemaException
import org.http4k.util.JsonSchema
import org.http4k.util.JsonSchemaCreator
class AutoJsonToJsonSchema(
private val json: AutoMarshallingJson,
private val fieldRetrieval: FieldRetrieval = FieldRetrieval.compose(SimpleLookup()),
private val modelNamer: SchemaModelNamer = SchemaModelNamer.Simple,
private val refPrefix: String = "components/schemas"
) : JsonSchemaCreator {
override fun toSchema(obj: Any, overrideDefinitionId: String?): JsonSchema {
val schema = json.asJsonObject(obj).toSchema(obj, overrideDefinitionId, true)
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) =
when (val param = json.typeOf(this).toParam()) {
is ArrayParam -> toArraySchema("", value, false, null)
ObjectParam -> toObjectOrMapSchema(objName, value, false, topLevel, null)
else -> value.javaClass.enumConstants?.let {
toEnumSchema("", it[0], json.typeOf(this).toParam(), it, false, null)
} ?: toSchema("", param, false, null)
}
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?
): SchemaNode.Array {
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)
} ?: node.toSchema(value, null, false)
}.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?
): SchemaNode =
SchemaNode.Reference(
fieldName,
"#/$refPrefix/${modelNamer(obj)}",
SchemaNode.Enum(
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?
) =
if (obj is Map<*, *>) toMapSchema(objName, obj, isNullable, topLevel, metadata)
else toObjectSchema(objName, obj, isNullable, topLevel, metadata)
private fun NODE.toObjectSchema(
objName: String?,
obj: Any,
isNullable: Boolean,
topLevel: Boolean,
metadata: FieldMetadata?
): SchemaNode.Reference {
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)
}
.map { it.name() to it }.toMap()
val nameToUseForRef = if (topLevel) objName ?: modelNamer(obj) else modelNamer(obj)
return SchemaNode.Reference(
objName
?: modelNamer(obj), "#/$refPrefix/$nameToUseForRef",
SchemaNode.Object(nameToUseForRef, isNullable, properties, this, null), metadata
)
}
private fun NODE.toMapSchema(
objName: String?,
obj: Map<*, *>,
isNullable: Boolean,
topLevel: Boolean,
metadata: FieldMetadata?
): 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, false, null) }
.map { it.name() to it }.toMap()
return if (topLevel && objName != null) {
SchemaNode.Reference(
objName, "#/$refPrefix/$objName",
SchemaNode.Object(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?
) = when (val param = json.typeOf(field).toParam()) {
is ArrayParam -> field.toArraySchema(fieldName, value, isNullable, metadata)
ObjectParam -> field.toObjectOrMapSchema(fieldName, value, isNullable, false, metadata)
else -> with(field) {
value.javaClass.enumConstants
?.let { toEnumSchema(fieldName, value, param, it, isNullable, metadata) }
?: 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 }
}
}
private interface ArrayItems {
fun definitions(): Iterable
}
private sealed class ArrayItem : ArrayItems {
class Array(val items: ArrayItems, private val schema: SchemaNode) : ArrayItem() {
val type = ArrayParam(NullParam).value
override fun definitions(): Iterable = schema.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, private val schema: SchemaNode) : 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
if (type != other.type) return false
return true
}
override fun hashCode(): Int = type.hashCode()
override fun definitions(): Iterable = schema.definitions()
}
class Ref(val `$ref`: String, private val schema: SchemaNode) : ArrayItem() {
override fun definitions(): Iterable = schema.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 {
val oneOf = schemas.toSet().sortedBy { it.javaClass.simpleName }
override fun definitions() = schemas.flatMap { it.definitions() }
}
private sealed class SchemaNode(
private val _name: String,
private val _paramMeta: ParamMeta,
private val isNullable: Boolean,
val example: Any?,
metadata: FieldMetadata?
) {
abstract fun definitions(): Iterable
fun name() = _name
fun paramMeta() = _paramMeta
abstract fun arrayItem(): ArrayItem
val description = metadata?.description
class Primitive(name: String, paramMeta: ParamMeta, isNullable: Boolean, example: Any?, metadata: FieldMetadata?) :
SchemaNode(name, paramMeta, isNullable, example, metadata) {
val type = paramMeta().value
override fun arrayItem() = ArrayItem.NonObject(paramMeta(), this)
override fun definitions() = emptyList()
}
class Enum(
name: String,
paramMeta: ParamMeta,
isNullable: Boolean,
example: Any?,
val enum: List,
metadata: FieldMetadata?
) :
SchemaNode(name, paramMeta, isNullable, example, metadata) {
val type = paramMeta().value
override fun arrayItem() = ArrayItem.Ref(name(), this)
override fun definitions() = emptyList()
}
class Array(name: String, isNullable: Boolean, val items: ArrayItems, example: Any?, metadata: FieldMetadata?) :
SchemaNode(
name,
ArrayParam(items.definitions().map { it.paramMeta() }.toSet().firstOrNull() ?: NullParam),
isNullable,
example,
metadata
) {
val type = paramMeta().value
override fun arrayItem() = when (paramMeta()) {
is ArrayParam -> ArrayItem.Array(items, this)
ObjectParam -> ArrayItem.Ref(name(), this)
else -> ArrayItem.NonObject(paramMeta(), this)
}
override fun definitions() = items.definitions()
}
class Object(
name: String, isNullable: Boolean, val properties: Map,
example: Any?, metadata: FieldMetadata?
) : SchemaNode(name, ObjectParam, isNullable, example, metadata) {
val type = paramMeta().value
val required = properties.filterNot { it.value.isNullable }.keys.sorted()
override fun arrayItem() = ArrayItem.Ref(name(), this)
override fun definitions() = properties.values.flatMap { it.definitions() }
}
class Reference(
name: String,
val `$ref`: String,
private val schemaNode: SchemaNode,
metadata: FieldMetadata?
) : SchemaNode(name, ObjectParam, schemaNode.isNullable, null, metadata) {
override fun arrayItem() = ArrayItem.Ref(`$ref`, this)
override fun definitions() = listOf(schemaNode) + schemaNode.definitions()
}
class MapType(name: String, isNullable: Boolean, val additionalProperties: SchemaNode, metadata: FieldMetadata?) :
SchemaNode(name, ObjectParam, isNullable, null, metadata) {
val type = paramMeta().value
override fun arrayItem() = ArrayItem.Ref(name(), this)
override fun definitions() = additionalProperties.definitions()
}
}
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!")
}