fix: locations inheritance (#135)

This commit is contained in:
Ryan Brink
2022-01-07 08:46:20 -05:00
committed by GitHub
parent da104d0a63
commit eb369dcdc8
11 changed files with 183 additions and 99 deletions

View File

@ -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

View File

@ -0,0 +1,5 @@
package io.bkbn.kompendium.core
import io.bkbn.kompendium.core.parser.IMethodParser
object DefaultMethodParser : IMethodParser

View File

@ -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("/\\(.+\\)"), "")
}

View File

@ -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<Int, Response> = responseType.toResponseSpec(responseInfo, feature)?.let { mapOf(it) }.orEmpty()
private fun parseExceptions(
fun parseExceptions(
exceptionInfo: Set<ExceptionInfo<*>>,
feature: Kompendium,
): Map<Int, Response> = 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<Int, Response>? =
fun KType.toResponseSpec(responseInfo: ResponseInfo<*>?, feature: Kompendium): Pair<Int, Response>? =
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<String>,
examples: Map<String, Any>
@ -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<Parameter> {
fun KType.toParameterSpec(info: MethodInfo<*, *>, feature: Kompendium): List<Parameter> {
val clazz = classifier as KClass<*>
return clazz.memberProperties.filter { prop ->
prop.findAnnotation<Param>() != null
}.map { prop ->
val wrapperSchema = feature.config.cache[this.getSimpleSlug()]!! as ObjectSchema
val anny = prop.findAnnotation<Param>()
?: 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<Param>() }
.map { prop -> prop.toParameter(info, this, clazz, feature) }
}
private fun Set<ParameterExample>.mapToSpec(parameterName: String): Map<String, Parameter.Example>? {
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<Param>()
?: 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<ParameterExample>.mapToSpec(parameterName: String): Map<String, Parameter.Example>? {
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("/\\(.+\\)"), "")
}

View File

@ -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<Parameter> {
val clazzList = determineLocationParents(classifier!!)
return clazzList.associateWith { it.memberProperties }
.flatMap { (clazz, memberProperties) -> memberProperties.associateWith { clazz }.toList() }
.filter { (prop, _) -> prop.hasAnnotation<Param>() }
.map { (prop, clazz) -> prop.toParameter(info, clazz.createType(), clazz, feature) }
}
private fun determineLocationParents(classifier: KClassifier): List<KClass<*>> {
var clazz: KClass<*>? = classifier as KClass<*>
val clazzList = mutableListOf<KClass<*>>()
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<Location>() }
return parent?.returnType?.classifier as? KClass<*>
}
fun KClass<*>.calculateLocationPath(suffix: String = ""): String {
val locationAnnotation = this.findAnnotation<Location>()
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 <reified TParam : Any> processBaseInfo(
paramType: KType,
requestType: KType,
responseType: KType,
info: MethodInfo<*, *>,
route: Route
): LocationBaseInfo {
val locationAnnotation = TParam::class.findAnnotation<Location>()
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
)
}

View File

@ -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<Unit, ApplicationCall>.(TParam) -> Unit
): Route = methodNotarizationPreFlight<TParam, Unit, TResp>() { paramType, requestType, responseType ->
val locationAnnotation = TParam::class.findAnnotation<Location>()
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<TParam>(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<Unit, ApplicationCall>.(TParam) -> Unit
): Route = methodNotarizationPreFlight<TParam, TReq, TResp>() { paramType, requestType, responseType ->
val locationAnnotation = TParam::class.findAnnotation<Location>()
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<TParam>(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<Unit, ApplicationCall>.(TParam) -> Unit
): Route = methodNotarizationPreFlight<TParam, TReq, TResp>() { paramType, requestType, responseType ->
val locationAnnotation = TParam::class.findAnnotation<Location>()
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<TParam>(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<Unit, ApplicationCall>.(TParam) -> Unit
): Route = methodNotarizationPreFlight<TParam, Unit, TResp> { paramType, requestType, responseType ->
val locationAnnotation = TParam::class.findAnnotation<Location>()
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<TParam>(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<Location>()
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)
}
}
}

View File

@ -40,6 +40,15 @@
},
"required": true,
"deprecated": false
},
{
"name": "name",
"in": "path",
"schema": {
"type": "string"
},
"required": true,
"deprecated": false
}
],
"responses": {

View File

@ -40,6 +40,15 @@
},
"required": true,
"deprecated": false
},
{
"name": "name",
"in": "path",
"schema": {
"type": "string"
},
"required": true,
"deprecated": false
}
],
"responses": {

View File

@ -40,6 +40,15 @@
},
"required": true,
"deprecated": false
},
{
"name": "name",
"in": "path",
"schema": {
"type": "string"
},
"required": true,
"deprecated": false
}
],
"requestBody": {

View File

@ -40,6 +40,15 @@
},
"required": true,
"deprecated": false
},
{
"name": "name",
"in": "path",
"schema": {
"type": "string"
},
"required": true,
"deprecated": false
}
],
"requestBody": {

View File

@ -20,5 +20,4 @@ object NumberSerializer : KSerializer<Number> {
override fun serialize(encoder: Encoder, value: Number) {
encoder.encodeString(value.toString())
}
}