serializing for complex types (#10)
This commit is contained in:
@ -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
|
||||
|
||||
### Added
|
||||
|
@ -1,5 +1,5 @@
|
||||
# Kompendium
|
||||
project.version=0.0.5
|
||||
project.version=0.0.6
|
||||
# Kotlin
|
||||
kotlin.code.style=official
|
||||
# Gradle
|
||||
|
@ -5,21 +5,11 @@ import io.ktor.http.HttpMethod
|
||||
import io.ktor.routing.Route
|
||||
import io.ktor.routing.method
|
||||
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.memberProperties
|
||||
import kotlin.reflect.jvm.javaField
|
||||
import org.leafygreens.kompendium.annotations.KompendiumField
|
||||
import org.leafygreens.kompendium.annotations.KompendiumRequest
|
||||
import org.leafygreens.kompendium.annotations.KompendiumResponse
|
||||
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.OpenApiSpecComponentSchema
|
||||
import org.leafygreens.kompendium.models.oas.OpenApiSpecInfo
|
||||
import org.leafygreens.kompendium.models.oas.OpenApiSpecMediaType
|
||||
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.OpenApiSpecRequest
|
||||
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.objectSchemaPair
|
||||
import org.leafygreens.kompendium.util.Helpers.putPairIfAbsent
|
||||
|
||||
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() {
|
||||
openApiSpec = OpenApiSpec(
|
||||
info = OpenApiSpecInfo(),
|
||||
|
@ -7,6 +7,14 @@ data class ObjectSchema(
|
||||
val properties: Map<String, OpenApiSpecComponentSchema>
|
||||
) : 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 FormatSchema(val format: String, override val type: String) : OpenApiSpecComponentSchema(type)
|
||||
|
@ -5,21 +5,145 @@ import io.ktor.routing.PathSegmentParameterRouteSelector
|
||||
import io.ktor.routing.RootRouteSelector
|
||||
import io.ktor.routing.Route
|
||||
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 {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
@OptIn(InternalAPI::class)
|
||||
fun Route.calculatePath(tail: String = ""): String = when (selector) {
|
||||
is RootRouteSelector -> tail
|
||||
is PathSegmentParameterRouteSelector -> parent?.calculatePath("/$selector$tail") ?: "/{$selector}$tail"
|
||||
is PathSegmentConstantRouteSelector -> parent?.calculatePath("/$selector$tail") ?: "/$selector$tail"
|
||||
else -> when (selector.javaClass.simpleName) {
|
||||
// dumb ass workaround to this object being internal to ktor
|
||||
"TrailingSlashRouteSelector" -> parent?.calculatePath("$tail/") ?: "$tail/"
|
||||
else -> error("unknown selector type $selector")
|
||||
fun Route.calculatePath(tail: String = ""): String {
|
||||
logger.info("Building path for ${selector::class}")
|
||||
return when (selector) {
|
||||
is RootRouteSelector -> {
|
||||
logger.info("Root route detected, returning path: $tail")
|
||||
tail
|
||||
}
|
||||
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)
|
||||
|
||||
// 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])
|
||||
}
|
||||
}
|
||||
|
@ -29,6 +29,7 @@ import org.leafygreens.kompendium.models.oas.OpenApiSpecInfo
|
||||
import org.leafygreens.kompendium.models.oas.OpenApiSpecInfoContact
|
||||
import org.leafygreens.kompendium.models.oas.OpenApiSpecInfoLicense
|
||||
import org.leafygreens.kompendium.models.oas.OpenApiSpecServer
|
||||
import org.leafygreens.kompendium.util.ComplexRequest
|
||||
import org.leafygreens.kompendium.util.TestCreatedResponse
|
||||
import org.leafygreens.kompendium.util.TestData
|
||||
import org.leafygreens.kompendium.util.TestDeleteResponse
|
||||
@ -96,7 +97,6 @@ internal class KompendiumTest {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun `Notarized post does not interrupt the pipeline`() {
|
||||
withTestApplication({
|
||||
@ -162,7 +162,6 @@ internal class KompendiumTest {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun `Notarized delete does not interrupt the pipeline`() {
|
||||
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 {
|
||||
val testGetInfo = MethodInfo("Another get test", "testing more")
|
||||
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() {
|
||||
routing {
|
||||
route("/openapi.json") {
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
@ -24,3 +24,29 @@ data class TestCreatedResponse(val id: Int, val c: String)
|
||||
|
||||
@KompendiumResponse(KompendiumHttpCodes.NO_CONTENT, "Entity was deleted successfully")
|
||||
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
|
||||
}
|
||||
|
107
kompendium-core/src/test/resources/complex_type.json
Normal file
107
kompendium-core/src/test/resources/complex_type.json
Normal 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" : [ ]
|
||||
}
|
Reference in New Issue
Block a user