breaking change: V3 alpha (#256)

This commit is contained in:
Ryan Brink
2022-08-13 09:59:59 -07:00
committed by GitHub
parent 48969a8fcc
commit c73c9b4605
308 changed files with 5410 additions and 10204 deletions

View File

@ -0,0 +1,32 @@
plugins {
kotlin("jvm")
kotlin("plugin.serialization")
id("io.bkbn.sourdough.library.jvm")
id("io.gitlab.arturbosch.detekt")
id("com.adarshr.test-logger")
id("org.jetbrains.dokka")
id("maven-publish")
id("java-library")
id("signing")
}
sourdoughLibrary {
libraryName.set("Kompendium JSON Schema")
libraryDescription.set("Json Schema Generator")
compilerArgs.set(listOf("-opt-in=kotlin.RequiresOptIn"))
}
dependencies {
implementation("org.jetbrains.kotlin:kotlin-reflect:1.7.10")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3")
testImplementation(testFixtures(projects.kompendiumCore))
}
testing {
suites {
named("test", JvmTestSuite::class) {
useJUnitJupiter()
}
}
}

View File

@ -0,0 +1,65 @@
package io.bkbn.kompendium.json.schema
import io.bkbn.kompendium.json.schema.definition.JsonSchema
import io.bkbn.kompendium.json.schema.definition.NullableDefinition
import io.bkbn.kompendium.json.schema.definition.OneOfDefinition
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
import io.bkbn.kompendium.json.schema.handler.CollectionHandler
import io.bkbn.kompendium.json.schema.handler.EnumHandler
import io.bkbn.kompendium.json.schema.handler.MapHandler
import io.bkbn.kompendium.json.schema.handler.SimpleObjectHandler
import io.bkbn.kompendium.json.schema.handler.SealedObjectHandler
import io.bkbn.kompendium.json.schema.util.Helpers.getSimpleSlug
import kotlin.reflect.KClass
import kotlin.reflect.KType
import kotlin.reflect.full.isSubclassOf
import kotlin.reflect.typeOf
object SchemaGenerator {
inline fun <reified T : Any?> fromTypeToSchema(cache: MutableMap<String, JsonSchema> = mutableMapOf()) =
fromTypeToSchema(typeOf<T>(), cache)
fun fromTypeToSchema(type: KType, cache: MutableMap<String, JsonSchema>): JsonSchema {
cache[type.getSimpleSlug()]?.let {
return it
}
return when (val clazz = type.classifier as KClass<*>) {
Unit::class -> error(
"""
Unit cannot be converted to JsonSchema.
If you are looking for a method will return null when called with Unit,
please call SchemaGenerator.fromTypeOrUnit()
""".trimIndent()
)
Int::class -> checkForNull(type, TypeDefinition.INT)
Long::class -> checkForNull(type, TypeDefinition.LONG)
Double::class -> checkForNull(type, TypeDefinition.DOUBLE)
Float::class -> checkForNull(type, TypeDefinition.FLOAT)
String::class -> checkForNull(type, TypeDefinition.STRING)
Boolean::class -> checkForNull(type, TypeDefinition.BOOLEAN)
else -> when {
clazz.isSubclassOf(Enum::class) -> EnumHandler.handle(type, clazz)
clazz.isSubclassOf(Collection::class) -> CollectionHandler.handle(type, cache)
clazz.isSubclassOf(Map::class) -> MapHandler.handle(type, cache)
else -> {
if (clazz.isSealed) {
SealedObjectHandler.handle(type, clazz, cache)
} else {
SimpleObjectHandler.handle(type, clazz, cache)
}
}
}
}
}
fun fromTypeOrUnit(type: KType, cache: MutableMap<String, JsonSchema> = mutableMapOf()): JsonSchema? =
when (type.classifier as KClass<*>) {
Unit::class -> null
else -> fromTypeToSchema(type, cache)
}
private fun checkForNull(type: KType, schema: JsonSchema): JsonSchema = when (type.isMarkedNullable) {
true -> OneOfDefinition(NullableDefinition(), schema)
false -> schema
}
}

View File

@ -0,0 +1,6 @@
package io.bkbn.kompendium.json.schema.definition
import kotlinx.serialization.Serializable
@Serializable
data class AnyOfDefinition(val anyOf: Set<JsonSchema>) : JsonSchema

View File

@ -0,0 +1,10 @@
package io.bkbn.kompendium.json.schema.definition
import kotlinx.serialization.Serializable
@Serializable
data class ArrayDefinition(
val items: JsonSchema
) : JsonSchema {
val type: String = "array"
}

View File

@ -0,0 +1,8 @@
package io.bkbn.kompendium.json.schema.definition
import kotlinx.serialization.Serializable
@Serializable
data class EnumDefinition(
val enum: Set<String>
) : JsonSchema

View File

@ -0,0 +1,33 @@
package io.bkbn.kompendium.json.schema.definition
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
@Serializable(with = JsonSchema.Serializer::class)
sealed interface JsonSchema {
object Serializer : KSerializer<JsonSchema> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("JsonSchema", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): JsonSchema {
error("Abandon all hope ye who enter 💀")
}
override fun serialize(encoder: Encoder, value: JsonSchema) {
when (value) {
is ReferenceDefinition -> ReferenceDefinition.serializer().serialize(encoder, value)
is TypeDefinition -> TypeDefinition.serializer().serialize(encoder, value)
is EnumDefinition -> EnumDefinition.serializer().serialize(encoder, value)
is ArrayDefinition -> ArrayDefinition.serializer().serialize(encoder, value)
is MapDefinition -> MapDefinition.serializer().serialize(encoder, value)
is NullableDefinition -> NullableDefinition.serializer().serialize(encoder, value)
is OneOfDefinition -> OneOfDefinition.serializer().serialize(encoder, value)
is AnyOfDefinition -> AnyOfDefinition.serializer().serialize(encoder, value)
}
}
}
}

View File

@ -0,0 +1,10 @@
package io.bkbn.kompendium.json.schema.definition
import kotlinx.serialization.Serializable
@Serializable
data class MapDefinition(
val additionalProperties: JsonSchema
) : JsonSchema {
val type: String = "object"
}

View File

@ -0,0 +1,6 @@
package io.bkbn.kompendium.json.schema.definition
import kotlinx.serialization.Serializable
@Serializable
data class NullableDefinition(val type: String = "null") : JsonSchema

View File

@ -0,0 +1,8 @@
package io.bkbn.kompendium.json.schema.definition
import kotlinx.serialization.Serializable
@Serializable
data class OneOfDefinition(val oneOf: Set<JsonSchema>) : JsonSchema {
constructor(vararg types: JsonSchema) : this(types.toSet())
}

View File

@ -0,0 +1,6 @@
package io.bkbn.kompendium.json.schema.definition
import kotlinx.serialization.Serializable
@Serializable
data class ReferenceDefinition(val `$ref`: String) : JsonSchema

View File

@ -0,0 +1,47 @@
package io.bkbn.kompendium.json.schema.definition
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
@Serializable
data class TypeDefinition(
val type: String,
val format: String? = null,
val description: String? = null,
val properties: Map<String, JsonSchema>? = null,
val required: Set<String>? = null,
@Contextual val default: Any? = null,
) : JsonSchema {
fun withDefault(default: Any): TypeDefinition = this.copy(default = default)
companion object {
val INT = TypeDefinition(
type = "number",
format = "int32"
)
val LONG = TypeDefinition(
type = "number",
format = "int64"
)
val DOUBLE = TypeDefinition(
type = "number",
format = "double"
)
val FLOAT = TypeDefinition(
type = "number",
format = "float"
)
val STRING = TypeDefinition(
type = "string"
)
val BOOLEAN = TypeDefinition(
type = "boolean"
)
}
}

View File

@ -0,0 +1,33 @@
package io.bkbn.kompendium.json.schema.handler
import io.bkbn.kompendium.json.schema.SchemaGenerator
import io.bkbn.kompendium.json.schema.definition.ArrayDefinition
import io.bkbn.kompendium.json.schema.definition.JsonSchema
import io.bkbn.kompendium.json.schema.definition.NullableDefinition
import io.bkbn.kompendium.json.schema.definition.OneOfDefinition
import io.bkbn.kompendium.json.schema.definition.ReferenceDefinition
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
import io.bkbn.kompendium.json.schema.util.Helpers.getReferenceSlug
import io.bkbn.kompendium.json.schema.util.Helpers.getSimpleSlug
import kotlin.reflect.KType
object CollectionHandler {
fun handle(type: KType, cache: MutableMap<String, JsonSchema>): JsonSchema {
val collectionType = type.arguments.first().type!!
val typeSchema = SchemaGenerator.fromTypeToSchema(collectionType, cache).let {
if (it is TypeDefinition && it.type == "object") {
cache[collectionType.getSimpleSlug()] = it
ReferenceDefinition(collectionType.getReferenceSlug())
} else {
it
}
}
val definition = ArrayDefinition(typeSchema)
return when (type.isMarkedNullable) {
true -> OneOfDefinition(NullableDefinition(), definition)
false -> definition
}
}
}

View File

@ -0,0 +1,21 @@
package io.bkbn.kompendium.json.schema.handler
import io.bkbn.kompendium.json.schema.definition.EnumDefinition
import io.bkbn.kompendium.json.schema.definition.JsonSchema
import io.bkbn.kompendium.json.schema.definition.NullableDefinition
import io.bkbn.kompendium.json.schema.definition.OneOfDefinition
import kotlin.reflect.KClass
import kotlin.reflect.KType
object EnumHandler {
fun handle(type: KType, clazz: KClass<*>): JsonSchema {
val options = clazz.java.enumConstants.map { it.toString() }.toSet()
val definition = EnumDefinition(enum = options)
return when (type.isMarkedNullable) {
true -> OneOfDefinition(NullableDefinition(), definition)
false -> definition
}
}
}

View File

@ -0,0 +1,37 @@
package io.bkbn.kompendium.json.schema.handler
import io.bkbn.kompendium.json.schema.SchemaGenerator
import io.bkbn.kompendium.json.schema.definition.JsonSchema
import io.bkbn.kompendium.json.schema.definition.MapDefinition
import io.bkbn.kompendium.json.schema.definition.NullableDefinition
import io.bkbn.kompendium.json.schema.definition.OneOfDefinition
import io.bkbn.kompendium.json.schema.definition.ReferenceDefinition
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
import io.bkbn.kompendium.json.schema.util.Helpers.getReferenceSlug
import io.bkbn.kompendium.json.schema.util.Helpers.getSimpleSlug
import kotlin.reflect.KClass
import kotlin.reflect.KType
object MapHandler {
fun handle(type: KType, cache: MutableMap<String, JsonSchema>): JsonSchema {
require(type.arguments.first().type!!.classifier as KClass<*> == String::class) {
"JSON requires that map keys MUST be Strings. You provided ${type.arguments.first().type}"
}
val valueType = type.arguments[1].type!!
val valueSchema = SchemaGenerator.fromTypeToSchema(valueType, cache).let {
if (it is TypeDefinition && it.type == "object") {
cache[valueType.getSimpleSlug()] = it
ReferenceDefinition(valueType.getReferenceSlug())
} else {
it
}
}
val definition = MapDefinition(valueSchema)
return when (type.isMarkedNullable) {
true -> OneOfDefinition(NullableDefinition(), definition)
false -> definition
}
}
}

View File

@ -0,0 +1,32 @@
package io.bkbn.kompendium.json.schema.handler
import io.bkbn.kompendium.json.schema.SchemaGenerator
import io.bkbn.kompendium.json.schema.definition.AnyOfDefinition
import io.bkbn.kompendium.json.schema.definition.JsonSchema
import io.bkbn.kompendium.json.schema.definition.ReferenceDefinition
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
import io.bkbn.kompendium.json.schema.util.Helpers.getReferenceSlug
import io.bkbn.kompendium.json.schema.util.Helpers.getSimpleSlug
import kotlin.reflect.KClass
import kotlin.reflect.KType
import kotlin.reflect.full.createType
object SealedObjectHandler {
fun handle(type: KType, clazz: KClass<*>, cache: MutableMap<String, JsonSchema>): JsonSchema {
val subclasses = clazz.sealedSubclasses
.map { it.createType(type.arguments) }
.map { t ->
SchemaGenerator.fromTypeToSchema(t, cache).let { js ->
if (js is TypeDefinition && js.type == "object") {
cache[t.getSimpleSlug()] = js
ReferenceDefinition(t.getReferenceSlug())
} else {
js
}
}
}
.toSet()
return AnyOfDefinition(subclasses)
}
}

View File

@ -0,0 +1,76 @@
package io.bkbn.kompendium.json.schema.handler
import io.bkbn.kompendium.json.schema.SchemaGenerator
import io.bkbn.kompendium.json.schema.definition.JsonSchema
import io.bkbn.kompendium.json.schema.definition.NullableDefinition
import io.bkbn.kompendium.json.schema.definition.OneOfDefinition
import io.bkbn.kompendium.json.schema.definition.ReferenceDefinition
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
import io.bkbn.kompendium.json.schema.util.Helpers.getReferenceSlug
import io.bkbn.kompendium.json.schema.util.Helpers.getSimpleSlug
import kotlin.reflect.KClass
import kotlin.reflect.KProperty
import kotlin.reflect.KType
import kotlin.reflect.KTypeParameter
import kotlin.reflect.KTypeProjection
import kotlin.reflect.full.memberProperties
import kotlin.reflect.full.primaryConstructor
object SimpleObjectHandler {
fun handle(type: KType, clazz: KClass<*>, cache: MutableMap<String, JsonSchema>): JsonSchema {
// cache[type.getSimpleSlug()] = ReferenceDefinition("RECURSION_PLACEHOLDER")
val typeMap = clazz.typeParameters.zip(type.arguments).toMap()
val props = clazz.memberProperties.associate { prop ->
val schema = when (typeMap.containsKey(prop.returnType.classifier)) {
true -> handleGenericProperty(prop, typeMap, cache)
false -> handleProperty(prop, cache)
}
prop.name to schema
}
val required = clazz.memberProperties.filterNot { prop -> prop.returnType.isMarkedNullable }
.filterNot { prop -> clazz.primaryConstructor!!.parameters.find { it.name == prop.name }!!.isOptional }
.map { it.name }
.toSet()
val definition = TypeDefinition(
type = "object",
properties = props,
required = required
)
return when (type.isMarkedNullable) {
true -> OneOfDefinition(NullableDefinition(), definition)
false -> definition
}
}
private fun handleGenericProperty(
prop: KProperty<*>,
typeMap: Map<KTypeParameter, KTypeProjection>,
cache: MutableMap<String, JsonSchema>
): JsonSchema {
val type = typeMap[prop.returnType.classifier]?.type!!
return SchemaGenerator.fromTypeToSchema(type, cache).let {
if (it is TypeDefinition && it.type == "object") {
cache[type.getSimpleSlug()] = it
ReferenceDefinition(prop.returnType.getReferenceSlug())
} else {
it
}
}
}
private fun handleProperty(prop: KProperty<*>, cache: MutableMap<String, JsonSchema>): JsonSchema =
SchemaGenerator.fromTypeToSchema(prop.returnType, cache).let {
if (it is TypeDefinition && it.type == "object") {
cache[prop.returnType.getSimpleSlug()] = it
ReferenceDefinition(prop.returnType.getReferenceSlug())
} else {
it
}
}
}

View File

@ -0,0 +1,29 @@
package io.bkbn.kompendium.json.schema.util
import kotlin.reflect.KClass
import kotlin.reflect.KType
object Helpers {
private const val COMPONENT_SLUG = "#/components/schemas"
fun KType.getSimpleSlug(): String = when {
this.arguments.isNotEmpty() -> genericNameAdapter(this, classifier as KClass<*>)
else -> (classifier as KClass<*>).simpleName ?: error("Could not determine simple name for $this")
}
fun KType.getReferenceSlug(): String = when {
arguments.isNotEmpty() -> "$COMPONENT_SLUG/${genericNameAdapter(this, classifier as KClass<*>)}"
else -> "$COMPONENT_SLUG/${(classifier as KClass<*>).simpleName}"
}
/**
* Adapts a class with type parameters into a reference friendly string
*/
private fun genericNameAdapter(type: KType, clazz: KClass<*>): String {
val classNames = type.arguments
.map { it.type?.classifier as KClass<*> }
.map { it.simpleName }
return classNames.joinToString(separator = "-", prefix = "${clazz.simpleName}-")
}
}

View File

@ -0,0 +1,9 @@
package io.bkbn.kompendium.json.schema.util
import io.bkbn.kompendium.json.schema.definition.JsonSchema
import kotlin.reflect.KType
data class ReferenceCache(
val referenceRootPath: String = "#/",
val cache: MutableMap<KType, JsonSchema> = mutableMapOf()
)

View File

@ -0,0 +1,98 @@
package io.bkbn.kompendium.json.schema
import io.bkbn.kompendium.core.fixtures.ComplexRequest
import io.bkbn.kompendium.core.fixtures.FlibbityGibbit
import io.bkbn.kompendium.core.fixtures.SimpleEnum
import io.bkbn.kompendium.core.fixtures.SlammaJamma
import io.bkbn.kompendium.core.fixtures.TestHelpers.getFileSnapshot
import io.bkbn.kompendium.core.fixtures.TestResponse
import io.bkbn.kompendium.core.fixtures.TestSimpleRequest
import io.bkbn.kompendium.json.schema.definition.JsonSchema
import io.kotest.assertions.json.shouldEqualJson
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.DescribeSpec
import kotlinx.serialization.json.Json
class SchemaGeneratorTest : DescribeSpec({
describe("Scalars") {
it("Can generate the schema for an Int") {
jsonSchemaTest<Int>("T0001__scalar_int.json")
}
it("Can generate the schema for a Boolean") {
jsonSchemaTest<Boolean>("T0002__scalar_bool.json")
}
it("Can generate the schema for a String") {
jsonSchemaTest<String>("T0003__scalar_string.json")
}
}
describe("Objects") {
it("Can generate the schema for a simple object") {
jsonSchemaTest<TestSimpleRequest>("T0004__simple_object.json")
}
it("Can generate the schema for a complex object") {
jsonSchemaTest<ComplexRequest>("T0005__complex_object.json")
}
it("Can generate the schema for a nullable object") {
jsonSchemaTest<TestSimpleRequest?>("T0006__nullable_object.json")
}
it("Can generate the schema for a polymorphic object") {
jsonSchemaTest<FlibbityGibbit>("T0015__polymorphic_object.json")
}
xit("Can generate the schema for a recursive type") {
// TODO jsonSchemaTest<SlammaJamma>("T0016__recursive_object.json")
}
}
describe("Enums") {
it("Can generate the schema for a simple enum") {
jsonSchemaTest<SimpleEnum>("T0007__simple_enum.json")
}
it("Can generate the schema for a nullable enum") {
jsonSchemaTest<SimpleEnum?>("T0008__nullable_enum.json")
}
}
describe("Arrays") {
it("Can generate the schema for an array of scalars") {
jsonSchemaTest<List<Int>>("T0009__scalar_array.json")
}
it("Can generate the schema for an array of objects") {
jsonSchemaTest<List<TestResponse>>("T0010__object_array.json")
}
it("Can generate the schema for a nullable array") {
jsonSchemaTest<List<Int>?>("T0011__nullable_array.json")
}
}
describe("Maps") {
it("Can generate the schema for a map of scalars") {
jsonSchemaTest<Map<String, Int>>("T0012__scalar_map.json")
}
it("Throws an error when map keys are not strings") {
shouldThrow<IllegalArgumentException> { SchemaGenerator.fromTypeToSchema<Map<Int, Int>>() }
}
it("Can generate the schema for a map of objects") {
jsonSchemaTest<Map<String, TestResponse>>("T0013__object_map.json")
}
it("Can generate the schema for a nullable map") {
jsonSchemaTest<Map<String, Int>?>("T0014__nullable_map.json")
}
}
}) {
companion object {
private val json = Json {
encodeDefaults = true
explicitNulls = false
prettyPrint = true
}
private fun JsonSchema.serialize() = json.encodeToString(JsonSchema.serializer(), this)
private inline fun <reified T> jsonSchemaTest(snapshotName: String) {
// act
val schema = SchemaGenerator.fromTypeToSchema<T>()
// todo add cache assertions!!!
// assert
schema.serialize() shouldEqualJson getFileSnapshot(snapshotName)
}
}
}

View File

@ -0,0 +1,4 @@
{
"type": "number",
"format": "int32"
}

View File

@ -0,0 +1,3 @@
{
"type": "boolean"
}

View File

@ -0,0 +1,3 @@
{
"type": "string"
}

View File

@ -0,0 +1,16 @@
{
"type": "object",
"properties": {
"a": {
"type": "string"
},
"b": {
"type": "number",
"format": "int32"
}
},
"required": [
"a",
"b"
]
}

View File

@ -0,0 +1,22 @@
{
"type": "object",
"properties": {
"amazingField": {
"type": "string"
},
"org": {
"type": "string"
},
"tables": {
"items": {
"$ref": "#/components/schemas/NestedComplexItem"
},
"type": "array"
}
},
"required": [
"amazingField",
"org",
"tables"
]
}

View File

@ -0,0 +1,23 @@
{
"oneOf": [
{
"type": "null"
},
{
"type": "object",
"properties": {
"a": {
"type": "string"
},
"b": {
"type": "number",
"format": "int32"
}
},
"required": [
"a",
"b"
]
}
]
}

View File

@ -0,0 +1,3 @@
{
"enum": [ "ONE", "TWO" ]
}

View File

@ -0,0 +1,13 @@
{
"oneOf": [
{
"type": "null"
},
{
"enum": [
"ONE",
"TWO"
]
}
]
}

View File

@ -0,0 +1,7 @@
{
"items": {
"type": "number",
"format": "int32"
},
"type": "array"
}

View File

@ -0,0 +1,6 @@
{
"items": {
"$ref": "#/components/schemas/TestResponse"
},
"type": "array"
}

View File

@ -0,0 +1,14 @@
{
"oneOf": [
{
"type": "null"
},
{
"items": {
"type": "number",
"format": "int32"
},
"type": "array"
}
]
}

View File

@ -0,0 +1,7 @@
{
"additionalProperties": {
"type": "number",
"format": "int32"
},
"type": "object"
}

View File

@ -0,0 +1,6 @@
{
"additionalProperties": {
"$ref": "#/components/schemas/TestResponse"
},
"type": "object"
}

View File

@ -0,0 +1,14 @@
{
"oneOf": [
{
"type": "null"
},
{
"additionalProperties": {
"type": "number",
"format": "int32"
},
"type": "object"
}
]
}

View File

@ -0,0 +1,10 @@
{
"anyOf": [
{
"$ref": "#/components/schemas/ComplexGibbit"
},
{
"$ref": "#/components/schemas/SimpleGibbit"
}
]
}

View File

@ -0,0 +1,13 @@
{
"anyOf": [
{
"$ref": "#/components/schemas/OneJamma"
},
{
"$ref": "#/components/schemas/AnothaJamma"
},
{
"$ref": "#/components/schemas/InsaneJamma"
}
]
}