From 6eebaf15ead96e5d43533c1e8e652fa1e069ac74 Mon Sep 17 00:00:00 2001 From: Ryan Brink <5607577+rgbrizzlehizzle@users.noreply.github.com> Date: Wed, 14 Apr 2021 15:58:22 -0400 Subject: [PATCH] serializing for complex types (#10) --- CHANGELOG.md | 9 ++ gradle.properties | 2 +- .../org/leafygreens/kompendium/Kompendium.kt | 51 +------ .../models/oas/OpenApiSpecComponentSchema.kt | 8 + .../leafygreens/kompendium/util/Helpers.kt | 140 +++++++++++++++++- .../leafygreens/kompendium/KompendiumTest.kt | 29 +++- .../kompendium/util/HelpersTest.kt | 21 +++ .../leafygreens/kompendium/util/TestModels.kt | 26 ++++ .../src/test/resources/complex_type.json | 107 +++++++++++++ 9 files changed, 332 insertions(+), 61 deletions(-) create mode 100644 kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/HelpersTest.kt create mode 100644 kompendium-core/src/test/resources/complex_type.json diff --git a/CHANGELOG.md b/CHANGELOG.md index bf034e3eb..23d160277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/gradle.properties b/gradle.properties index a86bfadbd..a5660286d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ # Kompendium -project.version=0.0.5 +project.version=0.0.6 # Kotlin kotlin.code.style=official # Gradle diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kompendium.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kompendium.kt index 4da130838..f2cd0402a 100644 --- a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kompendium.kt +++ b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kompendium.kt @@ -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 { - 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() - 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(), diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/oas/OpenApiSpecComponentSchema.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/oas/OpenApiSpecComponentSchema.kt index eae3b6d36..e81c0503e 100644 --- a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/oas/OpenApiSpecComponentSchema.kt +++ b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/oas/OpenApiSpecComponentSchema.kt @@ -7,6 +7,14 @@ data class ObjectSchema( val properties: Map ) : OpenApiSpecComponentSchema("object") +data class DictionarySchema( + val additionalProperties: OpenApiSpecComponentSchema +) : OpenApiSpecComponentSchema("object") + +data class EnumSchema( + val `enum`: Set +) : OpenApiSpecComponentSchema("string") + data class SimpleSchema(override val type: String) : OpenApiSpecComponentSchema(type) data class FormatSchema(val format: String, override val type: String) : OpenApiSpecComponentSchema(type) diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/util/Helpers.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/util/Helpers.kt index 53e5c6a98..fd9eb1cbf 100644 --- a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/util/Helpers.kt +++ b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/util/Helpers.kt @@ -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 MutableMap.putPairIfAbsent(pair: Pair) = putIfAbsent(pair.first, pair.second) + // TODO Investigate a caching mechanism to reduce overhead... then just reference once created + fun objectSchemaPair(clazz: KClass<*>): Pair { + 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() + + 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 List.toPair(): Pair { + if (this.size != 2) { + throw IllegalArgumentException("List is not of length 2!") + } + return Pair(this[0], this[1]) + } } diff --git a/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/KompendiumTest.kt b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/KompendiumTest.kt index d3740e9a7..9ed6946af 100644 --- a/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/KompendiumTest.kt +++ b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/KompendiumTest.kt @@ -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(testPutInfo) { + call.respondText { "heya" } + } + } + } + } + private fun Application.openApiModule() { routing { route("/openapi.json") { diff --git a/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/HelpersTest.kt b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/HelpersTest.kt new file mode 100644 index 000000000..c493cebce --- /dev/null +++ b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/HelpersTest.kt @@ -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) + } + +} diff --git a/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/TestModels.kt b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/TestModels.kt index 95b22ee51..4049e90f5 100644 --- a/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/TestModels.kt +++ b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/TestModels.kt @@ -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 +) { + fun testThing() { + println("hey mom 👋") + } +} + +data class NestedComplexItem( + val name: String, + val alias: CustomAlias +) + +typealias CustomAlias = Map + +data class CrazyItem(val enumeration: SimpleEnum) + +enum class SimpleEnum { + ONE, + TWO +} diff --git a/kompendium-core/src/test/resources/complex_type.json b/kompendium-core/src/test/resources/complex_type.json new file mode 100644 index 000000000..d87a10d27 --- /dev/null +++ b/kompendium-core/src/test/resources/complex_type.json @@ -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" : [ ] +}