feat: schema configurator to enable field name overrides and transient field omission (#302)

This commit is contained in:
Stuart Campbell
2022-08-23 13:02:50 +01:00
committed by GitHub
parent bf31dd213c
commit 07bdeda364
28 changed files with 581 additions and 85 deletions

View File

@ -2,6 +2,10 @@
## Unreleased
- Support for @Transient annotation
- Support for @SerialName annotation on fields
- Supports for un-backed fields, by excluding them from the generated schema.
### Added
### Changed

View File

@ -1,8 +1,10 @@
package io.bkbn.kompendium.core.attribute
import io.bkbn.kompendium.json.schema.SchemaConfigurator
import io.bkbn.kompendium.oas.OpenApiSpec
import io.ktor.util.AttributeKey
object KompendiumAttributes {
val openApiSpec = AttributeKey<OpenApiSpec>("OpenApiSpec")
val schemaConfigurator = AttributeKey<SchemaConfigurator>("SchemaConfigurator")
}

View File

@ -1,6 +1,7 @@
package io.bkbn.kompendium.core.plugin
import io.bkbn.kompendium.core.attribute.KompendiumAttributes
import io.bkbn.kompendium.json.schema.SchemaConfigurator
import io.bkbn.kompendium.json.schema.definition.JsonSchema
import io.bkbn.kompendium.json.schema.util.Helpers.getSimpleSlug
import io.bkbn.kompendium.oas.OpenApiSpec
@ -13,7 +14,6 @@ import io.ktor.server.routing.application
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import kotlin.reflect.KClass
import kotlin.reflect.KType
object NotarizedApplication {
@ -28,6 +28,7 @@ object NotarizedApplication {
}
}
var customTypes: Map<KType, JsonSchema> = emptyMap()
var schemaConfigurator: SchemaConfigurator = SchemaConfigurator.Default()
}
operator fun invoke() = createApplicationPlugin(
@ -41,5 +42,6 @@ object NotarizedApplication {
spec.components.schemas[type.getSimpleSlug()] = schema
}
application.attributes.put(KompendiumAttributes.openApiSpec, spec)
application.attributes.put(KompendiumAttributes.schemaConfigurator, pluginConfig.schemaConfigurator)
}
}

View File

@ -63,17 +63,18 @@ object NotarizedRoute {
}
val spec = application.attributes[KompendiumAttributes.openApiSpec]
val serializableReader = application.attributes[KompendiumAttributes.schemaConfigurator]
val path = Path()
path.parameters = pluginConfig.parameters
pluginConfig.get?.addToSpec(path, spec, pluginConfig)
pluginConfig.delete?.addToSpec(path, spec, pluginConfig)
pluginConfig.head?.addToSpec(path, spec, pluginConfig)
pluginConfig.options?.addToSpec(path, spec, pluginConfig)
pluginConfig.post?.addToSpec(path, spec, pluginConfig)
pluginConfig.put?.addToSpec(path, spec, pluginConfig)
pluginConfig.patch?.addToSpec(path, spec, pluginConfig)
pluginConfig.get?.addToSpec(path, spec, pluginConfig, serializableReader)
pluginConfig.delete?.addToSpec(path, spec, pluginConfig, serializableReader)
pluginConfig.head?.addToSpec(path, spec, pluginConfig, serializableReader)
pluginConfig.options?.addToSpec(path, spec, pluginConfig, serializableReader)
pluginConfig.post?.addToSpec(path, spec, pluginConfig, serializableReader)
pluginConfig.put?.addToSpec(path, spec, pluginConfig, serializableReader)
pluginConfig.patch?.addToSpec(path, spec, pluginConfig, serializableReader)
pluginConfig.path = path
}

View File

@ -11,6 +11,7 @@ import io.bkbn.kompendium.core.metadata.PostInfo
import io.bkbn.kompendium.core.metadata.PutInfo
import io.bkbn.kompendium.core.metadata.ResponseInfo
import io.bkbn.kompendium.json.schema.SchemaGenerator
import io.bkbn.kompendium.json.schema.SchemaConfigurator
import io.bkbn.kompendium.json.schema.definition.ReferenceDefinition
import io.bkbn.kompendium.json.schema.util.Helpers.getReferenceSlug
import io.bkbn.kompendium.json.schema.util.Helpers.getSimpleSlug
@ -25,20 +26,27 @@ import kotlin.reflect.KType
object Helpers {
fun MethodInfo.addToSpec(path: Path, spec: OpenApiSpec, config: SpecConfig) {
SchemaGenerator.fromTypeOrUnit(this.response.responseType, spec.components.schemas)?.let { schema ->
fun MethodInfo.addToSpec(path: Path, spec: OpenApiSpec, config: SpecConfig, schemaConfigurator: SchemaConfigurator) {
SchemaGenerator.fromTypeOrUnit(
this.response.responseType,
spec.components.schemas, schemaConfigurator
)?.let { schema ->
spec.components.schemas[this.response.responseType.getSimpleSlug()] = schema
}
errors.forEach { error ->
SchemaGenerator.fromTypeOrUnit(error.responseType, spec.components.schemas)?.let { schema ->
SchemaGenerator.fromTypeOrUnit(error.responseType, spec.components.schemas, schemaConfigurator)?.let { schema ->
spec.components.schemas[error.responseType.getSimpleSlug()] = schema
}
}
when (this) {
is MethodInfoWithRequest -> {
SchemaGenerator.fromTypeOrUnit(this.request.requestType, spec.components.schemas)?.let { schema ->
SchemaGenerator.fromTypeOrUnit(
this.request.requestType,
spec.components.schemas,
schemaConfigurator
)?.let { schema ->
spec.components.schemas[this.request.requestType.getSimpleSlug()] = schema
}
}

View File

@ -4,6 +4,7 @@ import dev.forst.ktor.apikey.apiKey
import io.bkbn.kompendium.core.fixtures.TestHelpers.openApiTestAllSerializers
import io.bkbn.kompendium.core.util.TestModules.complexRequest
import io.bkbn.kompendium.core.util.TestModules.customAuthConfig
import io.bkbn.kompendium.core.util.TestModules.customFieldNameResponse
import io.bkbn.kompendium.core.util.TestModules.dateTimeString
import io.bkbn.kompendium.core.util.TestModules.defaultAuthConfig
import io.bkbn.kompendium.core.util.TestModules.defaultField
@ -19,6 +20,7 @@ import io.bkbn.kompendium.core.util.TestModules.genericPolymorphicResponse
import io.bkbn.kompendium.core.util.TestModules.genericPolymorphicResponseMultipleImpls
import io.bkbn.kompendium.core.util.TestModules.gnarlyGenericResponse
import io.bkbn.kompendium.core.util.TestModules.headerParameter
import io.bkbn.kompendium.core.util.TestModules.ignoredFieldsResponse
import io.bkbn.kompendium.core.util.TestModules.multipleAuthStrategies
import io.bkbn.kompendium.core.util.TestModules.multipleExceptions
import io.bkbn.kompendium.core.util.TestModules.nestedGenericCollection
@ -48,6 +50,7 @@ import io.bkbn.kompendium.core.util.TestModules.simpleGenericResponse
import io.bkbn.kompendium.core.util.TestModules.simplePathParsing
import io.bkbn.kompendium.core.util.TestModules.simpleRecursive
import io.bkbn.kompendium.core.util.TestModules.trailingSlash
import io.bkbn.kompendium.core.util.TestModules.unbackedFieldsResponse
import io.bkbn.kompendium.core.util.TestModules.withOperationId
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
import io.bkbn.kompendium.oas.component.Components
@ -192,6 +195,17 @@ class KompendiumTest : DescribeSpec({
openApiTestAllSerializers("T0043__gnarly_generic_example.json") { gnarlyGenericResponse() }
}
}
describe("Custom Serializable Reader tests") {
it("Can support ignoring fields") {
openApiTestAllSerializers("T0048__ignored_property.json") { ignoredFieldsResponse() }
}
it("Can support un-backed fields") {
openApiTestAllSerializers("T0049__unbacked_property.json") { unbackedFieldsResponse() }
}
it("Can support custom named fields") {
openApiTestAllSerializers("T0050__custom_named_property.json") { customFieldNameResponse() }
}
}
describe("Miscellaneous") {
xit("Can generate the necessary ReDoc home page") {
// TODO apiFunctionalityTest(getFileSnapshot("redoc.html"), "/docs") { returnsList() }

View File

@ -1,27 +1,6 @@
package io.bkbn.kompendium.core.util
import io.bkbn.kompendium.core.fixtures.Barzo
import io.bkbn.kompendium.core.fixtures.ColumnSchema
import io.bkbn.kompendium.core.fixtures.ComplexRequest
import io.bkbn.kompendium.core.fixtures.DateTimeString
import io.bkbn.kompendium.core.fixtures.DefaultField
import io.bkbn.kompendium.core.fixtures.ExceptionResponse
import io.bkbn.kompendium.core.fixtures.Flibbity
import io.bkbn.kompendium.core.fixtures.FlibbityGibbit
import io.bkbn.kompendium.core.fixtures.Foosy
import io.bkbn.kompendium.core.fixtures.Gibbity
import io.bkbn.kompendium.core.fixtures.ManyThings
import io.bkbn.kompendium.core.fixtures.MultiNestedGenerics
import io.bkbn.kompendium.core.fixtures.Nested
import io.bkbn.kompendium.core.fixtures.NullableEnum
import io.bkbn.kompendium.core.fixtures.NullableField
import io.bkbn.kompendium.core.fixtures.Page
import io.bkbn.kompendium.core.fixtures.ProfileUpdateRequest
import io.bkbn.kompendium.core.fixtures.TestCreatedResponse
import io.bkbn.kompendium.core.fixtures.TestNested
import io.bkbn.kompendium.core.fixtures.TestRequest
import io.bkbn.kompendium.core.fixtures.TestResponse
import io.bkbn.kompendium.core.fixtures.TestSimpleRequest
import io.bkbn.kompendium.core.fixtures.*
import io.bkbn.kompendium.core.metadata.DeleteInfo
import io.bkbn.kompendium.core.metadata.GetInfo
import io.bkbn.kompendium.core.metadata.HeadInfo
@ -563,6 +542,12 @@ object TestModules {
fun Routing.polymorphicResponse() = basicGetGenerator<FlibbityGibbit>()
fun Routing.ignoredFieldsResponse() = basicGetGenerator<TransientObject>()
fun Routing.unbackedFieldsResponse() = basicGetGenerator<UnbakcedObject>()
fun Routing.customFieldNameResponse() = basicGetGenerator<SerialNameObject>()
fun Routing.polymorphicCollectionResponse() = basicGetGenerator<List<FlibbityGibbit>>()
fun Routing.polymorphicMapResponse() = basicGetGenerator<Map<String, FlibbityGibbit>>()

View File

@ -0,0 +1,72 @@
{
"openapi": "3.1.0",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "Test API",
"version": "1.33.7",
"description": "An amazing, fully-ish 😉 generated API spec",
"termsOfService": "https://example.com",
"contact": {
"name": "Homer Simpson",
"url": "https://gph.is/1NPUDiM",
"email": "chunkylover53@aol.com"
},
"license": {
"name": "MIT",
"url": "https://github.com/bkbnio/kompendium/blob/main/LICENSE"
}
},
"servers": [
{
"url": "https://myawesomeapi.com",
"description": "Production instance of my API"
},
{
"url": "https://staging.myawesomeapi.com",
"description": "Where the fun stuff happens"
}
],
"paths": {
"/": {
"get": {
"tags": [],
"summary": "Great Summary!",
"description": "testing more",
"parameters": [],
"responses": {
"200": {
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TransientObject"
}
}
}
}
},
"deprecated": false
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"TransientObject": {
"type": "object",
"properties": {
"nonTransient": {
"type": "string"
}
},
"required": [
"nonTransient"
]
}
},
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -0,0 +1,72 @@
{
"openapi": "3.1.0",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "Test API",
"version": "1.33.7",
"description": "An amazing, fully-ish 😉 generated API spec",
"termsOfService": "https://example.com",
"contact": {
"name": "Homer Simpson",
"url": "https://gph.is/1NPUDiM",
"email": "chunkylover53@aol.com"
},
"license": {
"name": "MIT",
"url": "https://github.com/bkbnio/kompendium/blob/main/LICENSE"
}
},
"servers": [
{
"url": "https://myawesomeapi.com",
"description": "Production instance of my API"
},
{
"url": "https://staging.myawesomeapi.com",
"description": "Where the fun stuff happens"
}
],
"paths": {
"/": {
"get": {
"tags": [],
"summary": "Great Summary!",
"description": "testing more",
"parameters": [],
"responses": {
"200": {
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnbakcedObject"
}
}
}
}
},
"deprecated": false
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"UnbakcedObject": {
"type": "object",
"properties": {
"backed": {
"type": "string"
}
},
"required": [
"backed"
]
}
},
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -0,0 +1,72 @@
{
"openapi": "3.1.0",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "Test API",
"version": "1.33.7",
"description": "An amazing, fully-ish 😉 generated API spec",
"termsOfService": "https://example.com",
"contact": {
"name": "Homer Simpson",
"url": "https://gph.is/1NPUDiM",
"email": "chunkylover53@aol.com"
},
"license": {
"name": "MIT",
"url": "https://github.com/bkbnio/kompendium/blob/main/LICENSE"
}
},
"servers": [
{
"url": "https://myawesomeapi.com",
"description": "Production instance of my API"
},
{
"url": "https://staging.myawesomeapi.com",
"description": "Where the fun stuff happens"
}
],
"paths": {
"/": {
"get": {
"tags": [],
"summary": "Great Summary!",
"description": "testing more",
"parameters": [],
"responses": {
"200": {
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SerialNameObject"
}
}
}
}
},
"deprecated": false
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"SerialNameObject": {
"type": "object",
"properties": {
"snake_case_name": {
"type": "string"
}
},
"required": [
"snake_case_name"
]
}
},
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -0,0 +1,54 @@
package io.bkbn.kompendium.core.fixtures
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonProperty
import com.google.gson.annotations.Expose
import com.google.gson.annotations.SerializedName
import io.bkbn.kompendium.json.schema.SchemaConfigurator
import kotlin.reflect.KClass
import kotlin.reflect.KProperty1
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.javaField
/*
These are test implementation and may well be a good starting point for creating production ones.
Both Gson and Jackson are complex and can achieve this things is more than one way therefore
these will not always work hence why they are in the test package
*/
class GsonSchemaConfigurator: SchemaConfigurator {
override fun serializableMemberProperties(clazz: KClass<*>): Collection<KProperty1<out Any, *>> {
// NOTE: This is test logic Expose is set at a global Gson level so configure to match your Gson set up
val hasAnyExpose = clazz.memberProperties.any { it.hasJavaAnnotation<Expose>() }
return if(hasAnyExpose) {
clazz.memberProperties
.filter { it.hasJavaAnnotation<Expose>() }
} else clazz.memberProperties
}
override fun serializableName(property: KProperty1<out Any, *>): String =
property.getJavaAnnotation<SerializedName>()?.value?: property.name
}
class JacksonSchemaConfigurator: SchemaConfigurator {
override fun serializableMemberProperties(clazz: KClass<*>): Collection<KProperty1<out Any, *>> =
clazz.memberProperties
.filterNot {
it.hasJavaAnnotation<JsonIgnore>()
}
override fun serializableName(property: KProperty1<out Any, *>): String =
property.getJavaAnnotation<JsonProperty>()?.value?: property.name
}
inline fun <reified T: Annotation> KProperty1<*, *>.hasJavaAnnotation(): Boolean {
return javaField?.isAnnotationPresent(T::class.java)?: false
}
inline fun <reified T: Annotation> KProperty1<*, *>.getJavaAnnotation(): T? {
return javaField?.getDeclaredAnnotation(T::class.java)
}

View File

@ -5,13 +5,10 @@ import com.fasterxml.jackson.databind.SerializationFeature
import io.bkbn.kompendium.core.fixtures.TestSpecs.defaultSpec
import io.bkbn.kompendium.core.plugin.NotarizedApplication
import io.bkbn.kompendium.core.routes.redoc
import io.bkbn.kompendium.json.schema.KotlinXSchemaConfigurator
import io.bkbn.kompendium.json.schema.definition.JsonSchema
import io.bkbn.kompendium.oas.OpenApiSpec
import io.bkbn.kompendium.oas.info.Contact
import io.bkbn.kompendium.oas.info.Info
import io.bkbn.kompendium.oas.info.License
import io.bkbn.kompendium.oas.serialization.KompendiumSerializersModule
import io.bkbn.kompendium.oas.server.Server
import io.kotest.assertions.json.shouldEqualJson
import io.kotest.assertions.ktor.client.shouldHaveStatus
import io.kotest.matchers.shouldNot
@ -31,7 +28,6 @@ import io.ktor.server.testing.testApplication
import kotlin.reflect.KType
import kotlinx.serialization.json.Json
import java.io.File
import java.net.URI
object TestHelpers {
private const val OPEN_API_ENDPOINT = "/openapi.json"
@ -83,6 +79,11 @@ object TestHelpers {
install(NotarizedApplication()) {
customTypes = typeOverrides
spec = defaultSpec().specOverrides()
schemaConfigurator = when (serializer) {
SupportedSerializer.KOTLINX -> KotlinXSchemaConfigurator()
SupportedSerializer.GSON -> GsonSchemaConfigurator()
SupportedSerializer.JACKSON -> JacksonSchemaConfigurator()
}
}
install(ContentNegotiation) {
when (serializer) {

View File

@ -1,6 +1,12 @@
package io.bkbn.kompendium.core.fixtures
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonProperty
import com.google.gson.annotations.Expose
import com.google.gson.annotations.SerializedName
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.time.Instant
@Serializable
@ -146,3 +152,27 @@ object Nested {
@Serializable
data class Response(val idk: Boolean)
}
@Serializable
data class TransientObject(
@field:Expose
val nonTransient: String,
@field:JsonIgnore
@Transient
val transient: String = "transient"
)
@Serializable
data class UnbakcedObject(
val backed: String
) {
val unbacked: String get() = "unbacked"
}
@Serializable
data class SerialNameObject(
@field:JsonProperty("snake_case_name")
@field:SerializedName("snake_case_name")
@SerialName("snake_case_name")
val camelCaseName: String
)

View File

@ -0,0 +1,20 @@
package io.bkbn.kompendium.json.schema
import kotlinx.serialization.SerialName
import kotlinx.serialization.Transient
import kotlin.reflect.KClass
import kotlin.reflect.KProperty1
import kotlin.reflect.full.hasAnnotation
import kotlin.reflect.full.memberProperties
class KotlinXSchemaConfigurator: SchemaConfigurator {
override fun serializableMemberProperties(clazz: KClass<*>): Collection<KProperty1<out Any, *>> =
clazz.memberProperties
.filterNot { it.hasAnnotation<Transient>() }
override fun serializableName(property: KProperty1<out Any, *>): String =
property.annotations
.filterIsInstance<SerialName>()
.firstOrNull()?.value?: property.name
}

View File

@ -0,0 +1,18 @@
package io.bkbn.kompendium.json.schema
import kotlin.reflect.KClass
import kotlin.reflect.KProperty1
import kotlin.reflect.full.memberProperties
interface SchemaConfigurator {
fun serializableMemberProperties(clazz: KClass<*>): Collection<KProperty1<out Any, *>>
fun serializableName(property: KProperty1<out Any, *>): String
open class Default: SchemaConfigurator {
override fun serializableMemberProperties(clazz: KClass<*>): Collection<KProperty1<out Any, *>>
= clazz.memberProperties
override fun serializableName(property: KProperty1<out Any, *>): String
= property.name
}
}

View File

@ -17,10 +17,17 @@ import kotlin.reflect.typeOf
import java.util.UUID
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 {
inline fun <reified T : Any?> fromTypeToSchema(
cache: MutableMap<String, JsonSchema> = mutableMapOf(),
schemaConfigurator: SchemaConfigurator = SchemaConfigurator.Default()
) = fromTypeToSchema(typeOf<T>(), cache, schemaConfigurator)
fun fromTypeToSchema(
type: KType,
cache: MutableMap<String, JsonSchema>,
schemaConfigurator: SchemaConfigurator
): JsonSchema {
cache[type.getSimpleSlug()]?.let {
return it
}
@ -41,26 +48,30 @@ object SchemaGenerator {
UUID::class -> checkForNull(type, TypeDefinition.UUID)
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)
clazz.isSubclassOf(Collection::class) -> CollectionHandler.handle(type, cache, schemaConfigurator)
clazz.isSubclassOf(Map::class) -> MapHandler.handle(type, cache, schemaConfigurator)
else -> {
if (clazz.isSealed) {
SealedObjectHandler.handle(type, clazz, cache)
SealedObjectHandler.handle(type, clazz, cache, schemaConfigurator)
} else {
SimpleObjectHandler.handle(type, clazz, cache)
SimpleObjectHandler.handle(type, clazz, cache, schemaConfigurator)
}
}
}
}
}
fun fromTypeOrUnit(type: KType, cache: MutableMap<String, JsonSchema> = mutableMapOf()): JsonSchema? =
fun fromTypeOrUnit(
type: KType,
cache: MutableMap<String, JsonSchema> = mutableMapOf(),
schemaConfigurator: SchemaConfigurator
): JsonSchema? =
when (type.classifier as KClass<*>) {
Unit::class -> null
else -> fromTypeToSchema(type, cache)
else -> fromTypeToSchema(type, cache, schemaConfigurator)
}
private fun checkForNull(type: KType, schema: JsonSchema): JsonSchema = when (type.isMarkedNullable) {
private fun checkForNull(type: KType, schema: JsonSchema, ): JsonSchema = when (type.isMarkedNullable) {
true -> OneOfDefinition(NullableDefinition(), schema)
false -> schema
}

View File

@ -1,6 +1,7 @@
package io.bkbn.kompendium.json.schema.handler
import io.bkbn.kompendium.json.schema.SchemaGenerator
import io.bkbn.kompendium.json.schema.SchemaConfigurator
import io.bkbn.kompendium.json.schema.definition.ArrayDefinition
import io.bkbn.kompendium.json.schema.definition.JsonSchema
import io.bkbn.kompendium.json.schema.definition.NullableDefinition
@ -13,10 +14,10 @@ import kotlin.reflect.KType
object CollectionHandler {
fun handle(type: KType, cache: MutableMap<String, JsonSchema>): JsonSchema {
fun handle(type: KType, cache: MutableMap<String, JsonSchema>, schemaConfigurator: SchemaConfigurator): JsonSchema {
val collectionType = type.arguments.first().type
?: error("This indicates a bug in Kompendium, please open a GitHub issue!")
val typeSchema = SchemaGenerator.fromTypeToSchema(collectionType, cache).let {
val typeSchema = SchemaGenerator.fromTypeToSchema(collectionType, cache, schemaConfigurator).let {
if (it is TypeDefinition && it.type == "object") {
cache[collectionType.getSimpleSlug()] = it
ReferenceDefinition(collectionType.getReferenceSlug())

View File

@ -1,6 +1,7 @@
package io.bkbn.kompendium.json.schema.handler
import io.bkbn.kompendium.json.schema.SchemaGenerator
import io.bkbn.kompendium.json.schema.SchemaConfigurator
import io.bkbn.kompendium.json.schema.definition.JsonSchema
import io.bkbn.kompendium.json.schema.definition.MapDefinition
import io.bkbn.kompendium.json.schema.definition.NullableDefinition
@ -14,12 +15,12 @@ import kotlin.reflect.KType
object MapHandler {
fun handle(type: KType, cache: MutableMap<String, JsonSchema>): JsonSchema {
fun handle(type: KType, cache: MutableMap<String, JsonSchema>, schemaConfigurator: SchemaConfigurator): 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 ?: error("this indicates a bug in Kompendium, please open a GitHub issue")
val valueSchema = SchemaGenerator.fromTypeToSchema(valueType, cache).let {
val valueSchema = SchemaGenerator.fromTypeToSchema(valueType, cache, schemaConfigurator).let {
if (it is TypeDefinition && it.type == "object") {
cache[valueType.getSimpleSlug()] = it
ReferenceDefinition(valueType.getReferenceSlug())

View File

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

View File

@ -1,6 +1,7 @@
package io.bkbn.kompendium.json.schema.handler
import io.bkbn.kompendium.json.schema.SchemaGenerator
import io.bkbn.kompendium.json.schema.SchemaConfigurator
import io.bkbn.kompendium.json.schema.definition.JsonSchema
import io.bkbn.kompendium.json.schema.definition.NullableDefinition
import io.bkbn.kompendium.json.schema.definition.OneOfDefinition
@ -14,22 +15,29 @@ import kotlin.reflect.KType
import kotlin.reflect.KTypeParameter
import kotlin.reflect.KTypeProjection
import kotlin.reflect.full.createType
import kotlin.reflect.full.memberProperties
import kotlin.reflect.full.primaryConstructor
import kotlin.reflect.jvm.javaField
object SimpleObjectHandler {
fun handle(type: KType, clazz: KClass<*>, cache: MutableMap<String, JsonSchema>): JsonSchema {
fun handle(
type: KType,
clazz: KClass<*>,
cache: MutableMap<String, JsonSchema>,
schemaConfigurator: SchemaConfigurator
): JsonSchema {
cache[type.getSimpleSlug()] = ReferenceDefinition(type.getReferenceSlug())
val typeMap = clazz.typeParameters.zip(type.arguments).toMap()
val props = clazz.memberProperties.associate { prop ->
val props = schemaConfigurator.serializableMemberProperties(clazz)
.filterNot { it.javaField == null }
.associate { prop ->
val schema = when (prop.needsToInjectGenerics(typeMap)) {
true -> handleNestedGenerics(typeMap, prop, cache)
true -> handleNestedGenerics(typeMap, prop, cache, schemaConfigurator)
false -> when (typeMap.containsKey(prop.returnType.classifier)) {
true -> handleGenericProperty(prop, typeMap, cache)
false -> handleProperty(prop, cache)
true -> handleGenericProperty(prop, typeMap, cache, schemaConfigurator)
false -> handleProperty(prop, cache, schemaConfigurator)
}
}
@ -38,15 +46,17 @@ object SimpleObjectHandler {
false -> schema
}
prop.name to nullCheckSchema
schemaConfigurator.serializableName(prop) to nullCheckSchema
}
val required = clazz.memberProperties.filterNot { prop -> prop.returnType.isMarkedNullable }
val required = schemaConfigurator.serializableMemberProperties(clazz)
.asSequence()
.filterNot { it.javaField == null }
.filterNot { prop -> prop.returnType.isMarkedNullable }
.filterNot { prop -> clazz.primaryConstructor!!.parameters.find { it.name == prop.name }!!.isOptional }
.map { it.name }
.map { schemaConfigurator.serializableName(it) }
.toSet()
val definition = TypeDefinition(
type = "object",
properties = props,
@ -69,7 +79,8 @@ object SimpleObjectHandler {
private fun handleNestedGenerics(
typeMap: Map<KTypeParameter, KTypeProjection>,
prop: KProperty<*>,
cache: MutableMap<String, JsonSchema>
cache: MutableMap<String, JsonSchema>,
schemaConfigurator: SchemaConfigurator
): JsonSchema {
val propClass = prop.returnType.classifier as KClass<*>
val types = prop.returnType.arguments.map {
@ -77,7 +88,7 @@ object SimpleObjectHandler {
typeMap.filterKeys { k -> k.name == typeSymbol }.values.first()
}
val constructedType = propClass.createType(types)
return SchemaGenerator.fromTypeToSchema(constructedType, cache).let {
return SchemaGenerator.fromTypeToSchema(constructedType, cache, schemaConfigurator).let {
if (it.isOrContainsObjectDef()) {
cache[constructedType.getSimpleSlug()] = it
ReferenceDefinition(prop.returnType.getReferenceSlug())
@ -90,11 +101,12 @@ object SimpleObjectHandler {
private fun handleGenericProperty(
prop: KProperty<*>,
typeMap: Map<KTypeParameter, KTypeProjection>,
cache: MutableMap<String, JsonSchema>
cache: MutableMap<String, JsonSchema>,
schemaConfigurator: SchemaConfigurator
): JsonSchema {
val type = typeMap[prop.returnType.classifier]?.type
?: error("This indicates a bug in Kompendium, please open a GitHub issue")
return SchemaGenerator.fromTypeToSchema(type, cache).let {
return SchemaGenerator.fromTypeToSchema(type, cache, schemaConfigurator).let {
if (it.isOrContainsObjectDef()) {
cache[type.getSimpleSlug()] = it
ReferenceDefinition(type.getReferenceSlug())
@ -104,8 +116,12 @@ object SimpleObjectHandler {
}
}
private fun handleProperty(prop: KProperty<*>, cache: MutableMap<String, JsonSchema>): JsonSchema =
SchemaGenerator.fromTypeToSchema(prop.returnType, cache).let {
private fun handleProperty(
prop: KProperty<*>,
cache: MutableMap<String, JsonSchema>,
schemaConfigurator: SchemaConfigurator
): JsonSchema =
SchemaGenerator.fromTypeToSchema(prop.returnType, cache, schemaConfigurator).let {
if (it.isOrContainsObjectDef()) {
cache[prop.returnType.getSimpleSlug()] = it
ReferenceDefinition(prop.returnType.getReferenceSlug())

View File

@ -1,12 +1,7 @@
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.*
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
@ -45,6 +40,15 @@ class SchemaGeneratorTest : DescribeSpec({
xit("Can generate the schema for a recursive type") {
// TODO jsonSchemaTest<SlammaJamma>("T0016__recursive_object.json")
}
it("Can generate the schema for object with transient property") {
jsonSchemaTest<TransientObject>("T0018__transient_object.json")
}
it("Can generate the schema for object with unbacked property") {
jsonSchemaTest<UnbakcedObject>("T0019__unbacked_object.json")
}
it("Can generate the schema for object with SerialName annotation") {
jsonSchemaTest<SerialNameObject>("T0020__serial_name_object.json")
}
}
describe("Enums") {
it("Can generate the schema for a simple enum") {
@ -91,7 +95,7 @@ class SchemaGeneratorTest : DescribeSpec({
private inline fun <reified T> jsonSchemaTest(snapshotName: String) {
// act
val schema = SchemaGenerator.fromTypeToSchema<T>()
val schema = SchemaGenerator.fromTypeToSchema<T>(schemaConfigurator = KotlinXSchemaConfigurator())
// todo add cache assertions!!!

View File

@ -0,0 +1,11 @@
{
"type": "object",
"properties": {
"nonTransient": {
"type": "string"
}
},
"required": [
"nonTransient"
]
}

View File

@ -0,0 +1,11 @@
{
"type": "object",
"properties": {
"backed": {
"type": "string"
}
},
"required": [
"backed"
]
}

View File

@ -0,0 +1,12 @@
{
"type": "object",
"properties": {
"snake_case_name": {
"type": "string"
}
},
"required": [
"snake_case_name"
]
}

View File

@ -44,16 +44,17 @@ object NotarizedLocations {
createConfiguration = ::Config
) {
val spec = application.attributes[KompendiumAttributes.openApiSpec]
val serializableReader = application.attributes[KompendiumAttributes.schemaConfigurator]
pluginConfig.locations.forEach { (k, v) ->
val path = Path()
path.parameters = v.parameters
v.get?.addToSpec(path, spec, v)
v.delete?.addToSpec(path, spec, v)
v.head?.addToSpec(path, spec, v)
v.options?.addToSpec(path, spec, v)
v.post?.addToSpec(path, spec, v)
v.put?.addToSpec(path, spec, v)
v.patch?.addToSpec(path, spec, v)
v.get?.addToSpec(path, spec, v, serializableReader)
v.delete?.addToSpec(path, spec, v, serializableReader)
v.head?.addToSpec(path, spec, v, serializableReader)
v.options?.addToSpec(path, spec, v, serializableReader)
v.post?.addToSpec(path, spec, v, serializableReader)
v.put?.addToSpec(path, spec, v, serializableReader)
v.patch?.addToSpec(path, spec, v, serializableReader)
val location = k.getLocationFromClass()
spec.paths[location] = path

View File

@ -4,6 +4,7 @@ import io.bkbn.kompendium.core.metadata.GetInfo
import io.bkbn.kompendium.core.plugin.NotarizedApplication
import io.bkbn.kompendium.core.plugin.NotarizedRoute
import io.bkbn.kompendium.core.routes.redoc
import io.bkbn.kompendium.json.schema.KotlinXSchemaConfigurator
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
import io.bkbn.kompendium.oas.payload.Parameter
import io.bkbn.kompendium.oas.serialization.KompendiumSerializersModule
@ -42,6 +43,9 @@ private fun Application.mainModule() {
}
install(NotarizedApplication()) {
spec = baseSpec
// Adds support for @Transient and @SerialName
// If you are not using them this is not required.
schemaConfigurator = KotlinXSchemaConfigurator()
}
routing {
redoc(pageTitle = "Simple API Docs")

View File

@ -1,9 +1,12 @@
package io.bkbn.kompendium.playground
import com.google.gson.annotations.Expose
import com.google.gson.annotations.SerializedName
import io.bkbn.kompendium.core.metadata.GetInfo
import io.bkbn.kompendium.core.plugin.NotarizedApplication
import io.bkbn.kompendium.core.plugin.NotarizedRoute
import io.bkbn.kompendium.core.routes.redoc
import io.bkbn.kompendium.json.schema.SchemaConfigurator
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
import io.bkbn.kompendium.oas.payload.Parameter
import io.bkbn.kompendium.playground.util.ExampleResponse
@ -21,6 +24,10 @@ import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import kotlin.reflect.KClass
import kotlin.reflect.KProperty1
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.javaField
fun main() {
embeddedServer(
@ -38,6 +45,7 @@ private fun Application.mainModule() {
}
install(NotarizedApplication()) {
spec = baseSpec
schemaConfigurator = GsonSchemaConfigurator()
}
routing {
redoc(pageTitle = "Simple API Docs")
@ -71,3 +79,27 @@ private fun Route.locationDocumentation() {
}
}
}
// Adds support for Expose and SerializedName annotations,
// if you are not using them this is not required
class GsonSchemaConfigurator(
private val excludeFieldsWithoutExposeAnnotation: Boolean = false
): SchemaConfigurator.Default() {
override fun serializableMemberProperties(clazz: KClass<*>): Collection<KProperty1<out Any, *>> {
return if(excludeFieldsWithoutExposeAnnotation) clazz.memberProperties
.filter { it.hasJavaAnnotation<Expose>() }
else clazz.memberProperties
}
override fun serializableName(property: KProperty1<out Any, *>): String =
property.getJavaAnnotation<SerializedName>()?.value?: property.name
}
private inline fun <reified T: Annotation> KProperty1<*, *>.hasJavaAnnotation(): Boolean {
return javaField?.isAnnotationPresent(T::class.java)?: false
}
private inline fun <reified T: Annotation> KProperty1<*, *>.getJavaAnnotation(): T? {
return javaField?.getDeclaredAnnotation(T::class.java)
}

View File

@ -1,11 +1,14 @@
package io.bkbn.kompendium.playground
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.SerializationFeature
import io.bkbn.kompendium.core.metadata.GetInfo
import io.bkbn.kompendium.core.plugin.NotarizedApplication
import io.bkbn.kompendium.core.plugin.NotarizedRoute
import io.bkbn.kompendium.core.routes.redoc
import io.bkbn.kompendium.json.schema.SchemaConfigurator
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
import io.bkbn.kompendium.oas.payload.Parameter
import io.bkbn.kompendium.playground.util.ExampleResponse
@ -23,6 +26,10 @@ import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import kotlin.reflect.KClass
import kotlin.reflect.KProperty1
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.javaField
fun main() {
embeddedServer(
@ -41,6 +48,7 @@ private fun Application.mainModule() {
}
install(NotarizedApplication()) {
spec = baseSpec
schemaConfigurator = JacksonSchemaConfigurator()
}
routing {
redoc(pageTitle = "Simple API Docs")
@ -75,3 +83,26 @@ private fun Route.locationDocumentation() {
}
}
// Adds support for JsonIgnore and JsonProperty annotations,
// if you are not using them this is not required
// This also does not support class level configuration
private class JacksonSchemaConfigurator: SchemaConfigurator.Default() {
override fun serializableMemberProperties(clazz: KClass<*>): Collection<KProperty1<out Any, *>> =
clazz.memberProperties
.filterNot {
it.hasJavaAnnotation<JsonIgnore>()
}
override fun serializableName(property: KProperty1<out Any, *>): String =
property.getJavaAnnotation<JsonProperty>()?.value?: property.name
}
private inline fun <reified T: Annotation> KProperty1<*, *>.hasJavaAnnotation(): Boolean {
return javaField?.isAnnotationPresent(T::class.java)?: false
}
private inline fun <reified T: Annotation> KProperty1<*, *>.getJavaAnnotation(): T? {
return javaField?.getDeclaredAnnotation(T::class.java)
}