serializing for complex types (#10)

This commit is contained in:
Ryan Brink
2021-04-14 15:58:22 -04:00
committed by GitHub
parent c9de96cf86
commit 6eebaf15ea
9 changed files with 332 additions and 61 deletions

View File

@ -1,3 +1,12 @@
# Changelog
## [0.0.6] - April 15th, 2021
### Added
- Logging to get a more intuitive sense for operations performed
- Serialization for Maps, Collections and Enums
## [0.0.5] - April 15th, 2021 ## [0.0.5] - April 15th, 2021
### Added ### Added

View File

@ -1,5 +1,5 @@
# Kompendium # Kompendium
project.version=0.0.5 project.version=0.0.6
# Kotlin # Kotlin
kotlin.code.style=official kotlin.code.style=official
# Gradle # Gradle

View File

@ -5,21 +5,11 @@ import io.ktor.http.HttpMethod
import io.ktor.routing.Route import io.ktor.routing.Route
import io.ktor.routing.method import io.ktor.routing.method
import io.ktor.util.pipeline.PipelineInterceptor import io.ktor.util.pipeline.PipelineInterceptor
import java.lang.reflect.ParameterizedType
import kotlin.reflect.KClass
import kotlin.reflect.KProperty
import kotlin.reflect.full.findAnnotation import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.javaField
import org.leafygreens.kompendium.annotations.KompendiumField
import org.leafygreens.kompendium.annotations.KompendiumRequest import org.leafygreens.kompendium.annotations.KompendiumRequest
import org.leafygreens.kompendium.annotations.KompendiumResponse import org.leafygreens.kompendium.annotations.KompendiumResponse
import org.leafygreens.kompendium.models.meta.MethodInfo import org.leafygreens.kompendium.models.meta.MethodInfo
import org.leafygreens.kompendium.models.oas.ArraySchema
import org.leafygreens.kompendium.models.oas.FormatSchema
import org.leafygreens.kompendium.models.oas.ObjectSchema
import org.leafygreens.kompendium.models.oas.OpenApiSpec import org.leafygreens.kompendium.models.oas.OpenApiSpec
import org.leafygreens.kompendium.models.oas.OpenApiSpecComponentSchema
import org.leafygreens.kompendium.models.oas.OpenApiSpecInfo import org.leafygreens.kompendium.models.oas.OpenApiSpecInfo
import org.leafygreens.kompendium.models.oas.OpenApiSpecMediaType import org.leafygreens.kompendium.models.oas.OpenApiSpecMediaType
import org.leafygreens.kompendium.models.oas.OpenApiSpecPathItem import org.leafygreens.kompendium.models.oas.OpenApiSpecPathItem
@ -27,8 +17,8 @@ import org.leafygreens.kompendium.models.oas.OpenApiSpecPathItemOperation
import org.leafygreens.kompendium.models.oas.OpenApiSpecReferenceObject import org.leafygreens.kompendium.models.oas.OpenApiSpecReferenceObject
import org.leafygreens.kompendium.models.oas.OpenApiSpecRequest import org.leafygreens.kompendium.models.oas.OpenApiSpecRequest
import org.leafygreens.kompendium.models.oas.OpenApiSpecResponse import org.leafygreens.kompendium.models.oas.OpenApiSpecResponse
import org.leafygreens.kompendium.models.oas.SimpleSchema
import org.leafygreens.kompendium.util.Helpers.calculatePath import org.leafygreens.kompendium.util.Helpers.calculatePath
import org.leafygreens.kompendium.util.Helpers.objectSchemaPair
import org.leafygreens.kompendium.util.Helpers.putPairIfAbsent import org.leafygreens.kompendium.util.Helpers.putPairIfAbsent
object Kompendium { object Kompendium {
@ -130,45 +120,6 @@ object Kompendium {
} }
} }
// TODO Investigate a caching mechanism to reduce overhead... then just reference once created
fun objectSchemaPair(clazz: KClass<*>): Pair<String, ObjectSchema> {
val o = objectSchema(clazz)
return Pair(clazz.simpleName!!, o)
}
private fun objectSchema(clazz: KClass<*>): ObjectSchema =
ObjectSchema(properties = clazz.memberProperties.associate { prop ->
val field = prop.javaField?.type?.kotlin
val anny = prop.findAnnotation<KompendiumField>()
val schema = when (field) {
List::class -> listFieldSchema(prop)
else -> fieldToSchema(field as KClass<*>)
}
val name = anny?.let {
anny.name
} ?: prop.name
Pair(name, schema)
})
private fun listFieldSchema(prop: KProperty<*>): ArraySchema {
val listType = ((prop.javaField?.genericType
as ParameterizedType).actualTypeArguments.first()
as Class<*>).kotlin
return ArraySchema(fieldToSchema(listType))
}
private fun fieldToSchema(field: KClass<*>): OpenApiSpecComponentSchema = when (field) {
Int::class -> FormatSchema("int32", "integer")
Long::class -> FormatSchema("int64", "integer")
Double::class -> FormatSchema("double", "number")
Float::class -> FormatSchema("float", "number")
String::class -> SimpleSchema("string")
Boolean::class -> SimpleSchema("boolean")
else -> objectSchema(field)
}
internal fun resetSchema() { internal fun resetSchema() {
openApiSpec = OpenApiSpec( openApiSpec = OpenApiSpec(
info = OpenApiSpecInfo(), info = OpenApiSpecInfo(),

View File

@ -7,6 +7,14 @@ data class ObjectSchema(
val properties: Map<String, OpenApiSpecComponentSchema> val properties: Map<String, OpenApiSpecComponentSchema>
) : OpenApiSpecComponentSchema("object") ) : OpenApiSpecComponentSchema("object")
data class DictionarySchema(
val additionalProperties: OpenApiSpecComponentSchema
) : OpenApiSpecComponentSchema("object")
data class EnumSchema(
val `enum`: Set<String>
) : OpenApiSpecComponentSchema("string")
data class SimpleSchema(override val type: String) : OpenApiSpecComponentSchema(type) data class SimpleSchema(override val type: String) : OpenApiSpecComponentSchema(type)
data class FormatSchema(val format: String, override val type: String) : OpenApiSpecComponentSchema(type) data class FormatSchema(val format: String, override val type: String) : OpenApiSpecComponentSchema(type)

View File

@ -5,21 +5,145 @@ import io.ktor.routing.PathSegmentParameterRouteSelector
import io.ktor.routing.RootRouteSelector import io.ktor.routing.RootRouteSelector
import io.ktor.routing.Route import io.ktor.routing.Route
import io.ktor.util.InternalAPI import io.ktor.util.InternalAPI
import java.lang.reflect.ParameterizedType
import kotlin.reflect.KClass
import kotlin.reflect.KProperty
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.isSubclassOf
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.javaField
import org.leafygreens.kompendium.annotations.KompendiumField
import org.leafygreens.kompendium.models.oas.ArraySchema
import org.leafygreens.kompendium.models.oas.DictionarySchema
import org.leafygreens.kompendium.models.oas.EnumSchema
import org.leafygreens.kompendium.models.oas.FormatSchema
import org.leafygreens.kompendium.models.oas.ObjectSchema
import org.leafygreens.kompendium.models.oas.OpenApiSpecComponentSchema
import org.leafygreens.kompendium.models.oas.SimpleSchema
import org.slf4j.LoggerFactory
object Helpers { object Helpers {
private val logger = LoggerFactory.getLogger(javaClass)
@OptIn(InternalAPI::class) @OptIn(InternalAPI::class)
fun Route.calculatePath(tail: String = ""): String = when (selector) { fun Route.calculatePath(tail: String = ""): String {
is RootRouteSelector -> tail logger.info("Building path for ${selector::class}")
is PathSegmentParameterRouteSelector -> parent?.calculatePath("/$selector$tail") ?: "/{$selector}$tail" return when (selector) {
is PathSegmentConstantRouteSelector -> parent?.calculatePath("/$selector$tail") ?: "/$selector$tail" is RootRouteSelector -> {
else -> when (selector.javaClass.simpleName) { logger.info("Root route detected, returning path: $tail")
// dumb ass workaround to this object being internal to ktor tail
"TrailingSlashRouteSelector" -> parent?.calculatePath("$tail/") ?: "$tail/" }
else -> error("unknown selector type $selector") is PathSegmentParameterRouteSelector -> {
logger.info("Found segment parameter $selector, continuing to parent")
val newTail = "/$selector$tail"
parent?.calculatePath(newTail) ?: run {
logger.info("No parent found, returning current path")
newTail
}
}
is PathSegmentConstantRouteSelector -> {
logger.info("Found segment constant $selector, continuing to parent")
val newTail = "/$selector$tail"
parent?.calculatePath(newTail) ?: run {
logger.info("No parent found, returning current path")
newTail
}
}
else -> when (selector.javaClass.simpleName) {
// dumb ass workaround to this object being internal to ktor
"TrailingSlashRouteSelector" -> {
logger.info("Found trailing slash route selector")
val newTail = "$tail/"
parent?.calculatePath(newTail) ?: run {
logger.info("No parent found, returning current path")
newTail
}
}
else -> error("Unhandled selector type ${selector::class}")
}
} }
} }
fun <K, V> MutableMap<K, V>.putPairIfAbsent(pair: Pair<K, V>) = putIfAbsent(pair.first, pair.second) fun <K, V> MutableMap<K, V>.putPairIfAbsent(pair: Pair<K, V>) = putIfAbsent(pair.first, pair.second)
// TODO Investigate a caching mechanism to reduce overhead... then just reference once created
fun objectSchemaPair(clazz: KClass<*>): Pair<String, ObjectSchema> {
logger.info("Generating object schema for ${clazz.simpleName}")
val o = objectSchema(clazz)
return Pair(clazz.simpleName!!, o)
}
private fun objectSchema(clazz: KClass<*>): ObjectSchema =
ObjectSchema(properties = clazz.memberProperties.associate { prop ->
logger.info("Analyzing $prop in class $clazz")
val field = prop.javaField?.type?.kotlin
val anny = prop.findAnnotation<KompendiumField>()
if (anny != null) logger.info("Found field annotation: $anny")
val schema = when {
field?.isSubclassOf(Enum::class) == true -> {
logger.info("Detected that $prop is an enum")
val options = prop.javaField?.type?.enumConstants?.map { it.toString() }?.toSet()
?: error("unable to parse enum $prop")
EnumSchema(options)
}
field?.isSubclassOf(Map::class) == true || field?.isSubclassOf(Map.Entry::class) == true -> {
logger.info("$prop is a Map, doing some crazy stuff")
mapFieldSchema(prop)
}
field?.isSubclassOf(Collection::class) == true -> {
logger.info("$prop is a List, building array schema")
listFieldSchema(prop)
}
else -> {
logger.info("$prop is not a list or map, going directly to schema detection")
fieldToSchema(field as KClass<*>)
}
}
val name = anny?.let {
logger.info("Overriding property name with annotation $anny")
anny.name
} ?: prop.name
Pair(name, schema)
})
private fun mapFieldSchema(prop: KProperty<*>): DictionarySchema {
val (keyType, valType) = (prop.javaField?.genericType as ParameterizedType)
.actualTypeArguments.slice(IntRange(0, 1))
.map { it as Class<*> }
.map { it.kotlin }
.toPair()
if (keyType != String::class) error("Invalid Map $prop: OpenAPI dictionaries must have keys of type String")
return DictionarySchema(additionalProperties = fieldToSchema(valType))
}
private fun listFieldSchema(prop: KProperty<*>): ArraySchema {
val listType = ((prop.javaField?.genericType
as ParameterizedType).actualTypeArguments.first()
as Class<*>).kotlin
logger.info("Obtained List type, converting to schema $listType")
return ArraySchema(fieldToSchema(listType))
}
private fun fieldToSchema(field: KClass<*>): OpenApiSpecComponentSchema = when (field) {
Int::class -> FormatSchema("int32", "integer")
Long::class -> FormatSchema("int64", "integer")
Double::class -> FormatSchema("double", "number")
Float::class -> FormatSchema("float", "number")
String::class -> SimpleSchema("string")
Boolean::class -> SimpleSchema("boolean")
else -> objectSchema(field)
}
private fun <T> List<T>.toPair(): Pair<T, T> {
if (this.size != 2) {
throw IllegalArgumentException("List is not of length 2!")
}
return Pair(this[0], this[1])
}
} }

View File

@ -29,6 +29,7 @@ import org.leafygreens.kompendium.models.oas.OpenApiSpecInfo
import org.leafygreens.kompendium.models.oas.OpenApiSpecInfoContact import org.leafygreens.kompendium.models.oas.OpenApiSpecInfoContact
import org.leafygreens.kompendium.models.oas.OpenApiSpecInfoLicense import org.leafygreens.kompendium.models.oas.OpenApiSpecInfoLicense
import org.leafygreens.kompendium.models.oas.OpenApiSpecServer import org.leafygreens.kompendium.models.oas.OpenApiSpecServer
import org.leafygreens.kompendium.util.ComplexRequest
import org.leafygreens.kompendium.util.TestCreatedResponse import org.leafygreens.kompendium.util.TestCreatedResponse
import org.leafygreens.kompendium.util.TestData import org.leafygreens.kompendium.util.TestData
import org.leafygreens.kompendium.util.TestDeleteResponse import org.leafygreens.kompendium.util.TestDeleteResponse
@ -96,7 +97,6 @@ internal class KompendiumTest {
} }
} }
@Test @Test
fun `Notarized post does not interrupt the pipeline`() { fun `Notarized post does not interrupt the pipeline`() {
withTestApplication({ withTestApplication({
@ -162,7 +162,6 @@ internal class KompendiumTest {
} }
} }
@Test @Test
fun `Notarized delete does not interrupt the pipeline`() { fun `Notarized delete does not interrupt the pipeline`() {
withTestApplication({ withTestApplication({
@ -258,6 +257,22 @@ internal class KompendiumTest {
} }
} }
@Test
fun `Can notarize a complex type`() {
withTestApplication({
configModule()
openApiModule()
complexType()
}) {
// do
val json = handleRequest(HttpMethod.Get, "/openapi.json").response.content
// expect
val expected = TestData.getFileSnapshot("complex_type.json").trim()
assertEquals(expected, json, "The received json spec should match the expected content")
}
}
private companion object { private companion object {
val testGetInfo = MethodInfo("Another get test", "testing more") val testGetInfo = MethodInfo("Another get test", "testing more")
val testPostInfo = MethodInfo("Test post endpoint", "Post your tests here!") val testPostInfo = MethodInfo("Test post endpoint", "Post your tests here!")
@ -356,6 +371,16 @@ internal class KompendiumTest {
} }
} }
private fun Application.complexType() {
routing {
route("/test") {
notarizedPut<Unit, ComplexRequest, TestResponse>(testPutInfo) {
call.respondText { "heya" }
}
}
}
}
private fun Application.openApiModule() { private fun Application.openApiModule() {
routing { routing {
route("/openapi.json") { route("/openapi.json") {

View File

@ -0,0 +1,21 @@
package org.leafygreens.kompendium.util
import kotlin.test.Test
import kotlin.test.assertNotNull
import org.leafygreens.kompendium.util.Helpers.objectSchemaPair
internal class HelpersTest {
@Test
fun `can build an object schema from a complex object`() {
// when
val clazz = ComplexRequest::class
// do
val result = objectSchemaPair(clazz)
// expect
assertNotNull(result)
}
}

View File

@ -24,3 +24,29 @@ data class TestCreatedResponse(val id: Int, val c: String)
@KompendiumResponse(KompendiumHttpCodes.NO_CONTENT, "Entity was deleted successfully") @KompendiumResponse(KompendiumHttpCodes.NO_CONTENT, "Entity was deleted successfully")
object TestDeleteResponse object TestDeleteResponse
@KompendiumRequest("Request object to create a backbone project")
data class ComplexRequest(
val org: String,
@KompendiumField("amazing_field")
val amazingField: String,
val tables: List<NestedComplexItem>
) {
fun testThing() {
println("hey mom 👋")
}
}
data class NestedComplexItem(
val name: String,
val alias: CustomAlias
)
typealias CustomAlias = Map<String, CrazyItem>
data class CrazyItem(val enumeration: SimpleEnum)
enum class SimpleEnum {
ONE,
TWO
}

View File

@ -0,0 +1,107 @@
{
"openapi" : "3.0.3",
"info" : {
"title" : "Test API",
"version" : "1.33.7",
"description" : "An amazing, fully-ish \uD83D\uDE09 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/lg-backbone/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" : {
"/test" : {
"put" : {
"tags" : [ ],
"summary" : "Test put endpoint",
"description" : "Put your tests here!",
"requestBody" : {
"description" : "Request object to create a backbone project",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ComplexRequest"
}
}
},
"required" : false
},
"responses" : {
"200" : {
"description" : "A Successful Endeavor",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/TestResponse"
}
}
}
}
},
"deprecated" : false
}
}
},
"components" : {
"schemas" : {
"TestResponse" : {
"properties" : {
"c" : {
"type" : "string"
}
},
"type" : "object"
},
"ComplexRequest" : {
"properties" : {
"amazing_field" : {
"type" : "string"
},
"org" : {
"type" : "string"
},
"tables" : {
"items" : {
"properties" : {
"alias" : {
"additionalProperties" : {
"properties" : {
"enumeration" : {
"enum" : [ "ONE", "TWO" ],
"type" : "string"
}
},
"type" : "object"
},
"type" : "object"
},
"name" : {
"type" : "string"
}
},
"type" : "object"
},
"type" : "array"
}
},
"type" : "object"
}
},
"securitySchemes" : { }
},
"security" : [ ],
"tags" : [ ]
}