Error Responses, PR Template, Code Refactor, and Test plugin update (#42)

This commit is contained in:
Ryan Brink
2021-04-29 19:08:45 -04:00
committed by GitHub
parent 571dc11c29
commit 26ada3daad
17 changed files with 578 additions and 79 deletions

View File

@ -1,22 +1,17 @@
package org.leafygreens.kompendium
import io.ktor.application.ApplicationCall
import io.ktor.http.HttpMethod
import io.ktor.routing.Route
import io.ktor.routing.method
import io.ktor.util.pipeline.PipelineInterceptor
import kotlin.reflect.KClass
import kotlin.reflect.KType
import kotlin.reflect.full.createType
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.javaField
import kotlin.reflect.typeOf
import org.leafygreens.kompendium.Kontent.generateKontent
import org.leafygreens.kompendium.Kontent.generateParameterKontent
import org.leafygreens.kompendium.annotations.CookieParam
import org.leafygreens.kompendium.annotations.HeaderParam
import org.leafygreens.kompendium.annotations.PathParam
import org.leafygreens.kompendium.annotations.QueryParam
import org.leafygreens.kompendium.models.meta.ErrorMap
import org.leafygreens.kompendium.models.meta.MethodInfo
import org.leafygreens.kompendium.models.meta.RequestInfo
import org.leafygreens.kompendium.models.meta.ResponseInfo
@ -25,8 +20,8 @@ import org.leafygreens.kompendium.models.oas.OpenApiSpec
import org.leafygreens.kompendium.models.oas.OpenApiSpecInfo
import org.leafygreens.kompendium.models.oas.OpenApiSpecMediaType
import org.leafygreens.kompendium.models.oas.OpenApiSpecParameter
import org.leafygreens.kompendium.models.oas.OpenApiSpecPathItem
import org.leafygreens.kompendium.models.oas.OpenApiSpecPathItemOperation
import org.leafygreens.kompendium.models.oas.OpenApiSpecReferencable
import org.leafygreens.kompendium.models.oas.OpenApiSpecReferenceObject
import org.leafygreens.kompendium.models.oas.OpenApiSpecRequest
import org.leafygreens.kompendium.models.oas.OpenApiSpecResponse
@ -38,6 +33,7 @@ import org.leafygreens.kompendium.util.Helpers.getReferenceSlug
object Kompendium {
var errorMap: ErrorMap = emptyMap()
var cache: SchemaMap = emptyMap()
var openApiSpec = OpenApiSpec(
@ -48,47 +44,6 @@ object Kompendium {
var pathCalculator: PathCalculator = CorePathCalculator()
@OptIn(ExperimentalStdlibApi::class)
inline fun <reified TParam : Any, reified TResp : Any> Route.notarizedGet(
info: MethodInfo,
noinline body: PipelineInterceptor<Unit, ApplicationCall>
): Route = notarizationPreFlight<TParam, Unit, TResp>() { paramType, requestType, responseType ->
val path = pathCalculator.calculate(this)
openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() }
openApiSpec.paths[path]?.get = info.parseMethodInfo(HttpMethod.Get, paramType, requestType, responseType)
return method(HttpMethod.Get) { handle(body) }
}
inline fun <reified TParam : Any, reified TReq : Any, reified TResp : Any> Route.notarizedPost(
info: MethodInfo,
noinline body: PipelineInterceptor<Unit, ApplicationCall>
): Route = notarizationPreFlight<TParam, TReq, TResp>() { paramType, requestType, responseType ->
val path = pathCalculator.calculate(this)
openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() }
openApiSpec.paths[path]?.post = info.parseMethodInfo(HttpMethod.Post, paramType, requestType, responseType)
return method(HttpMethod.Post) { handle(body) }
}
inline fun <reified TParam : Any, reified TReq : Any, reified TResp : Any> Route.notarizedPut(
info: MethodInfo,
noinline body: PipelineInterceptor<Unit, ApplicationCall>,
): Route = notarizationPreFlight<TParam, TReq, TResp>() { paramType, requestType, responseType ->
val path = pathCalculator.calculate(this)
openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() }
openApiSpec.paths[path]?.put = info.parseMethodInfo(HttpMethod.Put, paramType, requestType, responseType)
return method(HttpMethod.Put) { handle(body) }
}
inline fun <reified TParam : Any, reified TResp : Any> Route.notarizedDelete(
info: MethodInfo,
noinline body: PipelineInterceptor<Unit, ApplicationCall>
): Route = notarizationPreFlight<TParam, Unit, TResp> { paramType, requestType, responseType ->
val path = pathCalculator.calculate(this)
openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() }
openApiSpec.paths[path]?.delete = info.parseMethodInfo(HttpMethod.Delete, paramType, requestType, responseType)
return method(HttpMethod.Delete) { handle(body) }
}
// TODO here down is a mess, needs refactor once core functionality is in place
fun MethodInfo.parseMethodInfo(
method: HttpMethod,
@ -101,7 +56,18 @@ object Kompendium {
tags = this.tags,
deprecated = this.deprecated,
parameters = paramType.toParameterSpec(),
responses = responseType.toResponseSpec(responseInfo)?.let { mapOf(it) },
responses = responseType.toResponseSpec(responseInfo)?.let { mapOf(it) }.let {
when (it) {
null -> {
val throwables = parseThrowables(canThrow)
when (throwables.isEmpty()) {
true -> null
false -> throwables
}
}
else -> it.plus(parseThrowables(canThrow))
}
},
requestBody = if (method != HttpMethod.Get) requestType.toRequestSpec(requestInfo) else null,
security = if (this.securitySchemes.isNotEmpty()) listOf(
// TODO support scopes
@ -109,18 +75,15 @@ object Kompendium {
) else null
)
@OptIn(ExperimentalStdlibApi::class)
inline fun <reified TParam : Any, reified TReq : Any, reified TResp : Any> notarizationPreFlight(
block: (KType, KType, KType) -> Route
): Route {
cache = generateKontent<TResp>(cache)
cache = generateKontent<TReq>(cache)
cache = generateParameterKontent<TParam>(cache)
openApiSpec.components.schemas.putAll(cache)
val requestType = typeOf<TReq>()
val responseType = typeOf<TResp>()
val paramType = typeOf<TParam>()
return block.invoke(paramType, requestType, responseType)
private fun parseThrowables(throwables: Set<KClass<*>>): Map<Int, OpenApiSpecReferencable> = throwables.mapNotNull {
errorMap[it.createType()]
}.toMap()
fun ResponseInfo.parseErrorInfo(
errorType: KType,
responseType: KType
) {
errorMap = errorMap.plus(errorType to responseType.toResponseSpec(this))
}
// TODO These two lookin' real similar 👀 Combine?

View File

@ -0,0 +1,33 @@
package org.leafygreens.kompendium
import io.ktor.routing.Route
import kotlin.reflect.KType
import kotlin.reflect.typeOf
object KompendiumPreFlight {
@OptIn(ExperimentalStdlibApi::class)
inline fun <reified TParam : Any, reified TReq : Any, reified TResp : Any> methodNotarizationPreFlight(
block: (KType, KType, KType) -> Route
): Route {
Kompendium.cache = Kontent.generateKontent<TResp>(Kompendium.cache)
Kompendium.cache = Kontent.generateKontent<TReq>(Kompendium.cache)
Kompendium.cache = Kontent.generateParameterKontent<TParam>(Kompendium.cache)
Kompendium.openApiSpec.components.schemas.putAll(Kompendium.cache)
val requestType = typeOf<TReq>()
val responseType = typeOf<TResp>()
val paramType = typeOf<TParam>()
return block.invoke(paramType, requestType, responseType)
}
@OptIn(ExperimentalStdlibApi::class)
inline fun <reified TErr: Throwable, reified TResp : Any> errorNotarizationPreFlight(
block: (KType, KType) -> Unit
) {
Kompendium.cache = Kontent.generateKontent<TResp>(Kompendium.cache)
Kompendium.openApiSpec.components.schemas.putAll(Kompendium.cache)
val errorType = typeOf<TErr>()
val responseType = typeOf<TResp>()
return block.invoke(errorType, responseType)
}
}

View File

@ -0,0 +1,76 @@
package org.leafygreens.kompendium
import io.ktor.application.ApplicationCall
import io.ktor.features.StatusPages
import io.ktor.http.HttpMethod
import io.ktor.routing.Route
import io.ktor.routing.method
import io.ktor.util.pipeline.PipelineContext
import io.ktor.util.pipeline.PipelineInterceptor
import org.leafygreens.kompendium.Kompendium.parseErrorInfo
import org.leafygreens.kompendium.Kompendium.parseMethodInfo
import org.leafygreens.kompendium.KompendiumPreFlight.errorNotarizationPreFlight
import org.leafygreens.kompendium.models.meta.MethodInfo
import org.leafygreens.kompendium.models.meta.ResponseInfo
import org.leafygreens.kompendium.models.oas.OpenApiSpecPathItem
object Notarized {
@OptIn(ExperimentalStdlibApi::class)
inline fun <reified TParam : Any, reified TResp : Any> Route.notarizedGet(
info: MethodInfo,
noinline body: PipelineInterceptor<Unit, ApplicationCall>
): Route =
KompendiumPreFlight.methodNotarizationPreFlight<TParam, Unit, TResp>() { paramType, requestType, responseType ->
val path = Kompendium.pathCalculator.calculate(this)
Kompendium.openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() }
Kompendium.openApiSpec.paths[path]?.get =
info.parseMethodInfo(HttpMethod.Get, paramType, requestType, responseType)
return method(HttpMethod.Get) { handle(body) }
}
inline fun <reified TParam : Any, reified TReq : Any, reified TResp : Any> Route.notarizedPost(
info: MethodInfo,
noinline body: PipelineInterceptor<Unit, ApplicationCall>
): Route =
KompendiumPreFlight.methodNotarizationPreFlight<TParam, TReq, TResp>() { paramType, requestType, responseType ->
val path = Kompendium.pathCalculator.calculate(this)
Kompendium.openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() }
Kompendium.openApiSpec.paths[path]?.post =
info.parseMethodInfo(HttpMethod.Post, paramType, requestType, responseType)
return method(HttpMethod.Post) { handle(body) }
}
inline fun <reified TParam : Any, reified TReq : Any, reified TResp : Any> Route.notarizedPut(
info: MethodInfo,
noinline body: PipelineInterceptor<Unit, ApplicationCall>,
): Route =
KompendiumPreFlight.methodNotarizationPreFlight<TParam, TReq, TResp>() { paramType, requestType, responseType ->
val path = Kompendium.pathCalculator.calculate(this)
Kompendium.openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() }
Kompendium.openApiSpec.paths[path]?.put =
info.parseMethodInfo(HttpMethod.Put, paramType, requestType, responseType)
return method(HttpMethod.Put) { handle(body) }
}
inline fun <reified TParam : Any, reified TResp : Any> Route.notarizedDelete(
info: MethodInfo,
noinline body: PipelineInterceptor<Unit, ApplicationCall>
): Route =
KompendiumPreFlight.methodNotarizationPreFlight<TParam, Unit, TResp> { paramType, requestType, responseType ->
val path = Kompendium.pathCalculator.calculate(this)
Kompendium.openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() }
Kompendium.openApiSpec.paths[path]?.delete =
info.parseMethodInfo(HttpMethod.Delete, paramType, requestType, responseType)
return method(HttpMethod.Delete) { handle(body) }
}
inline fun <reified TErr : Throwable, reified TResp : Any> StatusPages.Configuration.notarizedException(
info: ResponseInfo,
noinline handler: suspend PipelineContext<Unit, ApplicationCall>.(TErr) -> Unit
) = errorNotarizationPreFlight<TErr, TResp>() { errorType, responseType ->
info.parseErrorInfo(errorType, responseType)
exception(handler)
}
}

View File

@ -0,0 +1,6 @@
package org.leafygreens.kompendium.models.meta
import kotlin.reflect.KType
import org.leafygreens.kompendium.models.oas.OpenApiSpecResponse
typealias ErrorMap = Map<KType, Pair<Int, OpenApiSpecResponse>?>

View File

@ -1,5 +1,7 @@
package org.leafygreens.kompendium.models.meta
import kotlin.reflect.KClass
// TODO Seal and extend by method type?
data class MethodInfo(
val summary: String,
@ -8,5 +10,6 @@ data class MethodInfo(
val requestInfo: RequestInfo? = null,
val tags: Set<String> = emptySet(),
val deprecated: Boolean = false,
val securitySchemes: Set<String> = emptySet()
val securitySchemes: Set<String> = emptySet(),
val canThrow: Set<KClass<*>> = emptySet()
)

View File

@ -1,7 +1,7 @@
package org.leafygreens.kompendium.models.meta
data class ResponseInfo(
val status: Int, // TODO How to handle error codes?
val status: Int,
val description: String,
val mediaTypes: List<String> = listOf("application/json")
)

View File

@ -6,6 +6,7 @@ import io.ktor.application.Application
import io.ktor.application.call
import io.ktor.application.install
import io.ktor.features.ContentNegotiation
import io.ktor.features.StatusPages
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.jackson.jackson
@ -19,10 +20,11 @@ import java.net.URI
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import org.leafygreens.kompendium.Kompendium.notarizedDelete
import org.leafygreens.kompendium.Kompendium.notarizedGet
import org.leafygreens.kompendium.Kompendium.notarizedPost
import org.leafygreens.kompendium.Kompendium.notarizedPut
import org.leafygreens.kompendium.Notarized.notarizedDelete
import org.leafygreens.kompendium.Notarized.notarizedException
import org.leafygreens.kompendium.Notarized.notarizedGet
import org.leafygreens.kompendium.Notarized.notarizedPost
import org.leafygreens.kompendium.Notarized.notarizedPut
import org.leafygreens.kompendium.annotations.QueryParam
import org.leafygreens.kompendium.models.meta.MethodInfo
import org.leafygreens.kompendium.models.meta.RequestInfo
@ -34,6 +36,7 @@ import org.leafygreens.kompendium.models.oas.OpenApiSpecServer
import org.leafygreens.kompendium.routes.openApi
import org.leafygreens.kompendium.routes.redoc
import org.leafygreens.kompendium.util.ComplexRequest
import org.leafygreens.kompendium.util.ExceptionResponse
import org.leafygreens.kompendium.util.KompendiumHttpCodes
import org.leafygreens.kompendium.util.TestCreatedResponse
import org.leafygreens.kompendium.util.TestData
@ -374,6 +377,42 @@ internal class KompendiumTest {
}
}
@Test
fun `Generates additional responses when passed a throwable`() {
withTestApplication({
statusPageModule()
configModule()
docs()
notarizedGetWithNotarizedException()
}) {
// do
val json = handleRequest(HttpMethod.Get, "/openapi.json").response.content
// expect
val expected = TestData.getFileSnapshot("notarized_get_with_exception_response.json").trim()
assertEquals(expected, json, "The received json spec should match the expected content")
}
}
@Test
fun `Generates additional responses when passed multiple throwables`() {
withTestApplication({
statusPageMultiExceptions()
configModule()
docs()
notarizedGetWithMultipleThrowables()
}) {
// do
val json = handleRequest(HttpMethod.Get, "/openapi.json").response.content
// expect
val expected = TestData.getFileSnapshot("notarized_get_with_multiple_exception_responses.json").trim()
assertEquals(expected, json, "The received json spec should match the expected content")
}
}
private companion object {
val testGetResponse = ResponseInfo(KompendiumHttpCodes.OK, "A Successful Endeavor")
val testPostResponse = ResponseInfo(KompendiumHttpCodes.CREATED, "A Successful Endeavor")
@ -381,6 +420,12 @@ internal class KompendiumTest {
ResponseInfo(KompendiumHttpCodes.NO_CONTENT, "A Successful Endeavor", mediaTypes = emptyList())
val testRequest = RequestInfo("A Test request")
val testGetInfo = MethodInfo("Another get test", "testing more", testGetResponse)
val testGetWithException = testGetInfo.copy(
canThrow = setOf(Exception::class)
)
val testGetWithMultipleExceptions = testGetInfo.copy(
canThrow = setOf(AccessDeniedException::class, Exception::class)
)
val testPostInfo = MethodInfo("Test post endpoint", "Post your tests here!", testPostResponse, testRequest)
val testPutInfo = MethodInfo("Test put endpoint", "Put your tests here!", testPostResponse, testRequest)
val testDeleteInfo = MethodInfo("Test delete endpoint", "testing my deletes", testDeleteResponse)
@ -396,6 +441,45 @@ internal class KompendiumTest {
}
}
private fun Application.statusPageModule() {
install(StatusPages) {
notarizedException<Exception, ExceptionResponse>(info = ResponseInfo(400, "Bad Things Happened")) {
call.respond(HttpStatusCode.BadRequest, ExceptionResponse("Why you do dis?"))
}
}
}
private fun Application.statusPageMultiExceptions() {
install(StatusPages) {
notarizedException<AccessDeniedException, Unit>(info = ResponseInfo(403, "New API who dis?")) {
call.respond(HttpStatusCode.Forbidden)
}
notarizedException<Exception, ExceptionResponse>(info = ResponseInfo(400, "Bad Things Happened")) {
call.respond(HttpStatusCode.BadRequest, ExceptionResponse("Why you do dis?"))
}
}
}
private fun Application.notarizedGetWithNotarizedException() {
routing {
route("/test") {
notarizedGet<TestParams, TestResponse>(testGetWithException) {
error("something terrible has happened!")
}
}
}
}
private fun Application.notarizedGetWithMultipleThrowables() {
routing {
route("/test") {
notarizedGet<TestParams, TestResponse>(testGetWithMultipleExceptions) {
error("something terrible has happened!")
}
}
}
}
private fun Application.notarizedGetModule() {
routing {
route("/test") {

View File

@ -71,3 +71,5 @@ sealed class TestSealedClass(open val a: String)
data class SimpleTSC(val b: Int) : TestSealedClass("hey")
open class MediumTSC(override val a: String, val b: Int) : TestSealedClass(a)
data class WildTSC(val c: Boolean, val d: String, val e: Int) : MediumTSC(d, e)
data class ExceptionResponse(val message: String)

View File

@ -0,0 +1,104 @@
{
"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" : {
"get" : {
"tags" : [ ],
"summary" : "Another get test",
"description" : "testing more",
"parameters" : [ {
"name" : "a",
"in" : "path",
"schema" : {
"$ref" : "#/components/schemas/String"
},
"required" : true,
"deprecated" : false
}, {
"name" : "aa",
"in" : "query",
"schema" : {
"$ref" : "#/components/schemas/Int"
},
"required" : true,
"deprecated" : false
} ],
"responses" : {
"200" : {
"description" : "A Successful Endeavor",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/TestResponse"
}
}
}
},
"400" : {
"description" : "Bad Things Happened",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ExceptionResponse"
}
}
}
}
},
"deprecated" : false
}
}
},
"components" : {
"schemas" : {
"String" : {
"type" : "string"
},
"ExceptionResponse" : {
"properties" : {
"message" : {
"$ref" : "#/components/schemas/String"
}
},
"type" : "object"
},
"TestResponse" : {
"properties" : {
"c" : {
"$ref" : "#/components/schemas/String"
}
},
"type" : "object"
},
"Int" : {
"format" : "int32",
"type" : "integer"
}
},
"securitySchemes" : { }
},
"security" : [ ],
"tags" : [ ]
}

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" : {
"get" : {
"tags" : [ ],
"summary" : "Another get test",
"description" : "testing more",
"parameters" : [ {
"name" : "a",
"in" : "path",
"schema" : {
"$ref" : "#/components/schemas/String"
},
"required" : true,
"deprecated" : false
}, {
"name" : "aa",
"in" : "query",
"schema" : {
"$ref" : "#/components/schemas/Int"
},
"required" : true,
"deprecated" : false
} ],
"responses" : {
"200" : {
"description" : "A Successful Endeavor",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/TestResponse"
}
}
}
},
"403" : {
"description" : "New API who dis?"
},
"400" : {
"description" : "Bad Things Happened",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/ExceptionResponse"
}
}
}
}
},
"deprecated" : false
}
}
},
"components" : {
"schemas" : {
"String" : {
"type" : "string"
},
"ExceptionResponse" : {
"properties" : {
"message" : {
"$ref" : "#/components/schemas/String"
}
},
"type" : "object"
},
"TestResponse" : {
"properties" : {
"c" : {
"$ref" : "#/components/schemas/String"
}
},
"type" : "object"
},
"Int" : {
"format" : "int32",
"type" : "integer"
}
},
"securitySchemes" : { }
},
"security" : [ ],
"tags" : [ ]
}