From 26ada3daad5ca3a51a3d10baf982390043318637 Mon Sep 17 00:00:00 2001 From: Ryan Brink <5607577+rgbrizzlehizzle@users.noreply.github.com> Date: Thu, 29 Apr 2021 19:08:45 -0400 Subject: [PATCH] Error Responses, PR Template, Code Refactor, and Test plugin update (#42) --- .github/pull_request_template.md | 30 +++++ CHANGELOG.md | 11 ++ README.md | 41 ++++++- build.gradle.kts | 21 ++++ gradle/libs.versions.toml | 2 +- .../kompendium/auth/KompendiumAuthTest.kt | 2 +- .../org/leafygreens/kompendium/Kompendium.kt | 87 ++++---------- .../kompendium/KompendiumPreFlight.kt | 33 ++++++ .../org/leafygreens/kompendium/Notarized.kt | 76 +++++++++++++ .../kompendium/models/meta/ErrorMap.kt | 6 + .../kompendium/models/meta/MethodInfo.kt | 5 +- .../kompendium/models/meta/ResponseInfo.kt | 2 +- .../leafygreens/kompendium/KompendiumTest.kt | 92 ++++++++++++++- .../leafygreens/kompendium/util/TestModels.kt | 2 + ...notarized_get_with_exception_response.json | 104 +++++++++++++++++ ...get_with_multiple_exception_responses.json | 107 ++++++++++++++++++ .../leafygreens/kompendium/playground/Main.kt | 36 +++++- 17 files changed, 578 insertions(+), 79 deletions(-) create mode 100644 .github/pull_request_template.md create mode 100644 kompendium-core/src/main/kotlin/org/leafygreens/kompendium/KompendiumPreFlight.kt create mode 100644 kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Notarized.kt create mode 100644 kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/meta/ErrorMap.kt create mode 100644 kompendium-core/src/test/resources/notarized_get_with_exception_response.json create mode 100644 kompendium-core/src/test/resources/notarized_get_with_multiple_exception_responses.json diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..28aade892 --- /dev/null +++ b/.github/pull_request_template.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index f9049e891..1422358ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # 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 ### Added diff --git a/README.md b/README.md index ede31bd4d..30e7431ba 100644 --- a/README.md +++ b/README.md @@ -40,12 +40,13 @@ dependencies { ### Warning 🚨 Kompendium is still under active development ⚠️ There are a number of yet-to-be-implemented features, including -- Multiple Responses 📜 - Sealed Class / Polymorphic Support 😬 - Validation / Enforcement (❓👀❓) 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 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. + +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, 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) } } + install(StatusPages) { + notarizedException(exceptionResponseInfo) { + call.respond(HttpStatusCode.BadRequest, ExceptionResponse("Why you do dis?")) + } + } routing { openApi() redoc() @@ -125,9 +139,19 @@ fun Application.mainModule() { call.respondText { "heya" } } } + route("/error") { + notarizedGet(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` @@ -136,7 +160,7 @@ https://gist.github.com/rgbrizzlehizzle/b9544922f2e99a2815177f8bdbf80668 ### 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. At the moment, the basic and jwt authentication is only supported. @@ -188,11 +212,22 @@ Minimal Example: ```kotlin install(Webjars) routing { - openApi() + openApi(oas) 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 ### Kompendium as a singleton diff --git a/build.gradle.kts b/build.gradle.kts index 6245063f8..03ea5b9e6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("org.jetbrains.kotlin.jvm") version "1.4.32" 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 { @@ -21,6 +22,7 @@ allprojects { apply(plugin = "org.jetbrains.kotlin.jvm") apply(plugin = "io.gitlab.arturbosch.detekt") + apply(plugin = "com.adarshr.test-logger") apply(plugin = "idea") tasks.withType().configureEach { @@ -29,6 +31,25 @@ allprojects { } } + configure { + 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 { toolVersion = "1.16.0-RC2" config = files("${rootProject.projectDir}/detekt.yml") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2211fae21..80750334c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } # 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] ktor = [ "ktor-server-core", "ktor-server-netty", "ktor-jackson", "ktor-html-builder" ] diff --git a/kompendium-auth/src/test/kotlin/org/leafygreens/kompendium/auth/KompendiumAuthTest.kt b/kompendium-auth/src/test/kotlin/org/leafygreens/kompendium/auth/KompendiumAuthTest.kt index 5277b4aae..2dda9c1c8 100644 --- a/kompendium-auth/src/test/kotlin/org/leafygreens/kompendium/auth/KompendiumAuthTest.kt +++ b/kompendium-auth/src/test/kotlin/org/leafygreens/kompendium/auth/KompendiumAuthTest.kt @@ -12,7 +12,6 @@ import io.ktor.routing.* import io.ktor.server.testing.* import org.junit.Test import org.leafygreens.kompendium.Kompendium -import org.leafygreens.kompendium.Kompendium.notarizedGet import org.leafygreens.kompendium.auth.KompendiumAuth.notarizedBasic import org.leafygreens.kompendium.auth.KompendiumAuth.notarizedJwt import org.leafygreens.kompendium.auth.util.TestData @@ -27,6 +26,7 @@ import org.leafygreens.kompendium.routes.redoc import org.leafygreens.kompendium.util.KompendiumHttpCodes import kotlin.test.AfterTest import kotlin.test.assertEquals +import org.leafygreens.kompendium.Notarized.notarizedGet internal class KompendiumAuthTest { diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kompendium.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kompendium.kt index e914b4d36..607f54e3d 100644 --- a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kompendium.kt +++ b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kompendium.kt @@ -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 Route.notarizedGet( - info: MethodInfo, - noinline body: PipelineInterceptor - ): Route = notarizationPreFlight() { 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 Route.notarizedPost( - info: MethodInfo, - noinline body: PipelineInterceptor - ): Route = notarizationPreFlight() { 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 Route.notarizedPut( - info: MethodInfo, - noinline body: PipelineInterceptor, - ): Route = notarizationPreFlight() { 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 Route.notarizedDelete( - info: MethodInfo, - noinline body: PipelineInterceptor - ): Route = notarizationPreFlight { 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 notarizationPreFlight( - block: (KType, KType, KType) -> Route - ): Route { - cache = generateKontent(cache) - cache = generateKontent(cache) - cache = generateParameterKontent(cache) - openApiSpec.components.schemas.putAll(cache) - val requestType = typeOf() - val responseType = typeOf() - val paramType = typeOf() - return block.invoke(paramType, requestType, responseType) + private fun parseThrowables(throwables: Set>): Map = 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? diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/KompendiumPreFlight.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/KompendiumPreFlight.kt new file mode 100644 index 000000000..3ee7c71e5 --- /dev/null +++ b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/KompendiumPreFlight.kt @@ -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 methodNotarizationPreFlight( + block: (KType, KType, KType) -> Route + ): Route { + Kompendium.cache = Kontent.generateKontent(Kompendium.cache) + Kompendium.cache = Kontent.generateKontent(Kompendium.cache) + Kompendium.cache = Kontent.generateParameterKontent(Kompendium.cache) + Kompendium.openApiSpec.components.schemas.putAll(Kompendium.cache) + val requestType = typeOf() + val responseType = typeOf() + val paramType = typeOf() + return block.invoke(paramType, requestType, responseType) + } + + @OptIn(ExperimentalStdlibApi::class) + inline fun errorNotarizationPreFlight( + block: (KType, KType) -> Unit + ) { + Kompendium.cache = Kontent.generateKontent(Kompendium.cache) + Kompendium.openApiSpec.components.schemas.putAll(Kompendium.cache) + val errorType = typeOf() + val responseType = typeOf() + return block.invoke(errorType, responseType) + } +} diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Notarized.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Notarized.kt new file mode 100644 index 000000000..fdf97e954 --- /dev/null +++ b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Notarized.kt @@ -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 Route.notarizedGet( + info: MethodInfo, + noinline body: PipelineInterceptor + ): Route = + KompendiumPreFlight.methodNotarizationPreFlight() { 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 Route.notarizedPost( + info: MethodInfo, + noinline body: PipelineInterceptor + ): Route = + KompendiumPreFlight.methodNotarizationPreFlight() { 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 Route.notarizedPut( + info: MethodInfo, + noinline body: PipelineInterceptor, + ): Route = + KompendiumPreFlight.methodNotarizationPreFlight() { 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 Route.notarizedDelete( + info: MethodInfo, + noinline body: PipelineInterceptor + ): Route = + KompendiumPreFlight.methodNotarizationPreFlight { 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 StatusPages.Configuration.notarizedException( + info: ResponseInfo, + noinline handler: suspend PipelineContext.(TErr) -> Unit + ) = errorNotarizationPreFlight() { errorType, responseType -> + info.parseErrorInfo(errorType, responseType) + exception(handler) + } + +} diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/meta/ErrorMap.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/meta/ErrorMap.kt new file mode 100644 index 000000000..f7e34d88e --- /dev/null +++ b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/meta/ErrorMap.kt @@ -0,0 +1,6 @@ +package org.leafygreens.kompendium.models.meta + +import kotlin.reflect.KType +import org.leafygreens.kompendium.models.oas.OpenApiSpecResponse + +typealias ErrorMap = Map?> diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/meta/MethodInfo.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/meta/MethodInfo.kt index 43c8c3c9d..b2031bad1 100644 --- a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/meta/MethodInfo.kt +++ b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/meta/MethodInfo.kt @@ -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 = emptySet(), val deprecated: Boolean = false, - val securitySchemes: Set = emptySet() + val securitySchemes: Set = emptySet(), + val canThrow: Set> = emptySet() ) diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/meta/ResponseInfo.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/meta/ResponseInfo.kt index 018141f39..f8b509445 100644 --- a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/meta/ResponseInfo.kt +++ b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/meta/ResponseInfo.kt @@ -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 = listOf("application/json") ) diff --git a/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/KompendiumTest.kt b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/KompendiumTest.kt index 5fb43e62f..f8ed8989f 100644 --- a/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/KompendiumTest.kt +++ b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/KompendiumTest.kt @@ -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(info = ResponseInfo(400, "Bad Things Happened")) { + call.respond(HttpStatusCode.BadRequest, ExceptionResponse("Why you do dis?")) + } + } + } + + private fun Application.statusPageMultiExceptions() { + install(StatusPages) { + notarizedException(info = ResponseInfo(403, "New API who dis?")) { + call.respond(HttpStatusCode.Forbidden) + } + notarizedException(info = ResponseInfo(400, "Bad Things Happened")) { + call.respond(HttpStatusCode.BadRequest, ExceptionResponse("Why you do dis?")) + } + } + } + + private fun Application.notarizedGetWithNotarizedException() { + routing { + route("/test") { + notarizedGet(testGetWithException) { + error("something terrible has happened!") + } + } + } + } + + private fun Application.notarizedGetWithMultipleThrowables() { + routing { + route("/test") { + notarizedGet(testGetWithMultipleExceptions) { + error("something terrible has happened!") + } + } + } + } + private fun Application.notarizedGetModule() { routing { route("/test") { diff --git a/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/TestModels.kt b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/TestModels.kt index ea4b38c4e..a07c89619 100644 --- a/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/TestModels.kt +++ b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/TestModels.kt @@ -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) diff --git a/kompendium-core/src/test/resources/notarized_get_with_exception_response.json b/kompendium-core/src/test/resources/notarized_get_with_exception_response.json new file mode 100644 index 000000000..cf131b17b --- /dev/null +++ b/kompendium-core/src/test/resources/notarized_get_with_exception_response.json @@ -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" : [ ] +} diff --git a/kompendium-core/src/test/resources/notarized_get_with_multiple_exception_responses.json b/kompendium-core/src/test/resources/notarized_get_with_multiple_exception_responses.json new file mode 100644 index 000000000..e17dd3f56 --- /dev/null +++ b/kompendium-core/src/test/resources/notarized_get_with_multiple_exception_responses.json @@ -0,0 +1,107 @@ +{ + "openapi" : "3.0.3", + "info" : { + "title" : "Test API", + "version" : "1.33.7", + "description" : "An amazing, fully-ish \uD83D\uDE09 generated API spec", + "termsOfService" : "https://example.com", + "contact" : { + "name" : "Homer Simpson", + "url" : "https://gph.is/1NPUDiM", + "email" : "chunkylover53@aol.com" + }, + "license" : { + "name" : "MIT", + "url" : "https://github.com/lg-backbone/kompendium/blob/main/LICENSE" + } + }, + "servers" : [ { + "url" : "https://myawesomeapi.com", + "description" : "Production instance of my API" + }, { + "url" : "https://staging.myawesomeapi.com", + "description" : "Where the fun stuff happens" + } ], + "paths" : { + "/test" : { + "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" : [ ] +} diff --git a/kompendium-playground/src/main/kotlin/org/leafygreens/kompendium/playground/Main.kt b/kompendium-playground/src/main/kotlin/org/leafygreens/kompendium/playground/Main.kt index 868b4171b..64fab56f1 100644 --- a/kompendium-playground/src/main/kotlin/org/leafygreens/kompendium/playground/Main.kt +++ b/kompendium-playground/src/main/kotlin/org/leafygreens/kompendium/playground/Main.kt @@ -6,9 +6,10 @@ import io.ktor.application.Application import io.ktor.application.call import io.ktor.application.install import io.ktor.auth.Authentication -import io.ktor.auth.authenticate import io.ktor.auth.UserIdPrincipal +import io.ktor.auth.authenticate import io.ktor.features.ContentNegotiation +import io.ktor.features.StatusPages import io.ktor.http.HttpStatusCode import io.ktor.jackson.jackson import io.ktor.response.respond @@ -20,14 +21,15 @@ import io.ktor.server.netty.Netty import io.ktor.webjars.Webjars import java.net.URI import org.leafygreens.kompendium.Kompendium -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.auth.KompendiumAuth.notarizedBasic +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.KompendiumField import org.leafygreens.kompendium.annotations.PathParam 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.RequestInfo 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.testSingleDeleteInfo 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.testSinglePutInfo import org.leafygreens.kompendium.routes.openApi @@ -105,6 +108,16 @@ fun Application.mainModule() { } } install(Webjars) + install(StatusPages) { + notarizedException( + info = ResponseInfo( + KompendiumHttpCodes.BAD_REQUEST, + "Bad Things Happened" + ) + ) { + call.respond(HttpStatusCode.BadRequest, ExceptionResponse("Why you do dis?")) + } + } featuresInstalled = true } routing { @@ -139,6 +152,11 @@ fun Application.mainModule() { } } } + route("/error") { + notarizedGet(testSingleGetInfoWithThrowable) { + error("bad things just happened") + } + } } } @@ -163,6 +181,8 @@ data class ExampleRequest( data class ExampleResponse(val c: String) +data class ExceptionResponse(val message: String) + data class ExampleCreatedResponse(val id: Int, val c: String) object KompendiumTOC { @@ -184,6 +204,10 @@ object KompendiumTOC { description = "Returns a different sample" ) ) + val testSingleGetInfoWithThrowable = testSingleGetInfo.copy( + summary = "Show me the error baby 🙏", + canThrow = setOf(Exception::class) + ) val testSinglePostInfo = MethodInfo( summary = "Test post endpoint", description = "Post your tests here!",