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

30
.github/pull_request_template.md vendored Normal file
View File

@ -0,0 +1,30 @@
# Description
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
Closes # (issue)
## Type of change
Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
# How Has This Been Tested?
Please describe the tests that you ran to verify your changes.
# Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] I have updated the CHANGELOG and bumped the version
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream modules

View File

@ -1,5 +1,16 @@
# Changelog # Changelog
## [0.7.0] - April 29th, 2021
### Added
- `notarizedException` for notarizing `StatusPage` handlers 🎉
- `com.adarshr.test-logger` Gradle plugin for improved test output clarity and insight
### Changed
- Refactored `kompendium-core` to break up the `Kompendium` object into slightly more manageable chunks
## [0.6.2] - April 23rd, 2021 ## [0.6.2] - April 23rd, 2021
### Added ### Added

View File

@ -40,12 +40,13 @@ dependencies {
### Warning 🚨 ### Warning 🚨
Kompendium is still under active development ⚠️ There are a number of yet-to-be-implemented features, including Kompendium is still under active development ⚠️ There are a number of yet-to-be-implemented features, including
- Multiple Responses 📜
- Sealed Class / Polymorphic Support 😬 - Sealed Class / Polymorphic Support 😬
- Validation / Enforcement (❓👀❓) - Validation / Enforcement (❓👀❓)
If you have a feature that is not listed here, please open an issue! If you have a feature that is not listed here, please open an issue!
In addition, if you find any 🐞😱 please open an issue as well!
### Notarized Routes ### Notarized Routes
Kompendium introduces the concept of `notarized` HTTP methods. That is, for all your `GET`, `POST`, `PUT`, and `DELETE` Kompendium introduces the concept of `notarized` HTTP methods. That is, for all your `GET`, `POST`, `PUT`, and `DELETE`
@ -59,6 +60,14 @@ will consume are
`GET` and `DELETE` take `TParam` and `TResp` while `PUT` and `POST` take all three. `GET` and `DELETE` take `TParam` and `TResp` while `PUT` and `POST` take all three.
In addition to standard HTTP Methods, Kompendium also introduced the concept of `notarizedExceptions`. Using the `StatusPage`
extension, users can notarize all handled exceptions, along with their respective HTTP codes and response types.
Exceptions that have been `notarized` require two types as supplemental information
- `TErr`: Used to notarize the exception being handled by this use case. Used for matching responses at the route level.
- `TResp`: Same as above, this dictates the expected return type of the error response.
In keeping with minimal invasion, these extension methods all consume the same code block as a standard Ktor route method, In keeping with minimal invasion, these extension methods all consume the same code block as a standard Ktor route method,
meaning that swapping in a default Ktor route and a Kompendium `notarized` route is as simple as a single method change. meaning that swapping in a default Ktor route and a Kompendium `notarized` route is as simple as a single method change.
@ -102,6 +111,11 @@ fun Application.mainModule() {
setSerializationInclusion(JsonInclude.Include.NON_NULL) setSerializationInclusion(JsonInclude.Include.NON_NULL)
} }
} }
install(StatusPages) {
notarizedException<Exception, ExceptionResponse>(exceptionResponseInfo) {
call.respond(HttpStatusCode.BadRequest, ExceptionResponse("Why you do dis?"))
}
}
routing { routing {
openApi() openApi()
redoc() redoc()
@ -125,9 +139,19 @@ fun Application.mainModule() {
call.respondText { "heya" } call.respondText { "heya" }
} }
} }
route("/error") {
notarizedGet<Unit, ExampleResponse>(testSingleGetInfoWithThrowable) {
error("bad things just happened")
}
}
} }
} }
} }
val testSingleGetInfoWithThrowable = testSingleGetInfo.copy(
summary = "Show me the error baby 🙏",
canThrow = setOf(Exception::class) // Must match an exception that has been notarized in the `StatusPages`
)
``` ```
When run in the playground, this would output the following at `/openapi.json` When run in the playground, this would output the following at `/openapi.json`
@ -136,7 +160,7 @@ https://gist.github.com/rgbrizzlehizzle/b9544922f2e99a2815177f8bdbf80668
### Kompendium Auth and security schemes ### Kompendium Auth and security schemes
There is a seperate library to handle security schemes: `kompendium-auth`. There is a separate library to handle security schemes: `kompendium-auth`.
This needs to be added to your project as dependency. This needs to be added to your project as dependency.
At the moment, the basic and jwt authentication is only supported. At the moment, the basic and jwt authentication is only supported.
@ -188,11 +212,22 @@ Minimal Example:
```kotlin ```kotlin
install(Webjars) install(Webjars)
routing { routing {
openApi() openApi(oas)
swaggerUI() swaggerUI()
} }
``` ```
### Enabling ReDoc
Unlike swagger, redoc is provided (perhaps confusingly, in the `core` module). This means out of the box with `kompendium-core`, you can add
[ReDoc](https://github.com/Redocly/redoc) as follows
```kotlin
routing {
openApi(oas)
redoc(oas)
}
```
## Limitations ## Limitations
### Kompendium as a singleton ### Kompendium as a singleton

View File

@ -1,6 +1,7 @@
plugins { plugins {
id("org.jetbrains.kotlin.jvm") version "1.4.32" apply false id("org.jetbrains.kotlin.jvm") version "1.4.32" apply false
id("io.gitlab.arturbosch.detekt") version "1.16.0-RC2" apply false id("io.gitlab.arturbosch.detekt") version "1.16.0-RC2" apply false
id("com.adarshr.test-logger") version "3.0.0" apply false
} }
allprojects { allprojects {
@ -21,6 +22,7 @@ allprojects {
apply(plugin = "org.jetbrains.kotlin.jvm") apply(plugin = "org.jetbrains.kotlin.jvm")
apply(plugin = "io.gitlab.arturbosch.detekt") apply(plugin = "io.gitlab.arturbosch.detekt")
apply(plugin = "com.adarshr.test-logger")
apply(plugin = "idea") apply(plugin = "idea")
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach { tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
@ -29,6 +31,25 @@ allprojects {
} }
} }
configure<com.adarshr.gradle.testlogger.TestLoggerExtension> {
setTheme("standard")
setLogLevel("lifecycle")
showExceptions = true
showStackTraces = true
showFullStackTraces = false
showCauses = true
slowThreshold = 2000
showSummary = true
showSimpleNames = false
showPassed = true
showSkipped = true
showFailed = true
showStandardStreams = false
showPassedStandardStreams = true
showSkippedStandardStreams = true
showFailedStandardStreams = true
}
configure<io.gitlab.arturbosch.detekt.extensions.DetektExtension> { configure<io.gitlab.arturbosch.detekt.extensions.DetektExtension> {
toolVersion = "1.16.0-RC2" toolVersion = "1.16.0-RC2"
config = files("${rootProject.projectDir}/detekt.yml") config = files("${rootProject.projectDir}/detekt.yml")

View File

@ -21,7 +21,7 @@ logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.
logback-core = { group = "ch.qos.logback", name = "logback-core", version.ref = "logback" } logback-core = { group = "ch.qos.logback", name = "logback-core", version.ref = "logback" }
# webjars # webjars
webjars-swagger-ui = { group "org.webjars", name = "swagger-ui", version.ref = "swagger-ui" } webjars-swagger-ui = { group = "org.webjars", name = "swagger-ui", version.ref = "swagger-ui" }
[bundles] [bundles]
ktor = [ "ktor-server-core", "ktor-server-netty", "ktor-jackson", "ktor-html-builder" ] ktor = [ "ktor-server-core", "ktor-server-netty", "ktor-jackson", "ktor-html-builder" ]

View File

@ -12,7 +12,6 @@ import io.ktor.routing.*
import io.ktor.server.testing.* import io.ktor.server.testing.*
import org.junit.Test import org.junit.Test
import org.leafygreens.kompendium.Kompendium import org.leafygreens.kompendium.Kompendium
import org.leafygreens.kompendium.Kompendium.notarizedGet
import org.leafygreens.kompendium.auth.KompendiumAuth.notarizedBasic import org.leafygreens.kompendium.auth.KompendiumAuth.notarizedBasic
import org.leafygreens.kompendium.auth.KompendiumAuth.notarizedJwt import org.leafygreens.kompendium.auth.KompendiumAuth.notarizedJwt
import org.leafygreens.kompendium.auth.util.TestData import org.leafygreens.kompendium.auth.util.TestData
@ -27,6 +26,7 @@ import org.leafygreens.kompendium.routes.redoc
import org.leafygreens.kompendium.util.KompendiumHttpCodes import org.leafygreens.kompendium.util.KompendiumHttpCodes
import kotlin.test.AfterTest import kotlin.test.AfterTest
import kotlin.test.assertEquals import kotlin.test.assertEquals
import org.leafygreens.kompendium.Notarized.notarizedGet
internal class KompendiumAuthTest { internal class KompendiumAuthTest {

View File

@ -1,22 +1,17 @@
package org.leafygreens.kompendium package org.leafygreens.kompendium
import io.ktor.application.ApplicationCall
import io.ktor.http.HttpMethod 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.KClass
import kotlin.reflect.KType import kotlin.reflect.KType
import kotlin.reflect.full.createType
import kotlin.reflect.full.findAnnotation import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.memberProperties import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.javaField 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.CookieParam
import org.leafygreens.kompendium.annotations.HeaderParam import org.leafygreens.kompendium.annotations.HeaderParam
import org.leafygreens.kompendium.annotations.PathParam import org.leafygreens.kompendium.annotations.PathParam
import org.leafygreens.kompendium.annotations.QueryParam 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.MethodInfo
import org.leafygreens.kompendium.models.meta.RequestInfo import org.leafygreens.kompendium.models.meta.RequestInfo
import org.leafygreens.kompendium.models.meta.ResponseInfo 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.OpenApiSpecInfo
import org.leafygreens.kompendium.models.oas.OpenApiSpecMediaType import org.leafygreens.kompendium.models.oas.OpenApiSpecMediaType
import org.leafygreens.kompendium.models.oas.OpenApiSpecParameter 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.OpenApiSpecPathItemOperation
import org.leafygreens.kompendium.models.oas.OpenApiSpecReferencable
import org.leafygreens.kompendium.models.oas.OpenApiSpecReferenceObject import org.leafygreens.kompendium.models.oas.OpenApiSpecReferenceObject
import org.leafygreens.kompendium.models.oas.OpenApiSpecRequest import org.leafygreens.kompendium.models.oas.OpenApiSpecRequest
import org.leafygreens.kompendium.models.oas.OpenApiSpecResponse import org.leafygreens.kompendium.models.oas.OpenApiSpecResponse
@ -38,6 +33,7 @@ import org.leafygreens.kompendium.util.Helpers.getReferenceSlug
object Kompendium { object Kompendium {
var errorMap: ErrorMap = emptyMap()
var cache: SchemaMap = emptyMap() var cache: SchemaMap = emptyMap()
var openApiSpec = OpenApiSpec( var openApiSpec = OpenApiSpec(
@ -48,47 +44,6 @@ object Kompendium {
var pathCalculator: PathCalculator = CorePathCalculator() 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 // TODO here down is a mess, needs refactor once core functionality is in place
fun MethodInfo.parseMethodInfo( fun MethodInfo.parseMethodInfo(
method: HttpMethod, method: HttpMethod,
@ -101,7 +56,18 @@ object Kompendium {
tags = this.tags, tags = this.tags,
deprecated = this.deprecated, deprecated = this.deprecated,
parameters = paramType.toParameterSpec(), 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, requestBody = if (method != HttpMethod.Get) requestType.toRequestSpec(requestInfo) else null,
security = if (this.securitySchemes.isNotEmpty()) listOf( security = if (this.securitySchemes.isNotEmpty()) listOf(
// TODO support scopes // TODO support scopes
@ -109,18 +75,15 @@ object Kompendium {
) else null ) else null
) )
@OptIn(ExperimentalStdlibApi::class) private fun parseThrowables(throwables: Set<KClass<*>>): Map<Int, OpenApiSpecReferencable> = throwables.mapNotNull {
inline fun <reified TParam : Any, reified TReq : Any, reified TResp : Any> notarizationPreFlight( errorMap[it.createType()]
block: (KType, KType, KType) -> Route }.toMap()
): Route {
cache = generateKontent<TResp>(cache) fun ResponseInfo.parseErrorInfo(
cache = generateKontent<TReq>(cache) errorType: KType,
cache = generateParameterKontent<TParam>(cache) responseType: KType
openApiSpec.components.schemas.putAll(cache) ) {
val requestType = typeOf<TReq>() errorMap = errorMap.plus(errorType to responseType.toResponseSpec(this))
val responseType = typeOf<TResp>()
val paramType = typeOf<TParam>()
return block.invoke(paramType, requestType, responseType)
} }
// TODO These two lookin' real similar 👀 Combine? // 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 package org.leafygreens.kompendium.models.meta
import kotlin.reflect.KClass
// TODO Seal and extend by method type? // TODO Seal and extend by method type?
data class MethodInfo( data class MethodInfo(
val summary: String, val summary: String,
@ -8,5 +10,6 @@ data class MethodInfo(
val requestInfo: RequestInfo? = null, val requestInfo: RequestInfo? = null,
val tags: Set<String> = emptySet(), val tags: Set<String> = emptySet(),
val deprecated: Boolean = false, 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 package org.leafygreens.kompendium.models.meta
data class ResponseInfo( data class ResponseInfo(
val status: Int, // TODO How to handle error codes? val status: Int,
val description: String, val description: String,
val mediaTypes: List<String> = listOf("application/json") 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.call
import io.ktor.application.install import io.ktor.application.install
import io.ktor.features.ContentNegotiation import io.ktor.features.ContentNegotiation
import io.ktor.features.StatusPages
import io.ktor.http.HttpMethod import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.jackson.jackson import io.ktor.jackson.jackson
@ -19,10 +20,11 @@ import java.net.URI
import kotlin.test.AfterTest import kotlin.test.AfterTest
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import org.leafygreens.kompendium.Kompendium.notarizedDelete import org.leafygreens.kompendium.Notarized.notarizedDelete
import org.leafygreens.kompendium.Kompendium.notarizedGet import org.leafygreens.kompendium.Notarized.notarizedException
import org.leafygreens.kompendium.Kompendium.notarizedPost import org.leafygreens.kompendium.Notarized.notarizedGet
import org.leafygreens.kompendium.Kompendium.notarizedPut import org.leafygreens.kompendium.Notarized.notarizedPost
import org.leafygreens.kompendium.Notarized.notarizedPut
import org.leafygreens.kompendium.annotations.QueryParam import org.leafygreens.kompendium.annotations.QueryParam
import org.leafygreens.kompendium.models.meta.MethodInfo import org.leafygreens.kompendium.models.meta.MethodInfo
import org.leafygreens.kompendium.models.meta.RequestInfo 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.openApi
import org.leafygreens.kompendium.routes.redoc import org.leafygreens.kompendium.routes.redoc
import org.leafygreens.kompendium.util.ComplexRequest import org.leafygreens.kompendium.util.ComplexRequest
import org.leafygreens.kompendium.util.ExceptionResponse
import org.leafygreens.kompendium.util.KompendiumHttpCodes import org.leafygreens.kompendium.util.KompendiumHttpCodes
import org.leafygreens.kompendium.util.TestCreatedResponse import org.leafygreens.kompendium.util.TestCreatedResponse
import org.leafygreens.kompendium.util.TestData 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 { private companion object {
val testGetResponse = ResponseInfo(KompendiumHttpCodes.OK, "A Successful Endeavor") val testGetResponse = ResponseInfo(KompendiumHttpCodes.OK, "A Successful Endeavor")
val testPostResponse = ResponseInfo(KompendiumHttpCodes.CREATED, "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()) ResponseInfo(KompendiumHttpCodes.NO_CONTENT, "A Successful Endeavor", mediaTypes = emptyList())
val testRequest = RequestInfo("A Test request") val testRequest = RequestInfo("A Test request")
val testGetInfo = MethodInfo("Another get test", "testing more", testGetResponse) 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 testPostInfo = MethodInfo("Test post endpoint", "Post your tests here!", testPostResponse, testRequest)
val testPutInfo = MethodInfo("Test put endpoint", "Put 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) 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() { private fun Application.notarizedGetModule() {
routing { routing {
route("/test") { route("/test") {

View File

@ -71,3 +71,5 @@ sealed class TestSealedClass(open val a: String)
data class SimpleTSC(val b: Int) : TestSealedClass("hey") data class SimpleTSC(val b: Int) : TestSealedClass("hey")
open class MediumTSC(override val a: String, val b: Int) : TestSealedClass(a) 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 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" : [ ]
}

View File

@ -6,9 +6,10 @@ import io.ktor.application.Application
import io.ktor.application.call import io.ktor.application.call
import io.ktor.application.install import io.ktor.application.install
import io.ktor.auth.Authentication import io.ktor.auth.Authentication
import io.ktor.auth.authenticate
import io.ktor.auth.UserIdPrincipal import io.ktor.auth.UserIdPrincipal
import io.ktor.auth.authenticate
import io.ktor.features.ContentNegotiation import io.ktor.features.ContentNegotiation
import io.ktor.features.StatusPages
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.jackson.jackson import io.ktor.jackson.jackson
import io.ktor.response.respond import io.ktor.response.respond
@ -20,14 +21,15 @@ import io.ktor.server.netty.Netty
import io.ktor.webjars.Webjars import io.ktor.webjars.Webjars
import java.net.URI import java.net.URI
import org.leafygreens.kompendium.Kompendium import org.leafygreens.kompendium.Kompendium
import org.leafygreens.kompendium.Kompendium.notarizedDelete import org.leafygreens.kompendium.Notarized.notarizedDelete
import org.leafygreens.kompendium.Kompendium.notarizedGet import org.leafygreens.kompendium.Notarized.notarizedException
import org.leafygreens.kompendium.Kompendium.notarizedPost import org.leafygreens.kompendium.Notarized.notarizedGet
import org.leafygreens.kompendium.Kompendium.notarizedPut import org.leafygreens.kompendium.Notarized.notarizedPost
import org.leafygreens.kompendium.auth.KompendiumAuth.notarizedBasic import org.leafygreens.kompendium.Notarized.notarizedPut
import org.leafygreens.kompendium.annotations.KompendiumField import org.leafygreens.kompendium.annotations.KompendiumField
import org.leafygreens.kompendium.annotations.PathParam import org.leafygreens.kompendium.annotations.PathParam
import org.leafygreens.kompendium.annotations.QueryParam import org.leafygreens.kompendium.annotations.QueryParam
import org.leafygreens.kompendium.auth.KompendiumAuth.notarizedBasic
import org.leafygreens.kompendium.models.meta.MethodInfo import org.leafygreens.kompendium.models.meta.MethodInfo
import org.leafygreens.kompendium.models.meta.RequestInfo import org.leafygreens.kompendium.models.meta.RequestInfo
import org.leafygreens.kompendium.models.meta.ResponseInfo import org.leafygreens.kompendium.models.meta.ResponseInfo
@ -39,6 +41,7 @@ import org.leafygreens.kompendium.playground.KompendiumTOC.testAuthenticatedSing
import org.leafygreens.kompendium.playground.KompendiumTOC.testIdGetInfo import org.leafygreens.kompendium.playground.KompendiumTOC.testIdGetInfo
import org.leafygreens.kompendium.playground.KompendiumTOC.testSingleDeleteInfo import org.leafygreens.kompendium.playground.KompendiumTOC.testSingleDeleteInfo
import org.leafygreens.kompendium.playground.KompendiumTOC.testSingleGetInfo import org.leafygreens.kompendium.playground.KompendiumTOC.testSingleGetInfo
import org.leafygreens.kompendium.playground.KompendiumTOC.testSingleGetInfoWithThrowable
import org.leafygreens.kompendium.playground.KompendiumTOC.testSinglePostInfo import org.leafygreens.kompendium.playground.KompendiumTOC.testSinglePostInfo
import org.leafygreens.kompendium.playground.KompendiumTOC.testSinglePutInfo import org.leafygreens.kompendium.playground.KompendiumTOC.testSinglePutInfo
import org.leafygreens.kompendium.routes.openApi import org.leafygreens.kompendium.routes.openApi
@ -105,6 +108,16 @@ fun Application.mainModule() {
} }
} }
install(Webjars) install(Webjars)
install(StatusPages) {
notarizedException<Exception, ExceptionResponse>(
info = ResponseInfo(
KompendiumHttpCodes.BAD_REQUEST,
"Bad Things Happened"
)
) {
call.respond(HttpStatusCode.BadRequest, ExceptionResponse("Why you do dis?"))
}
}
featuresInstalled = true featuresInstalled = true
} }
routing { routing {
@ -139,6 +152,11 @@ fun Application.mainModule() {
} }
} }
} }
route("/error") {
notarizedGet<Unit, ExampleResponse>(testSingleGetInfoWithThrowable) {
error("bad things just happened")
}
}
} }
} }
@ -163,6 +181,8 @@ data class ExampleRequest(
data class ExampleResponse(val c: String) data class ExampleResponse(val c: String)
data class ExceptionResponse(val message: String)
data class ExampleCreatedResponse(val id: Int, val c: String) data class ExampleCreatedResponse(val id: Int, val c: String)
object KompendiumTOC { object KompendiumTOC {
@ -184,6 +204,10 @@ object KompendiumTOC {
description = "Returns a different sample" description = "Returns a different sample"
) )
) )
val testSingleGetInfoWithThrowable = testSingleGetInfo.copy(
summary = "Show me the error baby 🙏",
canThrow = setOf(Exception::class)
)
val testSinglePostInfo = MethodInfo( val testSinglePostInfo = MethodInfo(
summary = "Test post endpoint", summary = "Test post endpoint",
description = "Post your tests here!", description = "Post your tests here!",