V1 beta release time 🥳 (#48)

This commit is contained in:
Ryan Brink
2021-05-06 09:36:35 -04:00
committed by GitHub
parent f23016e878
commit c019f92cc0
22 changed files with 616 additions and 465 deletions

25
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: Publish to GitHub Packages
on:
release:
types:
- prereleased
- released
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-java@v2
with:
distribution: 'adopt'
java-version: '11'
- name: Cache Gradle packages
uses: actions/cache@v2
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle.kts') }}
restore-keys: ${{ runner.os }}-gradle
- name: Publish package
run: ./gradlew publish -Prelease=true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -1,5 +1,17 @@
# Changelog
### [1.0.0-beta] - May 6th, 2021
### Added
- Release action to package a release JAR 🍻
- EXTREME DOCUMENTATION 📜
### Changed
- Cleanup to test files
- Removes KompendiumHttpCodes in favor of Ktor HttpStatusCode
### [0.9.0] - May 5th, 2021
### Added

View File

@ -37,16 +37,6 @@ dependencies {
## In depth
### Warning 🚨
Kompendium is still under active development ⚠️ There are a number of yet-to-be-implemented features, including
- 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`
@ -204,3 +194,17 @@ it offers a seriously clean UX where the implementer doesn't need to worry about
drawback, however, is that you are limited to a single API per classpath.
If this is a blocker, please open a GitHub issue, and we can start to think out solutions!
## Future Work
Work on V1 of Kompendium has come to a close. This, however, does not mean it has achieved complete
parity with the OpenAPI feature spec, nor does it have all-of-the nice to have features that a truly next-gen API spec
should have. There are several outstanding features that have been added to the
[V2 Milestone](https://github.com/lg-backbone/kompendium/milestone/2). Among others, this includes
- Polymorphic support
- AsyncAPI Integration
- Field Validation
- MavenCentral Release
If you have a feature that you would like to see implemented that is not on this list, or discover a 🐞, please open
an issue [here](https://github.com/lg-backbone/kompendium/issues/new)

View File

@ -1,5 +1,5 @@
# Kompendium
project.version=0.9.0
project.version=1.0.0-beta
# Kotlin
kotlin.code.style=official
# Gradle

View File

@ -11,6 +11,7 @@ import io.ktor.auth.authenticate
import io.ktor.features.ContentNegotiation
import io.ktor.http.ContentType
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.jackson.jackson
import io.ktor.response.respondText
import io.ktor.routing.route
@ -31,7 +32,6 @@ import org.leafygreens.kompendium.models.meta.MethodInfo
import org.leafygreens.kompendium.models.meta.ResponseInfo
import org.leafygreens.kompendium.routes.openApi
import org.leafygreens.kompendium.routes.redoc
import org.leafygreens.kompendium.util.KompendiumHttpCodes
internal class KompendiumAuthTest {
@ -189,7 +189,7 @@ internal class KompendiumAuthTest {
}
private companion object {
val testGetResponse = ResponseInfo<TestResponse>(KompendiumHttpCodes.OK, "A Successful Endeavor")
val testGetResponse = ResponseInfo<TestResponse>(HttpStatusCode.OK, "A Successful Endeavor")
fun testGetInfo(vararg security: String) =
MethodInfo.GetInfo<TestParams, TestResponse>(
summary = "Another get test",

View File

@ -7,6 +7,9 @@ import org.leafygreens.kompendium.models.oas.OpenApiSpecInfo
import org.leafygreens.kompendium.path.CorePathCalculator
import org.leafygreens.kompendium.path.PathCalculator
/**
* Maintains all state for the Kompendium library
*/
object Kompendium {
var errorMap: ErrorMap = emptyMap()

View File

@ -4,8 +4,19 @@ import io.ktor.routing.Route
import kotlin.reflect.KType
import kotlin.reflect.typeOf
/**
* Functions are considered preflight when they are used to intercept a method ahead of running.
*/
object KompendiumPreFlight {
/**
* Performs all content analysis on the types provided to a notarized route and adds it to the top level spec
* @param TParam
* @param TReq
* @param TResp
* @param block The function to execute, provided type information of the parameters above
* @return [Route]
*/
@OptIn(ExperimentalStdlibApi::class)
inline fun <reified TParam : Any, reified TReq : Any, reified TResp : Any> methodNotarizationPreFlight(
block: (KType, KType, KType) -> Route
@ -20,6 +31,12 @@ object KompendiumPreFlight {
return block.invoke(paramType, requestType, responseType)
}
/**
* Performs all content analysis on the types provided to a notarized error and adds them to the top level spec.
* @param TErr
* @param TResp
* @param block The function to execute, provided type information of the parameters above
*/
@OptIn(ExperimentalStdlibApi::class)
inline fun <reified TErr: Throwable, reified TResp : Any> errorNotarizationPreFlight(
block: (KType, KType) -> Unit

View File

@ -21,10 +21,19 @@ import org.leafygreens.kompendium.util.Helpers.getReferenceSlug
import org.leafygreens.kompendium.util.Helpers.logged
import org.slf4j.LoggerFactory
/**
* Responsible for generating the schema map that is used to power all object references across the API Spec.
*/
object Kontent {
private val logger = LoggerFactory.getLogger(javaClass)
/**
* Analyzes a type [T] for its top-level and any nested schemas, and adds them to a [SchemaMap], if provided
* @param T type to analyze
* @param cache Existing schema map to append to
* @return an updated schema map containing all type information for [T]
*/
@OptIn(ExperimentalStdlibApi::class)
inline fun <reified T> generateKontent(
cache: SchemaMap = emptyMap()
@ -33,6 +42,12 @@ object Kontent {
return generateKTypeKontent(kontentType, cache)
}
/**
* Analyze a type [T], but filters out the top-level type
* @param T type to analyze
* @param cache Existing schema map to append to
* @return an updated schema map containing all type information for [T]
*/
@OptIn(ExperimentalStdlibApi::class)
inline fun <reified T> generateParameterKontent(
cache: SchemaMap = emptyMap()
@ -42,6 +57,11 @@ object Kontent {
.filterNot { (slug, _) -> slug == (kontentType.classifier as KClass<*>).simpleName }
}
/**
* Recursively fills schema map depending on [KType] classifier
* @param type [KType] to parse
* @param cache Existing schema map to append to
*/
fun generateKTypeKontent(
type: KType,
cache: SchemaMap = emptyMap()
@ -65,6 +85,11 @@ object Kontent {
}
}
/**
* In the event of an object type, this method will parse out individual fields to recursively aggregate object map.
* @param clazz Class of the object to analyze
* @param cache Existing schema map to append to
*/
private fun handleComplexType(clazz: KClass<*>, cache: SchemaMap): SchemaMap =
when (cache.containsKey(clazz.simpleName)) {
true -> {
@ -92,11 +117,22 @@ object Kontent {
}
}
/**
* Handler for when an [Enum] is encountered
* @param clazz Class of the object to analyze
* @param cache Existing schema map to append to
*/
private fun handleEnumType(clazz: KClass<*>, cache: SchemaMap): SchemaMap {
val options = clazz.java.enumConstants.map { it.toString() }.toSet()
return cache.plus(clazz.simpleName!! to EnumSchema(options))
}
/**
* Handler for when a [Map] is encountered
* @param type Map type information
* @param clazz Map class information
* @param cache Existing schema map to append to
*/
private fun handleMapType(type: KType, clazz: KClass<*>, cache: SchemaMap): SchemaMap {
logger.debug("Map detected for $type, generating schema and appending to cache")
val (keyType, valType) = type.arguments.map { it.type }
@ -112,6 +148,12 @@ object Kontent {
return updatedCache.plus(referenceName to schema)
}
/**
* Handler for when a [Collection] is encountered
* @param type Collection type information
* @param clazz Collection class information
* @param cache Existing schema map to append to
*/
private fun handleCollectionType(type: KType, clazz: KClass<*>, cache: SchemaMap): SchemaMap {
logger.debug("Collection detected for $type, generating schema and appending to cache")
val collectionType = type.arguments.first().type!!

View File

@ -26,7 +26,19 @@ import org.leafygreens.kompendium.util.Helpers
import org.leafygreens.kompendium.util.Helpers.getReferenceSlug
import org.leafygreens.kompendium.util.Helpers.getSimpleSlug
/**
* The MethodParser is responsible for converting route metadata and types into an OpenAPI compatible data class.
*/
object MethodParser {
/**
* Generates the OpenAPI Path spec from provided metadata
* @param info implementation of the [MethodInfo] sealed class
* @param paramType Type of `TParam`
* @param requestType Type of `TReq` if required
* @param responseType Type of `TResp`
* @return object representing the OpenAPI Path spec.
*/
fun parseMethodInfo(
info: MethodInfo<*, *>,
paramType: KType,
@ -61,19 +73,34 @@ object MethodParser {
) else null
)
private fun parseThrowables(throwables: Set<KClass<*>>): Map<Int, OpenApiSpecReferencable> = throwables.mapNotNull {
Kompendium.errorMap[it.createType()]
}.toMap()
fun <TResp> ResponseInfo<TResp>.parseErrorInfo(
/**
* Adds the error to the [Kompendium.errorMap] for reference in notarized routes.
* @param errorType [KType] of the throwable being handled
* @param responseType [KType] the type of the response sent in event of error
*/
fun ResponseInfo<*>.parseErrorInfo(
errorType: KType,
responseType: KType
) {
Kompendium.errorMap = Kompendium.errorMap.plus(errorType to responseType.toResponseSpec(this))
}
// TODO These two lookin' real similar 👀 Combine?
private fun <TReq> KType.toRequestSpec(requestInfo: RequestInfo<TReq>?): OpenApiSpecRequest<TReq>? =
/**
* Parses possible errors thrown by a route
* @param throwables Set of classes that can be thrown
* @return Mapping of status codes to their corresponding error spec
*/
private fun parseThrowables(throwables: Set<KClass<*>>): Map<Int, OpenApiSpecReferencable> = throwables.mapNotNull {
Kompendium.errorMap[it.createType()]
}.toMap()
/**
* Converts a [KType] to an [OpenApiSpecRequest]
* @receiver [KType] to convert
* @param requestInfo request metadata
* @return Will return a generated [OpenApiSpecRequest] if requestInfo is not null
*/
private fun KType.toRequestSpec(requestInfo: RequestInfo<*>?): OpenApiSpecRequest<*>? =
when (requestInfo) {
null -> null
else -> {
@ -84,18 +111,31 @@ object MethodParser {
}
}
private fun <TResp> KType.toResponseSpec(responseInfo: ResponseInfo<TResp>?): Pair<Int, OpenApiSpecResponse<TResp>>? =
/**
* Converts a [KType] to a pairing of http status code to [OpenApiSpecRequest]
* @receiver [KType] to convert
* @param responseInfo response metadata
* @return Will return a generated [Pair] if responseInfo is not null
*/
private fun KType.toResponseSpec(responseInfo: ResponseInfo<*>?): Pair<Int, OpenApiSpecResponse<*>>? =
when (responseInfo) {
null -> null // TODO again probably revisit this
null -> null
else -> {
val specResponse = OpenApiSpecResponse(
description = responseInfo.description,
content = resolveContent(responseInfo.mediaTypes, responseInfo.examples)
)
Pair(responseInfo.status, specResponse)
Pair(responseInfo.status.value, specResponse)
}
}
/**
* Generates MediaTypes along with any examples provided
* @receiver [KType] Type of the object
* @param mediaTypes list of acceptable http media types
* @param examples Mapping of named examples of valid bodies.
* @return Named mapping of media types.
*/
private fun <F> KType.resolveContent(
mediaTypes: List<String>,
examples: Map<String, F>
@ -111,6 +151,13 @@ object MethodParser {
} else null
}
/**
* Parses a type for all parameter information. All fields in the receiver
* must be annotated with [org.leafygreens.kompendium.annotations.KompendiumParam].
* @receiver type
* @return list of valid parameter specs as detailed by the [KType] members
* @throws [IllegalStateException] if the class could not be parsed properly
*/
private fun KType.toParameterSpec(): List<OpenApiSpecParameter> {
val clazz = classifier as KClass<*>
return clazz.memberProperties.map { prop ->
@ -131,6 +178,12 @@ object MethodParser {
}
}
/**
* Absolutely disgusting reflection to determine if a default value is available for a given property.
* @param clazz to which the property belongs
* @param prop the property in question
* @return The default value if found
*/
private fun getDefaultParameterValue(clazz: KClass<*>, prop: KProperty<*>): Any? {
val constructor = clazz.primaryConstructor
val parameterInQuestion = constructor
@ -152,6 +205,12 @@ object MethodParser {
return getterFunction.invoke(instance)
}
/**
* Allows the reflection invoker to populate a parameter map with values in order to sus out any default parameters.
* @param param Parameter to provide value for
* @return value of the proper type to match param
* @throws [IllegalStateException] if parameter type is not one of the basic types supported below.
*/
private fun defaultValueInjector(param: KParameter): Any = when (param.type.classifier) {
String::class -> "test"
Boolean::class -> false
@ -162,5 +221,4 @@ object MethodParser {
UUID::class -> UUID.randomUUID()
else -> error("Unsupported Type")
}
}

View File

@ -17,9 +17,20 @@ import org.leafygreens.kompendium.models.meta.MethodInfo.DeleteInfo
import org.leafygreens.kompendium.models.meta.ResponseInfo
import org.leafygreens.kompendium.models.oas.OpenApiSpecPathItem
/**
* Notarization methods are the primary way that a Ktor API using Kompendium differentiates
* from a default Ktor application. On instantiation, a notarized route, provided with the proper metadata,
* will reflectively analyze all pertinent data to build a corresponding OpenAPI entry.
*/
object Notarized {
@OptIn(ExperimentalStdlibApi::class)
/**
* Notarization for an HTTP GET request
* @param TParam The class containing all parameter fields.
* Each field must be annotated with @[org.leafygreens.kompendium.annotations.KompendiumField]
* @param TResp Class detailing the expected API response
* @param info Route metadata
*/
inline fun <reified TParam : Any, reified TResp : Any> Route.notarizedGet(
info: GetInfo<TParam, TResp>,
noinline body: PipelineInterceptor<Unit, ApplicationCall>
@ -31,6 +42,14 @@ object Notarized {
return method(HttpMethod.Get) { handle(body) }
}
/**
* Notarization for an HTTP POST request
* @param TParam The class containing all parameter fields.
* Each field must be annotated with @[org.leafygreens.kompendium.annotations.KompendiumField]
* @param TReq Class detailing the expected API request body
* @param TResp Class detailing the expected API response
* @param info Route metadata
*/
inline fun <reified TParam : Any, reified TReq : Any, reified TResp : Any> Route.notarizedPost(
info: PostInfo<TParam, TReq, TResp>,
noinline body: PipelineInterceptor<Unit, ApplicationCall>
@ -42,6 +61,14 @@ object Notarized {
return method(HttpMethod.Post) { handle(body) }
}
/**
* Notarization for an HTTP Delete request
* @param TParam The class containing all parameter fields.
* Each field must be annotated with @[org.leafygreens.kompendium.annotations.KompendiumField]
* @param TReq Class detailing the expected API request body
* @param TResp Class detailing the expected API response
* @param info Route metadata
*/
inline fun <reified TParam : Any, reified TReq : Any, reified TResp : Any> Route.notarizedPut(
info: PutInfo<TParam, TReq, TResp>,
noinline body: PipelineInterceptor<Unit, ApplicationCall>,
@ -54,6 +81,13 @@ object Notarized {
return method(HttpMethod.Put) { handle(body) }
}
/**
* Notarization for an HTTP POST request
* @param TParam The class containing all parameter fields.
* Each field must be annotated with @[org.leafygreens.kompendium.annotations.KompendiumField]
* @param TResp Class detailing the expected API response
* @param info Route metadata
*/
inline fun <reified TParam : Any, reified TResp : Any> Route.notarizedDelete(
info: DeleteInfo<TParam, TResp>,
noinline body: PipelineInterceptor<Unit, ApplicationCall>
@ -65,6 +99,12 @@ object Notarized {
return method(HttpMethod.Delete) { handle(body) }
}
/**
* Notarization for a handled exception response
* @param TErr The [Throwable] that is being handled
* @param TResp Class detailing the expected API response when handled
* @param info Response metadata
*/
inline fun <reified TErr : Throwable, reified TResp : Any> StatusPages.Configuration.notarizedException(
info: ResponseInfo<TResp>,
noinline handler: suspend PipelineContext<Unit, ApplicationCall>.(TErr) -> Unit

View File

@ -1,7 +1,9 @@
package org.leafygreens.kompendium.models.meta
import io.ktor.http.HttpStatusCode
data class ResponseInfo<TResp>(
val status: Int,
val status: HttpStatusCode,
val description: String,
val mediaTypes: List<String> = listOf("application/json"),
val examples: Map<String, TResp> = emptyMap()

View File

@ -7,6 +7,9 @@ import io.ktor.routing.Route
import io.ktor.util.InternalAPI
import org.slf4j.LoggerFactory
/**
* Default [PathCalculator] meant to be overridden as necessary
*/
open class CorePathCalculator : PathCalculator {
private val logger = LoggerFactory.getLogger(javaClass)

View File

@ -2,10 +2,19 @@ package org.leafygreens.kompendium.path
import io.ktor.routing.Route
/**
* Extensible interface for calculating Ktor paths
*/
interface PathCalculator {
/**
* Core route calculation method
*/
fun calculate(route: Route?, tail: String = ""): String
/**
* Used to handle any custom selectors that may be missed by the base route calculation
*/
fun handleCustomSelectors(route: Route, tail: String): String
}

View File

@ -7,6 +7,10 @@ import io.ktor.routing.get
import io.ktor.routing.route
import org.leafygreens.kompendium.models.oas.OpenApiSpec
/**
* Provides an out-of-the-box route to return the generated [OpenApiSpec]
* @param oas spec that is returned
*/
fun Routing.openApi(oas: OpenApiSpec) {
route("/openapi.json") {
get {

View File

@ -15,7 +15,12 @@ import kotlinx.html.title
import kotlinx.html.unsafe
import org.leafygreens.kompendium.models.oas.OpenApiSpec
fun Routing.redoc(oas: OpenApiSpec) {
/**
* Provides an out-of-the-box route to view docs using ReDoc
* @param oas spec to reference
* @param specUrl url to point ReDoc to the OpenAPI json document
*/
fun Routing.redoc(oas: OpenApiSpec, specUrl: String = "/openapi.json") {
route("/docs") {
get {
call.respondHtml {
@ -41,8 +46,7 @@ fun Routing.redoc(oas: OpenApiSpec) {
}
}
body {
// TODO needs to mirror openApi route
unsafe { +"<redoc spec-url='/openapi.json'></redoc>" }
unsafe { +"<redoc spec-url='${specUrl}'></redoc>" }
script {
src = "https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"
}

View File

@ -1,81 +0,0 @@
package org.leafygreens.kompendium.util
// Take from https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
object KompendiumHttpCodes {
// Informational responses
const val CONTINUE = 100
const val SWITCHING_PROTOCOL = 101
const val PROCESSING = 102
const val EARLY_HINTS = 103
// Successful responses
const val OK = 200
const val CREATED = 201
const val ACCEPTED = 202
const val NON_AUTHORITATIVE_INFORMATION = 203
const val NO_CONTENT = 204
const val RESET_CONTENT = 205
const val PARTIAL_CONTENT = 206
const val MULTI_STATUS = 207
const val ALREADY_REPORTED = 208
const val IM_USED = 226
// Redirection messages
const val MULTIPLE_CHOICE = 300
const val MOVED_PERMANENTLY = 301
const val FOUND = 302
const val SEE_OTHER = 303
const val NOT_MODIFIED = 304
@Deprecated("Deprecated due to security concerns regarding in-band configuration of a proxy")
const val USE_PROXY = 305
@Deprecated("This response code is no longer used; it is just reserved.")
const val UNUSED = 306
const val TEMPORARY_REDIRECT = 307
const val PERMANENT_REDIRECT = 308
// Client Response Errors
const val BAD_REQUEST = 400
const val UNAUTHORIZED = 401
const val PAYMENT_REQUIRED = 402
const val FORBIDDEN = 403
const val NOT_FOUND = 404
const val METHOD_NOT_ALLOWED = 405
const val NOT_ACCEPTABLE = 406
const val PROXY_AUTHENTICATION_REQUIRED = 407
const val REQUEST_TIMEOUT = 408
const val CONFLICT = 409
const val GONE = 410
const val LENGTH_REQUIRED = 411
const val PRECONDITION_FAILED = 412
const val PAYLOAD_TOO_LARGE = 413
const val URI_TOO_LONG = 414
const val UNSUPPORTED_MEDIA_TYPE = 415
const val RANGE_NOT_SATISFIABLE = 416
const val EXPECTATION_FAILED = 417
const val IM_A_TEAPOT = 418
const val MISDIRECTED_REQUEST = 421
const val UNPROCESSABLE_ENTITY = 422
const val LOCKED = 423
const val FAILED_DEPENDENCY = 424
const val TOO_EARLY = 425
const val UPGRADE_REQUIRED = 426
const val PRECONDITION_REQUIRED = 428
const val TOO_MANY_REQUESTS = 429
const val REQUEST_HEADER_FIELDS_TOO_LARGE = 431
const val UNAVAILABLE_FOR_LEGAL_REASONS = 451
// Server Error Responses
const val INTERNAL_SERVER_ERROR = 500
const val NOT_IMPLEMENTED = 501
const val BAD_GATEWAY = 502
const val SERVICE_UNAVAILABLE = 503
const val GATEWAY_TIMEOUT = 504
const val HTTP_VERSION_NOT_SUPPORTED = 505
const val VARIANT_ALSO_NEGOTIATES = 506
const val INSUFFICIENT_STORAGE = 507
const val LOOP_DETECTED = 508
const val NOT_EXTENDED = 510
const val NETWORK_AUTHENTICATION_REQUIRED = 511
}

View File

@ -1,18 +1,8 @@
package org.leafygreens.kompendium
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.databind.SerializationFeature
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
import io.ktor.response.respond
import io.ktor.response.respondText
import io.ktor.routing.route
import io.ktor.routing.routing
import io.ktor.server.testing.handleRequest
import io.ktor.server.testing.withTestApplication
@ -20,35 +10,33 @@ import java.net.URI
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
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.KompendiumParam
import org.leafygreens.kompendium.annotations.ParamType
import org.leafygreens.kompendium.models.meta.MethodInfo.DeleteInfo
import org.leafygreens.kompendium.models.meta.MethodInfo.GetInfo
import org.leafygreens.kompendium.models.meta.MethodInfo.PostInfo
import org.leafygreens.kompendium.models.meta.MethodInfo.PutInfo
import org.leafygreens.kompendium.models.meta.RequestInfo
import org.leafygreens.kompendium.models.meta.ResponseInfo
import org.leafygreens.kompendium.models.oas.OpenApiSpecInfo
import org.leafygreens.kompendium.models.oas.OpenApiSpecInfoContact
import org.leafygreens.kompendium.models.oas.OpenApiSpecInfoLicense
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.DefaultParameter
import org.leafygreens.kompendium.util.ExceptionResponse
import org.leafygreens.kompendium.util.KompendiumHttpCodes
import org.leafygreens.kompendium.util.TestCreatedResponse
import org.leafygreens.kompendium.util.TestHelpers.getFileSnapshot
import org.leafygreens.kompendium.util.TestNested
import org.leafygreens.kompendium.util.TestParams
import org.leafygreens.kompendium.util.TestRequest
import org.leafygreens.kompendium.util.TestResponse
import org.leafygreens.kompendium.util.complexType
import org.leafygreens.kompendium.util.configModule
import org.leafygreens.kompendium.util.emptyGet
import org.leafygreens.kompendium.util.nestedUnderRootModule
import org.leafygreens.kompendium.util.nonRequiredParamsGet
import org.leafygreens.kompendium.util.notarizedDeleteModule
import org.leafygreens.kompendium.util.notarizedGetModule
import org.leafygreens.kompendium.util.notarizedGetWithMultipleThrowables
import org.leafygreens.kompendium.util.notarizedGetWithNotarizedException
import org.leafygreens.kompendium.util.notarizedPostModule
import org.leafygreens.kompendium.util.notarizedPutModule
import org.leafygreens.kompendium.util.pathParsingTestModule
import org.leafygreens.kompendium.util.primitives
import org.leafygreens.kompendium.util.returnsList
import org.leafygreens.kompendium.util.rootModule
import org.leafygreens.kompendium.util.statusPageModule
import org.leafygreens.kompendium.util.statusPageMultiExceptions
import org.leafygreens.kompendium.util.trailingSlash
import org.leafygreens.kompendium.util.withDefaultParameter
import org.leafygreens.kompendium.util.withExamples
internal class KompendiumTest {
@ -448,305 +436,6 @@ internal class KompendiumTest {
}
}
private companion object {
val testGetResponse = ResponseInfo<TestResponse>(KompendiumHttpCodes.OK, "A Successful Endeavor")
val testGetListResponse = ResponseInfo<List<TestResponse>>(KompendiumHttpCodes.OK, "A Successful List-y Endeavor")
val testPostResponse = ResponseInfo<TestCreatedResponse>(KompendiumHttpCodes.CREATED, "A Successful Endeavor")
val testPostResponseAgain = ResponseInfo<Boolean>(KompendiumHttpCodes.CREATED, "A Successful Endeavor")
val testDeleteResponse =
ResponseInfo<Unit>(KompendiumHttpCodes.NO_CONTENT, "A Successful Endeavor", mediaTypes = emptyList())
val testRequest = RequestInfo<TestRequest>("A Test request")
val testRequestAgain = RequestInfo<Int>("A Test request")
val complexRequest = RequestInfo<ComplexRequest>("A Complex request")
val testGetInfo = GetInfo<TestParams, TestResponse>(
summary = "Another get test",
description = "testing more",
responseInfo = testGetResponse
)
val testGetInfoAgain = GetInfo<TestParams, List<TestResponse>>(
summary = "Another get test",
description = "testing more",
responseInfo = testGetListResponse
)
val testGetWithException = testGetInfo.copy(
canThrow = setOf(Exception::class)
)
val testGetWithMultipleExceptions = testGetInfo.copy(
canThrow = setOf(AccessDeniedException::class, Exception::class)
)
val testPostInfo = PostInfo<TestParams, TestRequest, TestCreatedResponse>(
summary = "Test post endpoint",
description = "Post your tests here!",
responseInfo = testPostResponse,
requestInfo = testRequest
)
val testPutInfo = PutInfo<Unit, ComplexRequest, TestCreatedResponse>(
summary = "Test put endpoint",
description = "Put your tests here!",
responseInfo = testPostResponse,
requestInfo = complexRequest
)
val testPutInfoAlso = PutInfo<TestParams, TestRequest, TestCreatedResponse>(
summary = "Test put endpoint",
description = "Put your tests here!",
responseInfo = testPostResponse,
requestInfo = testRequest
)
val testPutInfoAgain = PutInfo<Unit, Int, Boolean>(
summary = "Test put endpoint",
description = "Put your tests here!",
responseInfo = testPostResponseAgain,
requestInfo = testRequestAgain
)
val testDeleteInfo = DeleteInfo<TestParams, Unit>(
summary = "Test delete endpoint",
description = "testing my deletes",
responseInfo = testDeleteResponse
)
val emptyTestGetInfo =
GetInfo<OptionalParams, Unit>(summary = "No request params and response body", description = "testing more")
val trulyEmptyTestGetInfo =
GetInfo<Unit, Unit>(summary = "No request params and response body", description = "testing more")
}
private fun Application.configModule() {
install(ContentNegotiation) {
jackson {
enable(SerializationFeature.INDENT_OUTPUT)
setSerializationInclusion(JsonInclude.Include.NON_NULL)
}
}
}
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(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") {
notarizedGet(testGetInfo) {
call.respondText { "hey dude ‼️ congratz on the get request" }
}
}
}
}
private fun Application.notarizedPostModule() {
routing {
route("/test") {
notarizedPost(testPostInfo) {
call.respondText { "hey dude ✌️ congratz on the post request" }
}
}
}
}
private fun Application.notarizedDeleteModule() {
routing {
route("/test") {
notarizedDelete(testDeleteInfo) {
call.respond(HttpStatusCode.NoContent)
}
}
}
}
private fun Application.notarizedPutModule() {
routing {
route("/test") {
notarizedPut(testPutInfoAlso) {
call.respondText { "hey pal 🌝 whatcha doin' here?" }
}
}
}
}
private fun Application.pathParsingTestModule() {
routing {
route("/this") {
route("/is") {
route("/a") {
route("/complex") {
route("path") {
route("with/an/{id}") {
notarizedGet(testGetInfo) {
call.respondText { "Aww you followed this whole route 🥺" }
}
}
}
}
}
}
}
}
}
private fun Application.rootModule() {
routing {
route("/") {
notarizedGet(testGetInfo) {
call.respondText { "☎️🏠🌲" }
}
}
}
}
private fun Application.nestedUnderRootModule() {
routing {
route("/") {
route("/testerino") {
notarizedGet(testGetInfo) {
call.respondText { "🤔🔥" }
}
}
}
}
}
private fun Application.trailingSlash() {
routing {
route("/test") {
route("/") {
notarizedGet(testGetInfo) {
call.respondText { "🙀👾" }
}
}
}
}
}
private fun Application.returnsList() {
routing {
route("/test") {
notarizedGet(testGetInfoAgain) {
call.respondText { "hey dude ur doing amazing work!" }
}
}
}
}
private fun Application.complexType() {
routing {
route("/test") {
notarizedPut(testPutInfo) {
call.respondText { "heya" }
}
}
}
}
private fun Application.primitives() {
routing {
route("/test") {
notarizedPut(testPutInfoAgain) {
call.respondText { "heya" }
}
}
}
}
private fun Application.emptyGet() {
routing {
route("/test/empty") {
notarizedGet(trulyEmptyTestGetInfo) {
call.respond(HttpStatusCode.OK)
}
}
}
}
private fun Application.withExamples() {
routing {
route("/test/examples") {
notarizedPost(
info = PostInfo<Unit, TestRequest, TestResponse>(
summary = "Example Parameters",
description = "A test for setting parameter examples",
requestInfo = RequestInfo(
description = "Test",
examples = mapOf(
"one" to TestRequest(fieldName = TestNested(nesty = "hey"), b = 4.0, aaa = emptyList()),
"two" to TestRequest(fieldName = TestNested(nesty = "hello"), b = 3.8, aaa = listOf(31324234))
)
),
responseInfo = ResponseInfo(
status = 201,
description = "nice",
examples = mapOf("test" to TestResponse(c = "spud"))
),
)
) {
call.respond(HttpStatusCode.OK)
}
}
}
}
private fun Application.withDefaultParameter() {
routing {
route("/test") {
notarizedGet(
info = GetInfo<DefaultParameter, TestResponse>(
summary = "Testing Default Params",
description = "Should have a default parameter value"
)
) {
call.respond(TestResponse("hey"))
}
}
}
}
data class OptionalParams(
@KompendiumParam(ParamType.QUERY) val required: String,
@KompendiumParam(ParamType.QUERY) val notRequired: String?
)
private fun Application.nonRequiredParamsGet() {
routing {
route("/test/optional") {
notarizedGet(emptyTestGetInfo) {
call.respond(HttpStatusCode.OK)
}
}
}
}
private val oas = Kompendium.openApiSpec.copy(
info = OpenApiSpecInfo(

View File

@ -42,18 +42,12 @@ data class TestResponse(val c: String)
data class TestCreatedResponse(val id: Int, val c: String)
object TestDeleteResponse
data class ComplexRequest(
val org: String,
@KompendiumField("amazing_field")
val amazingField: String,
val tables: List<NestedComplexItem>
) {
fun testThing() {
println("hey mom 👋")
}
}
)
data class NestedComplexItem(
val name: String,
@ -75,10 +69,9 @@ data class DefaultParameter(
@KompendiumParam(ParamType.PATH) val c: Boolean
)
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)
data class OptionalParams(
@KompendiumParam(ParamType.QUERY) val required: String,
@KompendiumParam(ParamType.QUERY) val notRequired: String?
)

View File

@ -0,0 +1,257 @@
package org.leafygreens.kompendium.util
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.databind.SerializationFeature
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.HttpStatusCode
import io.ktor.jackson.jackson
import io.ktor.response.respond
import io.ktor.response.respondText
import io.ktor.routing.route
import io.ktor.routing.routing
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.models.meta.MethodInfo
import org.leafygreens.kompendium.models.meta.RequestInfo
import org.leafygreens.kompendium.models.meta.ResponseInfo
fun Application.configModule() {
install(ContentNegotiation) {
jackson {
enable(SerializationFeature.INDENT_OUTPUT)
setSerializationInclusion(JsonInclude.Include.NON_NULL)
}
}
}
fun Application.statusPageModule() {
install(StatusPages) {
notarizedException<Exception, ExceptionResponse>(info = ResponseInfo(HttpStatusCode.BadRequest, "Bad Things Happened")) {
call.respond(HttpStatusCode.BadRequest, ExceptionResponse("Why you do dis?"))
}
}
}
fun Application.statusPageMultiExceptions() {
install(StatusPages) {
notarizedException<AccessDeniedException, Unit>(info = ResponseInfo(HttpStatusCode.Forbidden, "New API who dis?")) {
call.respond(HttpStatusCode.Forbidden)
}
notarizedException<Exception, ExceptionResponse>(info = ResponseInfo(HttpStatusCode.BadRequest, "Bad Things Happened")) {
call.respond(HttpStatusCode.BadRequest, ExceptionResponse("Why you do dis?"))
}
}
}
fun Application.notarizedGetWithNotarizedException() {
routing {
route("/test") {
notarizedGet(TestResponseInfo.testGetWithException) {
error("something terrible has happened!")
}
}
}
}
fun Application.notarizedGetWithMultipleThrowables() {
routing {
route("/test") {
notarizedGet(TestResponseInfo.testGetWithMultipleExceptions) {
error("something terrible has happened!")
}
}
}
}
fun Application.notarizedGetModule() {
routing {
route("/test") {
notarizedGet(TestResponseInfo.testGetInfo) {
call.respondText { "hey dude ‼️ congratz on the get request" }
}
}
}
}
fun Application.notarizedPostModule() {
routing {
route("/test") {
notarizedPost(TestResponseInfo.testPostInfo) {
call.respondText { "hey dude ✌️ congratz on the post request" }
}
}
}
}
fun Application.notarizedDeleteModule() {
routing {
route("/test") {
notarizedDelete(TestResponseInfo.testDeleteInfo) {
call.respond(HttpStatusCode.NoContent)
}
}
}
}
fun Application.notarizedPutModule() {
routing {
route("/test") {
notarizedPut(TestResponseInfo.testPutInfoAlso) {
call.respondText { "hey pal 🌝 whatcha doin' here?" }
}
}
}
}
fun Application.pathParsingTestModule() {
routing {
route("/this") {
route("/is") {
route("/a") {
route("/complex") {
route("path") {
route("with/an/{id}") {
notarizedGet(TestResponseInfo.testGetInfo) {
call.respondText { "Aww you followed this whole route 🥺" }
}
}
}
}
}
}
}
}
}
fun Application.rootModule() {
routing {
route("/") {
notarizedGet(TestResponseInfo.testGetInfo) {
call.respondText { "☎️🏠🌲" }
}
}
}
}
fun Application.nestedUnderRootModule() {
routing {
route("/") {
route("/testerino") {
notarizedGet(TestResponseInfo.testGetInfo) {
call.respondText { "🤔🔥" }
}
}
}
}
}
fun Application.trailingSlash() {
routing {
route("/test") {
route("/") {
notarizedGet(TestResponseInfo.testGetInfo) {
call.respondText { "🙀👾" }
}
}
}
}
}
fun Application.returnsList() {
routing {
route("/test") {
notarizedGet(TestResponseInfo.testGetInfoAgain) {
call.respondText { "hey dude ur doing amazing work!" }
}
}
}
}
fun Application.complexType() {
routing {
route("/test") {
notarizedPut(TestResponseInfo.testPutInfo) {
call.respondText { "heya" }
}
}
}
}
fun Application.primitives() {
routing {
route("/test") {
notarizedPut(TestResponseInfo.testPutInfoAgain) {
call.respondText { "heya" }
}
}
}
}
fun Application.emptyGet() {
routing {
route("/test/empty") {
notarizedGet(TestResponseInfo.trulyEmptyTestGetInfo) {
call.respond(HttpStatusCode.OK)
}
}
}
}
fun Application.withExamples() {
routing {
route("/test/examples") {
notarizedPost(
info = MethodInfo.PostInfo<Unit, TestRequest, TestResponse>(
summary = "Example Parameters",
description = "A test for setting parameter examples",
requestInfo = RequestInfo(
description = "Test",
examples = mapOf(
"one" to TestRequest(fieldName = TestNested(nesty = "hey"), b = 4.0, aaa = emptyList()),
"two" to TestRequest(fieldName = TestNested(nesty = "hello"), b = 3.8, aaa = listOf(31324234))
)
),
responseInfo = ResponseInfo(
status = HttpStatusCode.Created,
description = "nice",
examples = mapOf("test" to TestResponse(c = "spud"))
),
)
) {
call.respond(HttpStatusCode.OK)
}
}
}
}
fun Application.withDefaultParameter() {
routing {
route("/test") {
notarizedGet(
info = MethodInfo.GetInfo<DefaultParameter, TestResponse>(
summary = "Testing Default Params",
description = "Should have a default parameter value"
)
) {
call.respond(TestResponse("hey"))
}
}
}
}
fun Application.nonRequiredParamsGet() {
routing {
route("/test/optional") {
notarizedGet(TestResponseInfo.emptyTestGetInfo) {
call.respond(HttpStatusCode.OK)
}
}
}
}

View File

@ -0,0 +1,71 @@
package org.leafygreens.kompendium.util
import io.ktor.http.HttpStatusCode
import org.leafygreens.kompendium.models.meta.MethodInfo
import org.leafygreens.kompendium.models.meta.RequestInfo
import org.leafygreens.kompendium.models.meta.ResponseInfo
object TestResponseInfo {
private val testGetResponse = ResponseInfo<TestResponse>(HttpStatusCode.OK, "A Successful Endeavor")
private val testGetListResponse =
ResponseInfo<List<TestResponse>>(HttpStatusCode.OK, "A Successful List-y Endeavor")
private val testPostResponse = ResponseInfo<TestCreatedResponse>(HttpStatusCode.Created, "A Successful Endeavor")
private val testPostResponseAgain = ResponseInfo<Boolean>(HttpStatusCode.Created, "A Successful Endeavor")
private val testDeleteResponse =
ResponseInfo<Unit>(HttpStatusCode.NoContent, "A Successful Endeavor", mediaTypes = emptyList())
private val testRequest = RequestInfo<TestRequest>("A Test request")
private val testRequestAgain = RequestInfo<Int>("A Test request")
private val complexRequest = RequestInfo<ComplexRequest>("A Complex request")
val testGetInfo = MethodInfo.GetInfo<TestParams, TestResponse>(
summary = "Another get test",
description = "testing more",
responseInfo = testGetResponse
)
val testGetInfoAgain = MethodInfo.GetInfo<TestParams, List<TestResponse>>(
summary = "Another get test",
description = "testing more",
responseInfo = testGetListResponse
)
val testGetWithException = testGetInfo.copy(
canThrow = setOf(Exception::class)
)
val testGetWithMultipleExceptions = testGetInfo.copy(
canThrow = setOf(AccessDeniedException::class, Exception::class)
)
val testPostInfo = MethodInfo.PostInfo<TestParams, TestRequest, TestCreatedResponse>(
summary = "Test post endpoint",
description = "Post your tests here!",
responseInfo = testPostResponse,
requestInfo = testRequest
)
val testPutInfo = MethodInfo.PutInfo<Unit, ComplexRequest, TestCreatedResponse>(
summary = "Test put endpoint",
description = "Put your tests here!",
responseInfo = testPostResponse,
requestInfo = complexRequest
)
val testPutInfoAlso = MethodInfo.PutInfo<TestParams, TestRequest, TestCreatedResponse>(
summary = "Test put endpoint",
description = "Put your tests here!",
responseInfo = testPostResponse,
requestInfo = testRequest
)
val testPutInfoAgain = MethodInfo.PutInfo<Unit, Int, Boolean>(
summary = "Test put endpoint",
description = "Put your tests here!",
responseInfo = testPostResponseAgain,
requestInfo = testRequestAgain
)
val testDeleteInfo = MethodInfo.DeleteInfo<TestParams, Unit>(
summary = "Test delete endpoint",
description = "testing my deletes",
responseInfo = testDeleteResponse
)
val emptyTestGetInfo =
MethodInfo.GetInfo<OptionalParams, Unit>(
summary = "No request params and response body",
description = "testing more"
)
val trulyEmptyTestGetInfo =
MethodInfo.GetInfo<Unit, Unit>(summary = "No request params and response body", description = "testing more")
}

View File

@ -38,7 +38,6 @@ import org.leafygreens.kompendium.playground.PlaygroundToC.testSinglePutInfo
import org.leafygreens.kompendium.routes.openApi
import org.leafygreens.kompendium.routes.redoc
import org.leafygreens.kompendium.swagger.swaggerUI
import org.leafygreens.kompendium.util.KompendiumHttpCodes
fun main() {
embeddedServer(
@ -74,7 +73,7 @@ fun Application.configModule() {
install(StatusPages) {
notarizedException<Exception, ExceptionResponse>(
info = ResponseInfo(
KompendiumHttpCodes.BAD_REQUEST,
HttpStatusCode.BadRequest,
"Bad Things Happened",
examples = mapOf("example" to ExceptionResponse("hey bad things happened sorry"))
)

View File

@ -1,16 +1,16 @@
package org.leafygreens.kompendium.playground
import io.ktor.http.HttpStatusCode
import org.leafygreens.kompendium.models.meta.MethodInfo
import org.leafygreens.kompendium.models.meta.RequestInfo
import org.leafygreens.kompendium.models.meta.ResponseInfo
import org.leafygreens.kompendium.util.KompendiumHttpCodes
object PlaygroundToC {
val testGetWithExamples = MethodInfo.GetInfo<Unit, ExampleResponse>(
summary = "Example Parameters",
description = "A test for setting parameter examples",
responseInfo = ResponseInfo(
status = 200,
status = HttpStatusCode.OK,
description = "nice",
examples = mapOf("test" to ExampleResponse(c = "spud"))
),
@ -27,7 +27,7 @@ object PlaygroundToC {
)
),
responseInfo = ResponseInfo(
status = KompendiumHttpCodes.CREATED,
status = HttpStatusCode.Created,
description = "Congratz you hit da endpoint",
examples = mapOf(
"Expect This" to ExampleResponse(c = "Hi"),
@ -42,7 +42,7 @@ object PlaygroundToC {
description = "Test for the getting",
tags = setOf("test", "sample", "get"),
responseInfo = ResponseInfo(
status = KompendiumHttpCodes.OK,
status = HttpStatusCode.OK,
description = "Returns sample info"
)
)
@ -51,7 +51,7 @@ object PlaygroundToC {
description = "testing more",
tags = setOf("anotherTest", "sample"),
responseInfo = ResponseInfo(
status = KompendiumHttpCodes.OK,
status = HttpStatusCode.OK,
description = "Returns a different sample"
)
)
@ -66,7 +66,7 @@ object PlaygroundToC {
description = "Simple request body"
),
responseInfo = ResponseInfo(
status = KompendiumHttpCodes.CREATED,
status = HttpStatusCode.Created,
description = "Worlds most complex response"
)
)
@ -77,7 +77,7 @@ object PlaygroundToC {
description = "Info needed to perform this put request"
),
responseInfo = ResponseInfo(
status = KompendiumHttpCodes.CREATED,
status = HttpStatusCode.Created,
description = "What we give you when u do the puts"
)
)
@ -85,7 +85,7 @@ object PlaygroundToC {
summary = "Test delete endpoint",
description = "testing my deletes",
responseInfo = ResponseInfo(
status = KompendiumHttpCodes.NO_CONTENT,
status = HttpStatusCode.NoContent,
description = "Signifies that your item was deleted successfully",
mediaTypes = emptyList()
)
@ -95,7 +95,7 @@ object PlaygroundToC {
description = "testing more",
tags = setOf("anotherTest", "sample"),
responseInfo = ResponseInfo(
status = KompendiumHttpCodes.OK,
status = HttpStatusCode.OK,
description = "Returns a different sample"
),
securitySchemes = setOf("basic")