feat: added head, patch, and options methods (#132)

This commit is contained in:
Ryan Brink
2022-01-03 09:32:55 -05:00
committed by GitHub
parent f02f7ad211
commit 012db5ad26
12 changed files with 381 additions and 40 deletions

View File

@ -2,6 +2,7 @@
## Unreleased
### Added
- Support for HTTP Patch, Head, and Options methods
### Changed

View File

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

View File

@ -5,6 +5,9 @@ import io.bkbn.kompendium.core.KompendiumPreFlight.methodNotarizationPreFlight
import io.bkbn.kompendium.core.MethodParser.parseMethodInfo
import io.bkbn.kompendium.core.metadata.method.DeleteInfo
import io.bkbn.kompendium.core.metadata.method.GetInfo
import io.bkbn.kompendium.core.metadata.method.HeadInfo
import io.bkbn.kompendium.core.metadata.method.OptionsInfo
import io.bkbn.kompendium.core.metadata.method.PatchInfo
import io.bkbn.kompendium.core.metadata.method.PostInfo
import io.bkbn.kompendium.core.metadata.method.PutInfo
import io.bkbn.kompendium.oas.path.Path
@ -66,7 +69,7 @@ object Notarized {
}
/**
* Notarization for an HTTP Delete request
* Notarization for an HTTP PUT request
* @param TParam The class containing all parameter fields. Each field must be annotated with @[Param]
* @param TReq Class detailing the expected API request body
* @param TResp Class detailing the expected API response
@ -87,7 +90,28 @@ object Notarized {
}
/**
* Notarization for an HTTP POST request
* Notarization for an HTTP PATCH request
* @param TParam The class containing all parameter fields. Each field must be annotated with @[Param]
* @param TReq Class detailing the expected API request body
* @param TResp Class detailing the expected API response
* @param info Route metadata
* @param postProcess Adds an optional callback hook to perform manual overrides on the generated [PathOperation]
*/
inline fun <reified TParam : Any, reified TReq : Any, reified TResp : Any> Route.notarizedPatch(
info: PatchInfo<TParam, TReq, TResp>,
postProcess: (PathOperation) -> PathOperation = { p -> p },
noinline body: PipelineInterceptor<Unit, ApplicationCall>,
): Route = methodNotarizationPreFlight<TParam, TReq, TResp> { paramType, requestType, responseType ->
val feature = this.application.feature(Kompendium)
val path = calculateRoutePath()
feature.config.spec.paths.getOrPut(path) { Path() }
val baseInfo = parseMethodInfo(info, paramType, requestType, responseType, feature)
feature.config.spec.paths[path]?.patch = postProcess(baseInfo)
return method(HttpMethod.Patch) { handle(body) }
}
/**
* Notarization for an HTTP DELETE request
* @param TParam The class containing all parameter fields. Each field must be annotated with @[Param]
* @param TResp Class detailing the expected API response
* @param info Route metadata
@ -106,6 +130,45 @@ object Notarized {
return method(HttpMethod.Delete) { handle(body) }
}
/**
* Notarization for an HTTP HEAD request
* @param TParam The class containing all parameter fields. Each field must be annotated with @[Param]
* @param info Route metadata
* @param postProcess Adds an optional callback hook to perform manual overrides on the generated [PathOperation]
*/
inline fun <reified TParam : Any> Route.notarizedHead(
info: HeadInfo<TParam>,
postProcess: (PathOperation) -> PathOperation = { p -> p },
noinline body: PipelineInterceptor<Unit, ApplicationCall>
): Route = methodNotarizationPreFlight<TParam, Unit, Unit> { paramType, requestType, responseType ->
val feature = this.application.feature(Kompendium)
val path = calculateRoutePath()
feature.config.spec.paths.getOrPut(path) { Path() }
val baseInfo = parseMethodInfo(info, paramType, requestType, responseType, feature)
feature.config.spec.paths[path]?.head = postProcess(baseInfo)
return method(HttpMethod.Head) { handle(body) }
}
/**
* Notarization for an HTTP OPTION request
* @param TParam The class containing all parameter fields. Each field must be annotated with @[Param]
* @param TResp Class detailing the expected API response
* @param info Route metadata
* @param postProcess Adds an optional callback hook to perform manual overrides on the generated [PathOperation]
*/
inline fun <reified TParam : Any, reified TResp : Any> Route.notarizedOptions(
info: OptionsInfo<TParam, TResp>,
postProcess: (PathOperation) -> PathOperation = { p -> p },
noinline body: PipelineInterceptor<Unit, ApplicationCall>
): Route = methodNotarizationPreFlight<TParam, Unit, TResp> { paramType, requestType, responseType ->
val feature = this.application.feature(Kompendium)
val path = calculateRoutePath()
feature.config.spec.paths.getOrPut(path) { Path() }
val baseInfo = parseMethodInfo(info, paramType, requestType, responseType, feature)
feature.config.spec.paths[path]?.options = postProcess(baseInfo)
return method(HttpMethod.Options) { handle(body) }
}
/**
* Uses the built-in Ktor route path [Route.toString] but cuts out any meta route such as authentication... anything
* that matches the RegEx pattern `/\\(.+\\)`

View File

@ -0,0 +1,16 @@
package io.bkbn.kompendium.core.metadata.method
import io.bkbn.kompendium.core.metadata.ExceptionInfo
import io.bkbn.kompendium.core.metadata.ResponseInfo
data class HeadInfo<TParam>(
override val responseInfo: ResponseInfo<Unit>,
override val summary: String,
override val description: String? = null,
override val tags: Set<String> = emptySet(),
override val deprecated: Boolean = false,
override val securitySchemes: Set<String> = emptySet(),
override val canThrow: Set<ExceptionInfo<*>> = emptySet(),
override val parameterExamples: Map<String, TParam> = emptyMap(),
override val operationId: String? = null
) : MethodInfo<TParam, Unit>

View File

@ -0,0 +1,16 @@
package io.bkbn.kompendium.core.metadata.method
import io.bkbn.kompendium.core.metadata.ExceptionInfo
import io.bkbn.kompendium.core.metadata.ResponseInfo
data class OptionsInfo<TParam, TResp>(
override val responseInfo: ResponseInfo<TResp>,
override val summary: String,
override val description: String? = null,
override val tags: Set<String> = emptySet(),
override val deprecated: Boolean = false,
override val securitySchemes: Set<String> = emptySet(),
override val canThrow: Set<ExceptionInfo<*>> = emptySet(),
override val parameterExamples: Map<String, TParam> = emptyMap(),
override val operationId: String? = null
) : MethodInfo<TParam, TResp>

View File

@ -0,0 +1,18 @@
package io.bkbn.kompendium.core.metadata.method
import io.bkbn.kompendium.core.metadata.ExceptionInfo
import io.bkbn.kompendium.core.metadata.RequestInfo
import io.bkbn.kompendium.core.metadata.ResponseInfo
data class PatchInfo<TParam, TReq, TResp>(
val requestInfo: RequestInfo<TReq>?,
override val responseInfo: ResponseInfo<TResp>,
override val summary: String,
override val description: String? = null,
override val tags: Set<String> = emptySet(),
override val deprecated: Boolean = false,
override val securitySchemes: Set<String> = emptySet(),
override val canThrow: Set<ExceptionInfo<*>> = emptySet(),
override val parameterExamples: Map<String, TParam> = emptyMap(),
override val operationId: String? = null
) : MethodInfo<TParam, TResp>

View File

@ -27,6 +27,9 @@ import io.bkbn.kompendium.core.util.notarizedGetWithGenericErrorResponse
import io.bkbn.kompendium.core.util.notarizedGetWithMultipleThrowables
import io.bkbn.kompendium.core.util.notarizedGetWithNotarizedException
import io.bkbn.kompendium.core.util.notarizedGetWithPolymorphicErrorResponse
import io.bkbn.kompendium.core.util.notarizedHeadModule
import io.bkbn.kompendium.core.util.notarizedOptionsModule
import io.bkbn.kompendium.core.util.notarizedPatchModule
import io.bkbn.kompendium.core.util.notarizedPostModule
import io.bkbn.kompendium.core.util.notarizedPutModule
import io.bkbn.kompendium.core.util.nullableField
@ -55,56 +58,53 @@ import io.ktor.http.HttpStatusCode
class KompendiumTest : DescribeSpec({
describe("Notarized Open API Metadata Tests") {
it("Can notarize a get request") {
// act
openApiTest("notarized_get.json") { notarizedGetModule() }
}
it("Can notarize a post request") {
// act
openApiTest("notarized_post.json") { notarizedPostModule() }
}
it("Can notarize a put request") {
// act
openApiTest("notarized_put.json") { notarizedPutModule() }
}
it("Can notarize a delete request") {
// act
openApiTest("notarized_delete.json") { notarizedDeleteModule() }
}
it("Can notarize a patch request") {
openApiTest("notarized_patch.json") { notarizedPatchModule() }
}
it("Can notarize a head request") {
openApiTest("notarized_head.json") { notarizedHeadModule() }
}
it("Can notarize an options request") {
openApiTest("notarized_options.json") { notarizedOptionsModule() }
}
it("Can notarize a complex type") {
// act
openApiTest("complex_type.json") { complexType() }
}
it("Can notarize primitives") {
// act
openApiTest("notarized_primitives.json") { primitives() }
}
it("Can notarize a top level list response") {
// act
openApiTest("response_list.json") { returnsList() }
}
it("Can notarize a route with non-required params") {
// act
openApiTest("non_required_params.json") { nonRequiredParamsGet() }
}
}
describe("Notarized Ktor Functionality Tests") {
it("Can notarized a get request and return the expected result") {
// act
apiFunctionalityTest("hey dude ‼️ congratz on the get request") { notarizedGetModule() }
}
it("Can notarize a post request and return the expected result") {
// act
apiFunctionalityTest(
"hey dude ✌️ congratz on the post request",
httpMethod = HttpMethod.Post
) { notarizedPostModule() }
}
it("Can notarize a put request and return the expected result") {
// act
apiFunctionalityTest("hey pal 🌝 whatcha doin' here?", httpMethod = HttpMethod.Put) { notarizedPutModule() }
}
it("Can notarize a delete request and return the expected result") {
// act
apiFunctionalityTest(
null,
httpMethod = HttpMethod.Delete,
@ -112,59 +112,47 @@ class KompendiumTest : DescribeSpec({
) { notarizedDeleteModule() }
}
it("Can notarize the root route and return the expected result") {
// act
apiFunctionalityTest("☎️🏠🌲", "/") { rootModule() }
}
it("Can notarize a trailing slash route and return the expected result") {
// act
apiFunctionalityTest("🙀👾", "/test/") { trailingSlash() }
}
}
describe("Route Parsing") {
it("Can parse a simple path and store it under the expected route") {
// act
openApiTest("path_parser.json") { pathParsingTestModule() }
}
it("Can notarize the root route") {
// act
openApiTest("root_route.json") { rootModule() }
}
it("Can notarize a route under the root module without appending trailing slash") {
// act
openApiTest("nested_under_root.json") { nestedUnderRootModule() }
}
it("Can notarize a route with a trailing slash") {
// act
openApiTest("trailing_slash.json") { trailingSlash() }
}
}
describe("Exceptions") {
it("Can add an exception status code to a response") {
// act
openApiTest("notarized_get_with_exception_response.json") { notarizedGetWithNotarizedException() }
}
it("Can support multiple response codes") {
// act
openApiTest("notarized_get_with_multiple_exception_responses.json") { notarizedGetWithMultipleThrowables() }
}
it("Can add a polymorphic exception response") {
// act
openApiTest("polymorphic_error_status_codes.json") { notarizedGetWithPolymorphicErrorResponse() }
}
it("Can add a generic exception response") {
// act
openApiTest("generic_exception.json") { notarizedGetWithGenericErrorResponse() }
}
}
describe("Examples") {
it("Can generate example response and request bodies") {
// act
openApiTest("example_req_and_resp.json") { withExamples() }
}
}
describe("Defaults") {
it("Can generate a default parameter values") {
// act
openApiTest("query_with_default_parameter.json") { withDefaultParameter() }
}
}
@ -184,49 +172,38 @@ class KompendiumTest : DescribeSpec({
}
describe("Polymorphism and Generics") {
it("can generate a polymorphic response type") {
// act
openApiTest("polymorphic_response.json") { polymorphicResponse() }
}
it("Can generate a collection with polymorphic response type") {
// act
openApiTest("polymorphic_list_response.json") { polymorphicCollectionResponse() }
}
it("Can generate a map with a polymorphic response type") {
// act
openApiTest("polymorphic_map_response.json") { polymorphicMapResponse() }
}
it("Can generate a polymorphic response from a sealed interface") {
// act
openApiTest("sealed_interface_response.json") { polymorphicInterfaceResponse() }
}
it("Can generate a response type with a generic type") {
// act
openApiTest("generic_response.json") { simpleGenericResponse() }
}
it("Can generate a polymorphic response type with generics") {
// act
openApiTest("polymorphic_response_with_generics.json") { genericPolymorphicResponse() }
}
it("Can handle an absolutely psycho inheritance test") {
// act
openApiTest("crazy_polymorphic_example.json") { genericPolymorphicResponseMultipleImpls() }
}
}
describe("Miscellaneous") {
it("Can generate the necessary ReDoc home page") {
// act
apiFunctionalityTest(getFileSnapshot("redoc.html"), "/docs") { returnsList() }
}
it("Can add an operation id to a notarized route") {
// act
openApiTest("notarized_get_with_operation_id.json") { withOperationId() }
}
it("Can add an undeclared field") {
// act
openApiTest("undeclared_field.json") { undeclaredType() }
}
it("Can add a custom header parameter with a name override") {
// act
openApiTest("override_parameter_name.json") { headerParameter() }
}
it("Can override field values via annotation") {

View File

@ -2,6 +2,9 @@ package io.bkbn.kompendium.core.util
import io.bkbn.kompendium.core.Notarized.notarizedDelete
import io.bkbn.kompendium.core.Notarized.notarizedGet
import io.bkbn.kompendium.core.Notarized.notarizedHead
import io.bkbn.kompendium.core.Notarized.notarizedOptions
import io.bkbn.kompendium.core.Notarized.notarizedPatch
import io.bkbn.kompendium.core.Notarized.notarizedPost
import io.bkbn.kompendium.core.Notarized.notarizedPut
import io.bkbn.kompendium.core.fixtures.Bibbity
@ -105,6 +108,36 @@ fun Application.notarizedDeleteModule() {
}
}
fun Application.notarizedPatchModule() {
routing {
route("/test") {
notarizedPatch(TestResponseInfo.testPatchInfo) {
call.respondText { "hey dude ✌️ congratz on the patch request" }
}
}
}
}
fun Application.notarizedHeadModule() {
routing {
route("/test") {
notarizedHead(TestResponseInfo.testHeadInfo) {
call.response.status(HttpStatusCode.OK)
}
}
}
}
fun Application.notarizedOptionsModule() {
routing {
route("/test") {
notarizedOptions(TestResponseInfo.testOptionsInfo) {
call.response.status(HttpStatusCode.OK)
}
}
}
}
fun Application.notarizedPutModule() {
routing {
route("/test") {

View File

@ -0,0 +1,49 @@
{
"openapi": "3.0.3",
"info": {
"title": "Test API",
"version": "1.33.7",
"description": "An amazing, fully-ish 😉 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/bkbnio/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": {
"head": {
"tags": [],
"summary": "Test head endpoint",
"description": "head test 💀",
"parameters": [],
"responses": {
"200": {
"description": "great!"
}
},
"deprecated": false
}
}
},
"components": {
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -0,0 +1,84 @@
{
"openapi": "3.0.3",
"info": {
"title": "Test API",
"version": "1.33.7",
"description": "An amazing, fully-ish 😉 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/bkbnio/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": {
"options": {
"tags": [],
"summary": "Test options",
"description": "endpoint of options",
"parameters": [
{
"name": "a",
"in": "path",
"schema": {
"type": "string"
},
"required": true,
"deprecated": false
},
{
"name": "aa",
"in": "query",
"schema": {
"format": "int32",
"type": "integer"
},
"required": true,
"deprecated": false
}
],
"responses": {
"200": {
"description": "nice",
"content": {
"application/json": {
"schema": {
"properties": {
"c": {
"type": "string"
}
},
"required": [
"c"
],
"type": "object"
}
}
}
}
},
"deprecated": false
}
}
},
"components": {
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -0,0 +1,64 @@
{
"openapi": "3.0.3",
"info": {
"title": "Test API",
"version": "1.33.7",
"description": "An amazing, fully-ish 😉 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/bkbnio/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": {
"patch": {
"tags": [],
"summary": "Test patch endpoint",
"description": "patch your tests here!",
"parameters": [],
"responses": {
"201": {
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"properties": {
"c": {
"type": "string"
}
},
"required": [
"c"
],
"type": "object"
}
}
}
}
},
"deprecated": false
}
}
},
"components": {
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -7,6 +7,9 @@ import io.bkbn.kompendium.core.metadata.RequestInfo
import io.bkbn.kompendium.core.metadata.ResponseInfo
import io.bkbn.kompendium.core.metadata.method.DeleteInfo
import io.bkbn.kompendium.core.metadata.method.GetInfo
import io.bkbn.kompendium.core.metadata.method.HeadInfo
import io.bkbn.kompendium.core.metadata.method.OptionsInfo
import io.bkbn.kompendium.core.metadata.method.PatchInfo
import io.ktor.http.HttpStatusCode
import kotlin.reflect.typeOf
@ -15,6 +18,7 @@ object TestResponseInfo {
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 testPatchResponse = ResponseInfo<TestResponse>(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())
@ -75,6 +79,22 @@ object TestResponseInfo {
responseInfo = testPostResponse,
requestInfo = complexRequest
)
val testPatchInfo = PatchInfo<Unit, TestRequest, TestResponse>(
summary = "Test patch endpoint",
description = "patch your tests here!",
responseInfo = testPatchResponse,
requestInfo = testRequest
)
val testHeadInfo = HeadInfo<Unit>(
summary = "Test head endpoint",
description = "head test 💀",
responseInfo = ResponseInfo(HttpStatusCode.OK, "great!")
)
val testOptionsInfo = OptionsInfo<TestParams, TestResponse>(
summary = "Test options",
description = "endpoint of options",
responseInfo = ResponseInfo(HttpStatusCode.OK, "nice")
)
val testPutInfoAlso = PutInfo<TestParams, TestRequest, TestCreatedResponse>(
summary = "Test put endpoint",
description = "Put your tests here!",