From eb369dcdc81dea8b87edfcba69cbcf59b04c02a0 Mon Sep 17 00:00:00 2001 From: Ryan Brink <5607577+unredundant@users.noreply.github.com> Date: Fri, 7 Jan 2022 08:46:20 -0500 Subject: [PATCH] fix: locations inheritance (#135) --- CHANGELOG.md | 1 + .../kompendium/core/DefaultMethodParser.kt | 5 ++ .../io/bkbn/kompendium/core/Notarized.kt | 9 +- .../IMethodParser.kt} | 82 ++++++++++-------- .../locations/LocationMethodParser.kt | 84 +++++++++++++++++++ .../kompendium/locations/NotarizedLocation.kt | 64 ++------------ .../notarized_delete_nested_location.json | 9 ++ .../notarized_get_nested_location.json | 9 ++ .../notarized_post_nested_location.json | 9 ++ .../notarized_put_nested_location.json | 9 ++ .../oas/serialization/NumberSerializer.kt | 1 - 11 files changed, 183 insertions(+), 99 deletions(-) create mode 100644 kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/DefaultMethodParser.kt rename kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/{MethodParser.kt => parser/IMethodParser.kt} (77%) create mode 100644 kompendium-locations/src/main/kotlin/io/bkbn/kompendium/locations/LocationMethodParser.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index a7b8a4ed7..229778b2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Changed - Kompendium now leverages the chosen API serializer. Supports Jackson, Gson and Kotlinx Serialization - Fixed bug where overridden field names were not reflected in serialized object and required array +- Fixed bug where Ktor Location parents were not being scanned for parameters ### Remove diff --git a/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/DefaultMethodParser.kt b/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/DefaultMethodParser.kt new file mode 100644 index 000000000..80a394fcf --- /dev/null +++ b/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/DefaultMethodParser.kt @@ -0,0 +1,5 @@ +package io.bkbn.kompendium.core + +import io.bkbn.kompendium.core.parser.IMethodParser + +object DefaultMethodParser : IMethodParser diff --git a/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/Notarized.kt b/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/Notarized.kt index 92bfab351..8fc4083a6 100644 --- a/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/Notarized.kt +++ b/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/Notarized.kt @@ -1,8 +1,9 @@ package io.bkbn.kompendium.core import io.bkbn.kompendium.annotations.Param +import io.bkbn.kompendium.core.DefaultMethodParser.calculateRoutePath import io.bkbn.kompendium.core.KompendiumPreFlight.methodNotarizationPreFlight -import io.bkbn.kompendium.core.MethodParser.parseMethodInfo +import io.bkbn.kompendium.core.DefaultMethodParser.parseMethodInfo import io.bkbn.kompendium.core.metadata.method.DeleteInfo import io.bkbn.kompendium.core.metadata.method.GetInfo import io.bkbn.kompendium.core.metadata.method.HeadInfo @@ -168,10 +169,4 @@ object Notarized { feature.config.spec.paths[path]?.options = postProcess(baseInfo) return method(HttpMethod.Options) { handle(body) } } - - /** - * Uses the built-in Ktor route path [Route.toString] but cuts out any meta route such as authentication... anything - * that matches the RegEx pattern `/\\(.+\\)` - */ - fun Route.calculateRoutePath() = toString().replace(Regex("/\\(.+\\)"), "") } diff --git a/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/MethodParser.kt b/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/parser/IMethodParser.kt similarity index 77% rename from kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/MethodParser.kt rename to kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/parser/IMethodParser.kt index 9488b0d87..0650a82e6 100644 --- a/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/MethodParser.kt +++ b/kompendium-core/src/main/kotlin/io/bkbn/kompendium/core/parser/IMethodParser.kt @@ -1,7 +1,8 @@ -package io.bkbn.kompendium.core +package io.bkbn.kompendium.core.parser import io.bkbn.kompendium.annotations.Param -import io.bkbn.kompendium.core.Kontent.generateKontent +import io.bkbn.kompendium.core.Kompendium +import io.bkbn.kompendium.core.Kontent import io.bkbn.kompendium.core.metadata.ExceptionInfo import io.bkbn.kompendium.core.metadata.ParameterExample import io.bkbn.kompendium.core.metadata.RequestInfo @@ -19,22 +20,20 @@ import io.bkbn.kompendium.oas.payload.Request import io.bkbn.kompendium.oas.payload.Response import io.bkbn.kompendium.oas.schema.AnyOfSchema import io.bkbn.kompendium.oas.schema.ObjectSchema +import io.ktor.routing.Route import kotlin.reflect.KClass import kotlin.reflect.KParameter import kotlin.reflect.KProperty import kotlin.reflect.KType import kotlin.reflect.full.createType import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.hasAnnotation import kotlin.reflect.full.memberProperties import kotlin.reflect.full.primaryConstructor import java.util.Locale import java.util.UUID -/** - * The MethodParser is responsible for converting route metadata and types into an OpenAPI compatible data class. - */ -object MethodParser { - +interface IMethodParser { /** * Generates the OpenAPI Path spec from provided metadata * @param info implementation of the [MethodInfo] sealed class @@ -68,17 +67,17 @@ object MethodParser { ) else null ) - private fun parseResponse( + fun parseResponse( responseType: KType, responseInfo: ResponseInfo<*>?, feature: Kompendium ): Map = responseType.toResponseSpec(responseInfo, feature)?.let { mapOf(it) }.orEmpty() - private fun parseExceptions( + fun parseExceptions( exceptionInfo: Set>, feature: Kompendium, ): Map = exceptionInfo.associate { info -> - feature.config.cache = generateKontent(info.responseType, feature.config.cache) + feature.config.cache = Kontent.generateKontent(info.responseType, feature.config.cache) val response = Response( description = info.description, content = feature.resolveContent(info.responseType, info.mediaTypes, info.examples) @@ -92,7 +91,7 @@ object MethodParser { * @param requestInfo request metadata * @return Will return a generated [Request] if requestInfo is not null */ - private fun KType.toRequestSpec(requestInfo: RequestInfo<*>?, feature: Kompendium): Request? = + fun KType.toRequestSpec(requestInfo: RequestInfo<*>?, feature: Kompendium): Request? = when (requestInfo) { null -> null else -> { @@ -111,7 +110,7 @@ object MethodParser { * @param responseInfo response metadata * @return Will return a generated [Pair] if responseInfo is not null */ - private fun KType.toResponseSpec(responseInfo: ResponseInfo<*>?, feature: Kompendium): Pair? = + fun KType.toResponseSpec(responseInfo: ResponseInfo<*>?, feature: Kompendium): Pair? = when (responseInfo) { null -> null else -> { @@ -130,7 +129,7 @@ object MethodParser { * @param examples Mapping of named examples of valid bodies. * @return Named mapping of media types. */ - private fun Kompendium.resolveContent( + fun Kompendium.resolveContent( type: KType, mediaTypes: List, examples: Map @@ -163,29 +162,36 @@ object MethodParser { * @return list of valid parameter specs as detailed by the [KType] members * @throws [IllegalStateException] if the class could not be parsed properly */ - private fun KType.toParameterSpec(info: MethodInfo<*, *>, feature: Kompendium): List { + fun KType.toParameterSpec(info: MethodInfo<*, *>, feature: Kompendium): List { val clazz = classifier as KClass<*> - return clazz.memberProperties.filter { prop -> - prop.findAnnotation() != null - }.map { prop -> - val wrapperSchema = feature.config.cache[this.getSimpleSlug()]!! as ObjectSchema - val anny = prop.findAnnotation() - ?: error("Field ${prop.name} is not annotated with KompendiumParam") - val schema = wrapperSchema.properties[prop.name] - ?: error("Could not find component type for $prop") - val defaultValue = getDefaultParameterValue(clazz, prop) - Parameter( - name = prop.name, - `in` = anny.type.name.lowercase(Locale.getDefault()), - schema = schema.addDefault(defaultValue), - description = schema.description, - required = !prop.returnType.isMarkedNullable && defaultValue == null, - examples = info.parameterExamples.mapToSpec(prop.name) - ) - } + return clazz.memberProperties + .filter { prop -> prop.hasAnnotation() } + .map { prop -> prop.toParameter(info, this, clazz, feature) } } - private fun Set.mapToSpec(parameterName: String): Map? { + fun KProperty<*>.toParameter( + info: MethodInfo<*, *>, + parentType: KType, + parentClazz: KClass<*>, + feature: Kompendium + ): Parameter { + val wrapperSchema = feature.config.cache[parentType.getSimpleSlug()]!! as ObjectSchema + val anny = this.findAnnotation() + ?: error("Field $name is not annotated with KompendiumParam") + val schema = wrapperSchema.properties[name] + ?: error("Could not find component type for $this") + val defaultValue = getDefaultParameterValue(parentClazz, this) + return Parameter( + name = name, + `in` = anny.type.name.lowercase(Locale.getDefault()), + schema = schema.addDefault(defaultValue), + description = schema.description, + required = !returnType.isMarkedNullable && defaultValue == null, + examples = info.parameterExamples.mapToSpec(name) + ) + } + + fun Set.mapToSpec(parameterName: String): Map? { val filtered = filter { it.parameterName == parameterName } return if (filtered.isEmpty()) { null @@ -200,7 +206,7 @@ object MethodParser { * @param prop the property in question * @return The default value if found */ - private fun getDefaultParameterValue(clazz: KClass<*>, prop: KProperty<*>): Any? { + fun getDefaultParameterValue(clazz: KClass<*>, prop: KProperty<*>): Any? { val constructor = clazz.primaryConstructor val parameterInQuestion = constructor ?.parameters @@ -227,7 +233,7 @@ object MethodParser { * @return value of the proper type to match param * @throws [IllegalStateException] if parameter type is not one of the basic types supported below. */ - private fun defaultValueInjector(param: KParameter): Any = when (param.type.classifier) { + fun defaultValueInjector(param: KParameter): Any = when (param.type.classifier) { String::class -> "test" Boolean::class -> false Int::class -> 1 @@ -237,4 +243,10 @@ object MethodParser { UUID::class -> UUID.randomUUID() else -> error("Unsupported Type") } + + /** + * Uses the built-in Ktor route path [Route.toString] but cuts out any meta route such as authentication... anything + * that matches the RegEx pattern `/\\(.+\\)` + */ + fun Route.calculateRoutePath() = toString().replace(Regex("/\\(.+\\)"), "") } diff --git a/kompendium-locations/src/main/kotlin/io/bkbn/kompendium/locations/LocationMethodParser.kt b/kompendium-locations/src/main/kotlin/io/bkbn/kompendium/locations/LocationMethodParser.kt new file mode 100644 index 000000000..7b9e9cb96 --- /dev/null +++ b/kompendium-locations/src/main/kotlin/io/bkbn/kompendium/locations/LocationMethodParser.kt @@ -0,0 +1,84 @@ +package io.bkbn.kompendium.locations + +import io.bkbn.kompendium.annotations.Param +import io.bkbn.kompendium.core.Kompendium +import io.bkbn.kompendium.core.metadata.method.MethodInfo +import io.bkbn.kompendium.core.parser.IMethodParser +import io.bkbn.kompendium.oas.path.Path +import io.bkbn.kompendium.oas.path.PathOperation +import io.bkbn.kompendium.oas.payload.Parameter +import io.ktor.application.feature +import io.ktor.locations.KtorExperimentalLocationsAPI +import io.ktor.locations.Location +import io.ktor.routing.Route +import io.ktor.routing.application +import kotlin.reflect.KAnnotatedElement +import kotlin.reflect.KClass +import kotlin.reflect.KClassifier +import kotlin.reflect.KType +import kotlin.reflect.full.createType +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.hasAnnotation +import kotlin.reflect.full.memberProperties + +@OptIn(KtorExperimentalLocationsAPI::class) +object LocationMethodParser : IMethodParser { + override fun KType.toParameterSpec(info: MethodInfo<*, *>, feature: Kompendium): List { + val clazzList = determineLocationParents(classifier!!) + return clazzList.associateWith { it.memberProperties } + .flatMap { (clazz, memberProperties) -> memberProperties.associateWith { clazz }.toList() } + .filter { (prop, _) -> prop.hasAnnotation() } + .map { (prop, clazz) -> prop.toParameter(info, clazz.createType(), clazz, feature) } + } + + private fun determineLocationParents(classifier: KClassifier): List> { + var clazz: KClass<*>? = classifier as KClass<*> + val clazzList = mutableListOf>() + while (clazz != null) { + clazzList.add(clazz) + clazz = getLocationParent(clazz) + } + return clazzList + } + + private fun getLocationParent(clazz: KClass<*>): KClass<*>? { + val parent = clazz.memberProperties + .find { (it.returnType.classifier as KAnnotatedElement).hasAnnotation() } + return parent?.returnType?.classifier as? KClass<*> + } + + fun KClass<*>.calculateLocationPath(suffix: String = ""): String { + val locationAnnotation = this.findAnnotation() + require(locationAnnotation != null) { "Location annotation must be present to leverage notarized location api" } + val parent = this.java.declaringClass?.kotlin + val newSuffix = locationAnnotation.path.plus(suffix) + return when (parent) { + null -> newSuffix + else -> parent.calculateLocationPath(newSuffix) + } + } + + inline fun processBaseInfo( + paramType: KType, + requestType: KType, + responseType: KType, + info: MethodInfo<*, *>, + route: Route + ): LocationBaseInfo { + val locationAnnotation = TParam::class.findAnnotation() + require(locationAnnotation != null) { "Location annotation must be present to leverage notarized location api" } + val path = route.calculateRoutePath() + val locationPath = TParam::class.calculateLocationPath() + val pathWithLocation = path.plus(locationPath) + val feature = route.application.feature(Kompendium) + feature.config.spec.paths.getOrPut(pathWithLocation) { Path() } + val baseInfo = parseMethodInfo(info, paramType, requestType, responseType, feature) + return LocationBaseInfo(baseInfo, feature, pathWithLocation) + } + + data class LocationBaseInfo( + val op: PathOperation, + val feature: Kompendium, + val path: String + ) +} diff --git a/kompendium-locations/src/main/kotlin/io/bkbn/kompendium/locations/NotarizedLocation.kt b/kompendium-locations/src/main/kotlin/io/bkbn/kompendium/locations/NotarizedLocation.kt index e2f119074..16b689fbc 100644 --- a/kompendium-locations/src/main/kotlin/io/bkbn/kompendium/locations/NotarizedLocation.kt +++ b/kompendium-locations/src/main/kotlin/io/bkbn/kompendium/locations/NotarizedLocation.kt @@ -1,28 +1,19 @@ package io.bkbn.kompendium.locations -import io.bkbn.kompendium.core.Kompendium import io.bkbn.kompendium.core.KompendiumPreFlight.methodNotarizationPreFlight -import io.bkbn.kompendium.core.MethodParser.parseMethodInfo -import io.bkbn.kompendium.core.Notarized.calculateRoutePath import io.bkbn.kompendium.core.metadata.method.DeleteInfo import io.bkbn.kompendium.core.metadata.method.GetInfo import io.bkbn.kompendium.core.metadata.method.PostInfo import io.bkbn.kompendium.core.metadata.method.PutInfo -import io.bkbn.kompendium.oas.path.Path import io.bkbn.kompendium.oas.path.PathOperation import io.ktor.application.ApplicationCall -import io.ktor.application.feature import io.ktor.http.HttpMethod import io.ktor.locations.KtorExperimentalLocationsAPI -import io.ktor.locations.Location import io.ktor.locations.handle import io.ktor.locations.location import io.ktor.routing.Route -import io.ktor.routing.application import io.ktor.routing.method import io.ktor.util.pipeline.PipelineContext -import kotlin.reflect.KClass -import kotlin.reflect.full.findAnnotation /** * This version of notarized routes leverages the Ktor [io.ktor.locations.Locations] plugin to provide type safe access @@ -45,15 +36,8 @@ object NotarizedLocation { postProcess: (PathOperation) -> PathOperation = { p -> p }, noinline body: suspend PipelineContext.(TParam) -> Unit ): Route = methodNotarizationPreFlight() { paramType, requestType, responseType -> - val locationAnnotation = TParam::class.findAnnotation() - require(locationAnnotation != null) { "Location annotation must be present to leverage notarized location api" } - val feature = application.feature(Kompendium) - val path = calculateRoutePath() - val locationPath = TParam::class.calculateLocationPath() - val pathWithLocation = path.plus(locationPath) - feature.config.spec.paths.getOrPut(pathWithLocation) { Path() } - val baseInfo = parseMethodInfo(info, paramType, requestType, responseType, feature) - feature.config.spec.paths[pathWithLocation]?.get = postProcess(baseInfo) + val lbi = LocationMethodParser.processBaseInfo(paramType, requestType, responseType, info, this) + lbi.feature.config.spec.paths[lbi.path]?.get = postProcess(lbi.op) return location(TParam::class) { method(HttpMethod.Get) { handle(body) } } @@ -74,15 +58,8 @@ object NotarizedLocation { postProcess: (PathOperation) -> PathOperation = { p -> p }, noinline body: suspend PipelineContext.(TParam) -> Unit ): Route = methodNotarizationPreFlight() { paramType, requestType, responseType -> - val locationAnnotation = TParam::class.findAnnotation() - require(locationAnnotation != null) { "Location annotation must be present to leverage notarized location api" } - val feature = application.feature(Kompendium) - val path = calculateRoutePath() - val locationPath = TParam::class.calculateLocationPath() - val pathWithLocation = path.plus(locationPath) - feature.config.spec.paths.getOrPut(pathWithLocation) { Path() } - val baseInfo = parseMethodInfo(info, paramType, requestType, responseType, feature) - feature.config.spec.paths[pathWithLocation]?.post = postProcess(baseInfo) + val lbi = LocationMethodParser.processBaseInfo(paramType, requestType, responseType, info, this) + lbi.feature.config.spec.paths[lbi.path]?.post = postProcess(lbi.op) return location(TParam::class) { method(HttpMethod.Post) { handle(body) } } @@ -103,15 +80,8 @@ object NotarizedLocation { postProcess: (PathOperation) -> PathOperation = { p -> p }, noinline body: suspend PipelineContext.(TParam) -> Unit ): Route = methodNotarizationPreFlight() { paramType, requestType, responseType -> - val locationAnnotation = TParam::class.findAnnotation() - require(locationAnnotation != null) { "Location annotation must be present to leverage notarized location api" } - val feature = application.feature(Kompendium) - val path = calculateRoutePath() - val locationPath = TParam::class.calculateLocationPath() - val pathWithLocation = path.plus(locationPath) - feature.config.spec.paths.getOrPut(pathWithLocation) { Path() } - val baseInfo = parseMethodInfo(info, paramType, requestType, responseType, feature) - feature.config.spec.paths[pathWithLocation]?.put = postProcess(baseInfo) + val lbi = LocationMethodParser.processBaseInfo(paramType, requestType, responseType, info, this) + lbi.feature.config.spec.paths[lbi.path]?.put = postProcess(lbi.op) return location(TParam::class) { method(HttpMethod.Put) { handle(body) } } @@ -131,28 +101,10 @@ object NotarizedLocation { postProcess: (PathOperation) -> PathOperation = { p -> p }, noinline body: suspend PipelineContext.(TParam) -> Unit ): Route = methodNotarizationPreFlight { paramType, requestType, responseType -> - val locationAnnotation = TParam::class.findAnnotation() - require(locationAnnotation != null) { "Location annotation must be present to leverage notarized location api" } - val feature = application.feature(Kompendium) - val path = calculateRoutePath() - val locationPath = TParam::class.calculateLocationPath() - val pathWithLocation = path.plus(locationPath) - feature.config.spec.paths.getOrPut(pathWithLocation) { Path() } - val baseInfo = parseMethodInfo(info, paramType, requestType, responseType, feature) - feature.config.spec.paths[pathWithLocation]?.delete = postProcess(baseInfo) + val lbi = LocationMethodParser.processBaseInfo(paramType, requestType, responseType, info, this) + lbi.feature.config.spec.paths[lbi.path]?.delete = postProcess(lbi.op) return location(TParam::class) { method(HttpMethod.Delete) { handle(body) } } } - - fun KClass<*>.calculateLocationPath(suffix: String = ""): String { - val locationAnnotation = this.findAnnotation() - require(locationAnnotation != null) { "Location annotation must be present to leverage notarized location api" } - val parent = this.java.declaringClass?.kotlin - val newSuffix = locationAnnotation.path.plus(suffix) - return when (parent) { - null -> newSuffix - else -> parent.calculateLocationPath(newSuffix) - } - } } diff --git a/kompendium-locations/src/test/resources/notarized_delete_nested_location.json b/kompendium-locations/src/test/resources/notarized_delete_nested_location.json index 65dead71f..e746d9c53 100644 --- a/kompendium-locations/src/test/resources/notarized_delete_nested_location.json +++ b/kompendium-locations/src/test/resources/notarized_delete_nested_location.json @@ -40,6 +40,15 @@ }, "required": true, "deprecated": false + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true, + "deprecated": false } ], "responses": { diff --git a/kompendium-locations/src/test/resources/notarized_get_nested_location.json b/kompendium-locations/src/test/resources/notarized_get_nested_location.json index 3e864c610..48a1b3ff8 100644 --- a/kompendium-locations/src/test/resources/notarized_get_nested_location.json +++ b/kompendium-locations/src/test/resources/notarized_get_nested_location.json @@ -40,6 +40,15 @@ }, "required": true, "deprecated": false + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true, + "deprecated": false } ], "responses": { diff --git a/kompendium-locations/src/test/resources/notarized_post_nested_location.json b/kompendium-locations/src/test/resources/notarized_post_nested_location.json index a01bdb8ea..e3f3897f2 100644 --- a/kompendium-locations/src/test/resources/notarized_post_nested_location.json +++ b/kompendium-locations/src/test/resources/notarized_post_nested_location.json @@ -40,6 +40,15 @@ }, "required": true, "deprecated": false + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true, + "deprecated": false } ], "requestBody": { diff --git a/kompendium-locations/src/test/resources/notarized_put_nested_location.json b/kompendium-locations/src/test/resources/notarized_put_nested_location.json index d9c989bb2..3447431e7 100644 --- a/kompendium-locations/src/test/resources/notarized_put_nested_location.json +++ b/kompendium-locations/src/test/resources/notarized_put_nested_location.json @@ -40,6 +40,15 @@ }, "required": true, "deprecated": false + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true, + "deprecated": false } ], "requestBody": { diff --git a/kompendium-oas/src/main/kotlin/io/bkbn/kompendium/oas/serialization/NumberSerializer.kt b/kompendium-oas/src/main/kotlin/io/bkbn/kompendium/oas/serialization/NumberSerializer.kt index 809bcac7e..5b30e105d 100644 --- a/kompendium-oas/src/main/kotlin/io/bkbn/kompendium/oas/serialization/NumberSerializer.kt +++ b/kompendium-oas/src/main/kotlin/io/bkbn/kompendium/oas/serialization/NumberSerializer.kt @@ -20,5 +20,4 @@ object NumberSerializer : KSerializer { override fun serialize(encoder: Encoder, value: Number) { encoder.encodeString(value.toString()) } - }