More of the core functionality (#6)

This commit is contained in:
Ryan Brink
2021-04-13 13:35:55 -04:00
committed by GitHub
parent a7505483c4
commit fbf8c15694
24 changed files with 962 additions and 132 deletions

View File

@ -1,3 +1,11 @@
## [0.0.3] - April 13th, 2021
### Added
- Notarized Deletes
- Request and Response reflection abstractions
- Basic unit test coverage for each notarized operation
## [0.0.2] - April 12th, 2021 ## [0.0.2] - April 12th, 2021
### Added ### Added

View File

@ -10,7 +10,7 @@ Ktor native functions when implementing their API,
and will supplement with Kompendium code in order and will supplement with Kompendium code in order
to generate the appropriate spec. to generate the appropriate spec.
## Modules ## In depth
TODO TODO
@ -25,20 +25,23 @@ fun Application.mainModule() {
routing { routing {
route("/test") { route("/test") {
route("/{id}") { route("/{id}") {
notarizedGet(testIdGetInfo) { notarizedGet<ExampleParams, ExampleResponse>(testIdGetInfo) {
call.respondText("get by id") call.respondText("get by id")
} }
} }
route("/single") { route("/single") {
notarizedGet(testSingleGetInfo) { notarizedGet<ExampleRequest, ExampleResponse>(testSingleGetInfo) {
call.respondText("get single") call.respondText("get single")
} }
notarizedPost<A, B, C>(testSinglePostInfo) { notarizedPost<ExampleParams, ExampleRequest, ExampleCreatedResponse>(testSinglePostInfo) {
call.respondText("test post") call.respondText("test post")
} }
notarizedPut<A, B, D>(testSinglePutInfo) { notarizedPut<ExampleParams, ExampleRequest, ExampleCreatedResponse>(testSinglePutInfo) {
call.respondText { "hey" } call.respondText { "hey" }
} }
notarizedDelete<Unit, DeleteResponse>(testSingleDeleteInfo) {
call.respondText { "heya" }
}
} }
} }
route("/openapi.json") { route("/openapi.json") {
@ -54,4 +57,34 @@ fun Application.mainModule() {
} }
} }
} }
// Ancillary Data
data class ExampleParams(val a: String, val aa: Int)
data class ExampleNested(val nesty: String)
@KompendiumResponse(status = 204, "Entity was deleted successfully")
object DeleteResponse
@KompendiumRequest("Example Request")
data class ExampleRequest(
@KompendiumField(name = "field_name")
val fieldName: ExampleNested,
val b: Double,
val aaa: List<Long>
)
@KompendiumResponse(200, "A Successful Endeavor")
data class ExampleResponse(val c: String)
@KompendiumResponse(201, "Created Successfully")
data class ExampleCreatedResponse(val id: Int, val c: String)
object KompendiumTOC {
val testIdGetInfo = MethodInfo("Get Test", "Test for getting", tags = setOf("test", "example", "get"))
val testSingleGetInfo = MethodInfo("Another get test", "testing more")
val testSinglePostInfo = MethodInfo("Test post endpoint", "Post your tests here!")
val testSinglePutInfo = MethodInfo("Test put endpoint", "Put your tests here!")
val testSingleDeleteInfo = MethodInfo("Test delete endpoint", "testing my deletes")
}
``` ```

View File

@ -1,5 +1,5 @@
# Backbone # Backbone
project.version=0.0.1 project.version=0.0.3
# Kotlin # Kotlin
kotlin.code.style=official kotlin.code.style=official
# Gradle # Gradle

View File

@ -10,6 +10,7 @@ dependencies {
testImplementation("org.jetbrains.kotlin:kotlin-test") testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit") testImplementation("org.jetbrains.kotlin:kotlin-test-junit")
testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.12.0") testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.12.0")
testImplementation("io.ktor:ktor-server-test-host:1.5.3")
} }
publishing { publishing {

View File

@ -3,7 +3,6 @@ package org.leafygreens.kompendium
import io.ktor.application.ApplicationCall import io.ktor.application.ApplicationCall
import io.ktor.http.HttpMethod import io.ktor.http.HttpMethod
import io.ktor.routing.Route import io.ktor.routing.Route
import io.ktor.routing.createRouteFromPath
import io.ktor.routing.method import io.ktor.routing.method
import io.ktor.util.pipeline.PipelineInterceptor import io.ktor.util.pipeline.PipelineInterceptor
import java.lang.reflect.ParameterizedType import java.lang.reflect.ParameterizedType
@ -13,83 +12,128 @@ import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.memberProperties import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.javaField import kotlin.reflect.jvm.javaField
import org.leafygreens.kompendium.annotations.KompendiumField import org.leafygreens.kompendium.annotations.KompendiumField
import org.leafygreens.kompendium.annotations.KompendiumInternal import org.leafygreens.kompendium.annotations.KompendiumRequest
import org.leafygreens.kompendium.annotations.KompendiumResponse
import org.leafygreens.kompendium.models.meta.MethodInfo
import org.leafygreens.kompendium.models.oas.ArraySchema import org.leafygreens.kompendium.models.oas.ArraySchema
import org.leafygreens.kompendium.models.oas.FormatSchema import org.leafygreens.kompendium.models.oas.FormatSchema
import org.leafygreens.kompendium.models.oas.ObjectSchema import org.leafygreens.kompendium.models.oas.ObjectSchema
import org.leafygreens.kompendium.models.oas.OpenApiSpec import org.leafygreens.kompendium.models.oas.OpenApiSpec
import org.leafygreens.kompendium.models.oas.OpenApiSpecComponentSchema import org.leafygreens.kompendium.models.oas.OpenApiSpecComponentSchema
import org.leafygreens.kompendium.models.oas.OpenApiSpecInfo import org.leafygreens.kompendium.models.oas.OpenApiSpecInfo
import org.leafygreens.kompendium.models.oas.OpenApiSpecMediaType
import org.leafygreens.kompendium.models.oas.OpenApiSpecPathItem import org.leafygreens.kompendium.models.oas.OpenApiSpecPathItem
import org.leafygreens.kompendium.models.oas.OpenApiSpecPathItemOperation import org.leafygreens.kompendium.models.oas.OpenApiSpecPathItemOperation
import org.leafygreens.kompendium.models.oas.OpenApiSpecReferenceObject
import org.leafygreens.kompendium.models.oas.OpenApiSpecRequest
import org.leafygreens.kompendium.models.oas.OpenApiSpecResponse
import org.leafygreens.kompendium.models.oas.SimpleSchema import org.leafygreens.kompendium.models.oas.SimpleSchema
import org.leafygreens.kompendium.models.meta.MethodInfo
import org.leafygreens.kompendium.util.Helpers.calculatePath import org.leafygreens.kompendium.util.Helpers.calculatePath
import org.leafygreens.kompendium.util.Helpers.putPairIfAbsent import org.leafygreens.kompendium.util.Helpers.putPairIfAbsent
object Kompendium { object Kompendium {
val openApiSpec = OpenApiSpec(
const val COMPONENT_SLUG = "#/components/schemas"
var openApiSpec = OpenApiSpec(
info = OpenApiSpecInfo(), info = OpenApiSpecInfo(),
servers = mutableListOf(), servers = mutableListOf(),
paths = mutableMapOf() paths = mutableMapOf()
) )
fun Route.notarizedGet(info: MethodInfo, body: PipelineInterceptor<Unit, ApplicationCall>): Route { inline fun <reified TParam : Any, reified TResp : Any> Route.notarizedGet(
info: MethodInfo,
noinline body: PipelineInterceptor<Unit, ApplicationCall>
): Route = generateComponentSchemas<TParam, Unit, TResp>() {
val path = calculatePath() val path = calculatePath()
openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() } openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() }
openApiSpec.paths[path]?.get = OpenApiSpecPathItemOperation( openApiSpec.paths[path]?.get = info.parseMethodInfo<Unit, TResp>()
summary = info.summary,
description = info.description,
tags = info.tags
)
return method(HttpMethod.Get) { handle(body) } return method(HttpMethod.Get) { handle(body) }
} }
inline fun <reified TQ : Any, reified TP : Any, reified TR : Any> Route.notarizedPost( inline fun <reified TParam : Any, reified TReq : Any, reified TResp : Any> Route.notarizedPost(
info: MethodInfo, info: MethodInfo,
noinline body: PipelineInterceptor<Unit, ApplicationCall> noinline body: PipelineInterceptor<Unit, ApplicationCall>
): Route = generateComponentSchemas<TQ, TP, TR>(info, body) { i, b -> ): Route = generateComponentSchemas<TParam, TReq, TResp>() {
val path = calculatePath() val path = calculatePath()
openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() } openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() }
openApiSpec.paths[path]?.post = OpenApiSpecPathItemOperation( openApiSpec.paths[path]?.post = info.parseMethodInfo<TReq, TResp>()
summary = i.summary, return method(HttpMethod.Post) { handle(body) }
description = i.description,
tags = i.tags
)
return method(HttpMethod.Post) { handle(b) }
} }
inline fun <reified TQ : Any, reified TP : Any, reified TR : Any> Route.notarizedPut( inline fun <reified TParam : Any, reified TReq : Any, reified TResp : Any> Route.notarizedPut(
info: MethodInfo, info: MethodInfo,
noinline body: PipelineInterceptor<Unit, ApplicationCall>, noinline body: PipelineInterceptor<Unit, ApplicationCall>,
): Route = generateComponentSchemas<TQ, TP, TR>(info, body) { i, b -> ): Route = generateComponentSchemas<TParam, TReq, TResp>() {
val path = calculatePath() val path = calculatePath()
openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() } openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() }
openApiSpec.paths[path]?.put = OpenApiSpecPathItemOperation( openApiSpec.paths[path]?.put = info.parseMethodInfo<TReq, TResp>()
summary = i.summary, return method(HttpMethod.Put) { handle(body) }
description = i.description,
tags = i.tags
)
return method(HttpMethod.Put) { handle(b) }
} }
@OptIn(KompendiumInternal::class) inline fun <reified TParam : Any, reified TResp : Any> Route.notarizedDelete(
inline fun <reified TQ : Any, reified TP : Any, reified TR : Any> generateComponentSchemas(
info: MethodInfo, info: MethodInfo,
noinline body: PipelineInterceptor<Unit, ApplicationCall>, noinline body: PipelineInterceptor<Unit, ApplicationCall>
block: (MethodInfo, PipelineInterceptor<Unit, ApplicationCall>) -> Route ): Route = generateComponentSchemas<TParam, Unit, TResp> {
val path = calculatePath()
openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() }
openApiSpec.paths[path]?.delete = info.parseMethodInfo<Unit, TResp>()
return method(HttpMethod.Delete) { handle(body) }
}
inline fun <reified TReq, reified TResp> MethodInfo.parseMethodInfo() = OpenApiSpecPathItemOperation(
summary = this.summary,
description = this.description,
tags = this.tags,
deprecated = this.deprecated,
responses = parseResponseAnnotation<TResp>()?.let { mapOf(it) },
requestBody = parseRequestAnnotation<TReq>()
)
inline fun <reified TParam : Any, reified TReq : Any, reified TResp : Any> generateComponentSchemas(
block: () -> Route
): Route { ): Route {
openApiSpec.components.schemas.putPairIfAbsent(objectSchemaPair(TQ::class)) if (TResp::class != Unit::class) openApiSpec.components.schemas.putPairIfAbsent(objectSchemaPair(TResp::class))
openApiSpec.components.schemas.putPairIfAbsent(objectSchemaPair(TR::class)) if (TReq::class != Unit::class) openApiSpec.components.schemas.putPairIfAbsent(objectSchemaPair(TReq::class))
openApiSpec.components.schemas.putPairIfAbsent(objectSchemaPair(TP::class)) // openApiSpec.components.schemas.putPairIfAbsent(objectSchemaPair(TParam::class))
return block.invoke(info, body) return block.invoke()
}
inline fun <reified TReq> parseRequestAnnotation(): OpenApiSpecRequest? = when (TReq::class) {
Unit::class -> null
else -> {
val anny = TReq::class.findAnnotation<KompendiumRequest>() ?: error("My way or the highway bub")
OpenApiSpecRequest(
description = anny.description,
content = anny.mediaTypes.associate {
val ref = OpenApiSpecReferenceObject("$COMPONENT_SLUG/${TReq::class.simpleName}")
val mediaType = OpenApiSpecMediaType.Referenced(ref)
Pair(it, mediaType)
}
)
}
}
inline fun <reified TResp> parseResponseAnnotation(): Pair<Int, OpenApiSpecResponse>? = when (TResp::class) {
Unit::class -> null
else -> {
val anny = TResp::class.findAnnotation<KompendiumResponse>() ?: error("My way or the highway bub")
val specResponse = OpenApiSpecResponse(
description = anny.description,
content = anny.mediaTypes.associate {
val ref = OpenApiSpecReferenceObject("$COMPONENT_SLUG/${TResp::class.simpleName}")
val mediaType = OpenApiSpecMediaType.Referenced(ref)
Pair(it, mediaType)
}
)
Pair(anny.status, specResponse)
}
} }
@KompendiumInternal
// TODO Investigate a caching mechanism to reduce overhead... then just reference once created // TODO Investigate a caching mechanism to reduce overhead... then just reference once created
fun objectSchemaPair(clazz: KClass<*>): Pair<String, ObjectSchema> { fun objectSchemaPair(clazz: KClass<*>): Pair<String, ObjectSchema> {
val o = objectSchema(clazz) val o = objectSchema(clazz)
return Pair(clazz.qualifiedName!!, o) return Pair(clazz.simpleName!!, o)
} }
private fun objectSchema(clazz: KClass<*>): ObjectSchema = private fun objectSchema(clazz: KClass<*>): ObjectSchema =
@ -115,7 +159,6 @@ object Kompendium {
return ArraySchema(fieldToSchema(listType)) return ArraySchema(fieldToSchema(listType))
} }
@OptIn(KompendiumInternal::class)
private fun fieldToSchema(field: KClass<*>): OpenApiSpecComponentSchema = when (field) { private fun fieldToSchema(field: KClass<*>): OpenApiSpecComponentSchema = when (field) {
Int::class -> FormatSchema("int32", "integer") Int::class -> FormatSchema("int32", "integer")
Long::class -> FormatSchema("int64", "integer") Long::class -> FormatSchema("int64", "integer")
@ -125,4 +168,12 @@ object Kompendium {
Boolean::class -> SimpleSchema("boolean") Boolean::class -> SimpleSchema("boolean")
else -> objectSchema(field) else -> objectSchema(field)
} }
internal fun resetSchema() {
openApiSpec = OpenApiSpec(
info = OpenApiSpecInfo(),
servers = mutableListOf(),
paths = mutableMapOf()
)
}
} }

View File

@ -1,9 +0,0 @@
package org.leafygreens.kompendium.annotations
@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
annotation class KompendiumContact(
val name: String,
val url: String = "",
val email: String = ""
)

View File

@ -1,10 +0,0 @@
package org.leafygreens.kompendium.annotations
@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
annotation class KompendiumInfo(
val title: String,
val version: String,
val description: String = "",
val termsOfService: String = ""
)

View File

@ -1,18 +0,0 @@
package org.leafygreens.kompendium.annotations
@Suppress("DEPRECATION")
@RequiresOptIn(
level = RequiresOptIn.Level.WARNING,
message = "This API internal to Kompendium and should not be used. It could be removed or changed without notice."
)
@Experimental(level = Experimental.Level.WARNING)
@Target(
AnnotationTarget.CLASS,
AnnotationTarget.TYPEALIAS,
AnnotationTarget.FUNCTION,
AnnotationTarget.PROPERTY,
AnnotationTarget.FIELD,
AnnotationTarget.CONSTRUCTOR,
AnnotationTarget.PROPERTY_SETTER
)
annotation class KompendiumInternal

View File

@ -1,5 +0,0 @@
package org.leafygreens.kompendium.annotations
@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.FUNCTION)
annotation class KompendiumModule

View File

@ -0,0 +1,7 @@
package org.leafygreens.kompendium.annotations
annotation class KompendiumRequest(
val description: String,
val required: Boolean = true,
val mediaTypes: Array<String> = ["application/json"]
)

View File

@ -0,0 +1,9 @@
package org.leafygreens.kompendium.annotations
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS)
annotation class KompendiumResponse(
val status: Int,
val description: String,
val mediaTypes: Array<String> = ["application/json"]
)

View File

@ -1,7 +0,0 @@
package org.leafygreens.kompendium.annotations
@Retention(AnnotationRetention.SOURCE)
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
annotation class KompendiumServers(
val urls: Array<String>
)

View File

@ -1,3 +1,8 @@
package org.leafygreens.kompendium.models.meta package org.leafygreens.kompendium.models.meta
data class MethodInfo(val summary: String, val description: String? = null, val tags: Set<String> = emptySet()) data class MethodInfo(
val summary: String,
val description: String? = null,
val tags: Set<String> = emptySet(),
val deprecated: Boolean = false
)

View File

@ -1,9 +1,16 @@
package org.leafygreens.kompendium.models.oas package org.leafygreens.kompendium.models.oas
// TODO Oof -> https://swagger.io/specification/#media-type-object // TODO Oof -> https://swagger.io/specification/#media-type-object
data class OpenApiSpecMediaType( sealed class OpenApiSpecMediaType {
data class Explicit(
val schema: OpenApiSpecSchema, // TODO sheesh -> https://swagger.io/specification/#schema-object val schema: OpenApiSpecSchema, // TODO sheesh -> https://swagger.io/specification/#schema-object
val example: String? = null, // TODO Enforce type? then serialize? val example: String? = null, // TODO Enforce type? then serialize?
val examples: Map<String, String>? = null, // needs to be mutually exclusive with example val examples: Map<String, String>? = null, // needs to be mutually exclusive with example
val encoding: Map<String, String>? = null // todo encoding object -> https://swagger.io/specification/#encoding-object val encoding: Map<String, String>? = null // todo encoding object -> https://swagger.io/specification/#encoding-object
) ) : OpenApiSpecMediaType()
data class Referenced(
val schema: OpenApiSpecReferenceObject
) : OpenApiSpecMediaType()
}

View File

@ -9,7 +9,7 @@ data class OpenApiSpecPathItemOperation(
var parameters: List<OpenApiSpecReferencable>? = null, var parameters: List<OpenApiSpecReferencable>? = null,
var requestBody: OpenApiSpecReferencable? = null, var requestBody: OpenApiSpecReferencable? = null,
// TODO How to enforce `default` requirement 🧐 // TODO How to enforce `default` requirement 🧐
var responses: Map<String, OpenApiSpecReferencable>? = null, var responses: Map<Int, OpenApiSpecReferencable>? = null,
var callbacks: Map<String, OpenApiSpecReferencable>? = null, var callbacks: Map<String, OpenApiSpecReferencable>? = null,
var deprecated: Boolean = false, var deprecated: Boolean = false,
// todo big yikes... also needs to reference objects in the security scheme 🤔 // todo big yikes... also needs to reference objects in the security scheme 🤔

View File

@ -1,13 +1,311 @@
package org.leafygreens.kompendium 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.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.get
import io.ktor.routing.route
import io.ktor.routing.routing
import io.ktor.server.testing.handleRequest
import io.ktor.server.testing.withTestApplication
import java.net.URI
import kotlin.test.AfterTest
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import org.leafygreens.kompendium.Kompendium.notarizedDelete
import org.leafygreens.kompendium.Kompendium.notarizedGet
import org.leafygreens.kompendium.Kompendium.notarizedPost
import org.leafygreens.kompendium.Kompendium.notarizedPut
import org.leafygreens.kompendium.models.meta.MethodInfo
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.util.TestCreatedResponse
import org.leafygreens.kompendium.util.TestData
import org.leafygreens.kompendium.util.TestDeleteResponse
import org.leafygreens.kompendium.util.TestParams
import org.leafygreens.kompendium.util.TestRequest
import org.leafygreens.kompendium.util.TestResponse
internal class KompendiumTest { internal class KompendiumTest {
@AfterTest
fun `reset kompendium`() {
Kompendium.resetSchema()
}
@Test @Test
fun `Kompendium can be instantiated with no details`() { fun `Kompendium can be instantiated with no details`() {
assertEquals(Kompendium.openApiSpec.openapi, "3.0.3", "Kompendium has a default spec version of 3.0.3") assertEquals(Kompendium.openApiSpec.openapi, "3.0.3", "Kompendium has a default spec version of 3.0.3")
} }
@Test
fun `Notarized Get records all expected information`() {
withTestApplication({
configModule()
openApiModule()
notarizedGetModule()
}) {
// do
val json = handleRequest(HttpMethod.Get, "/openapi.json").response.content
// expect
val expected = TestData.getFileSnapshot("notarized_get.json").trim()
assertEquals(expected, json, "The received json spec should match the expected content")
}
}
@Test
fun `Notarized Get does not interrupt the pipeline`() {
withTestApplication({
configModule()
openApiModule()
notarizedGetModule()
}) {
// do
val json = handleRequest(HttpMethod.Get, "/test").response.content
// expect
val expected = "hey dude ‼️ congratz on the get request"
assertEquals(expected, json, "The received json spec should match the expected content")
}
}
@Test
fun `Notarized Post records all expected information`() {
withTestApplication({
configModule()
openApiModule()
notarizedPostModule()
}) {
// do
val json = handleRequest(HttpMethod.Get, "/openapi.json").response.content
// expect
val expected = TestData.getFileSnapshot("notarized_post.json").trim()
assertEquals(expected, json, "The received json spec should match the expected content")
}
}
@Test
fun `Notarized post does not interrupt the pipeline`() {
withTestApplication({
configModule()
openApiModule()
notarizedPostModule()
}) {
// do
val json = handleRequest(HttpMethod.Post, "/test").response.content
// expect
val expected = "hey dude ✌️ congratz on the post request"
assertEquals(expected, json, "The received json spec should match the expected content")
}
}
@Test
fun `Notarized Put records all expected information`() {
withTestApplication({
configModule()
openApiModule()
notarizedPutModule()
}) {
// do
val json = handleRequest(HttpMethod.Get, "/openapi.json").response.content
// expect
val expected = TestData.getFileSnapshot("notarized_put.json").trim()
assertEquals(expected, json, "The received json spec should match the expected content")
}
}
@Test
fun `Notarized put does not interrupt the pipeline`() {
withTestApplication({
configModule()
openApiModule()
notarizedPutModule()
}) {
// do
val json = handleRequest(HttpMethod.Put, "/test").response.content
// expect
val expected = "hey pal 🌝 whatcha doin' here?"
assertEquals(expected, json, "The received json spec should match the expected content")
}
}
@Test
fun `Notarized delete records all expected information`() {
withTestApplication({
configModule()
openApiModule()
notarizedDeleteModule()
}) {
// do
val json = handleRequest(HttpMethod.Get, "/openapi.json").response.content
// expect
val expected = TestData.getFileSnapshot("notarized_delete.json").trim()
assertEquals(expected, json, "The received json spec should match the expected content")
}
}
@Test
fun `Notarized delete does not interrupt the pipeline`() {
withTestApplication({
configModule()
openApiModule()
notarizedDeleteModule()
}) {
// do
val status = handleRequest(HttpMethod.Delete, "/test").response.status()
// expect
assertEquals(HttpStatusCode.NoContent, status, "No content status should be received")
}
}
@Test
fun `Path parser stores the expected path`() {
withTestApplication({
configModule()
openApiModule()
pathParsingTestModule()
}) {
// do
val json = handleRequest(HttpMethod.Get, "/openapi.json").response.content
// expect
val expected = TestData.getFileSnapshot("path_parser.json").trim()
assertEquals(expected, json, "The received json spec should match the expected content")
}
}
private companion object {
val testGetInfo = MethodInfo("Another get test", "testing more")
val testPostInfo = MethodInfo("Test post endpoint", "Post your tests here!")
val testPutInfo = MethodInfo("Test put endpoint", "Put your tests here!")
val testDeleteInfo = MethodInfo("Test delete endpoint", "testing my deletes")
}
private fun Application.configModule() {
install(ContentNegotiation) {
jackson {
enable(SerializationFeature.INDENT_OUTPUT)
setSerializationInclusion(JsonInclude.Include.NON_NULL)
}
}
}
private fun Application.notarizedGetModule() {
routing {
route("/test") {
notarizedGet<TestParams, TestResponse>(testGetInfo) {
call.respondText { "hey dude ‼️ congratz on the get request" }
}
}
}
}
private fun Application.notarizedPostModule() {
routing {
route("/test") {
notarizedPost<TestParams, TestRequest, TestCreatedResponse>(testPostInfo) {
call.respondText { "hey dude ✌️ congratz on the post request" }
}
}
}
}
private fun Application.notarizedDeleteModule() {
routing {
route("/test") {
notarizedDelete<TestParams, TestDeleteResponse>(testDeleteInfo) {
call.respond(HttpStatusCode.NoContent)
}
}
}
}
private fun Application.notarizedPutModule() {
routing {
route("/test") {
notarizedPut<TestParams, TestRequest, TestCreatedResponse>(testPutInfo) {
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<TestParams, TestResponse>(testGetInfo) {
call.respondText { "Aww you followed this whole route 🥺" }
}
}
}
}
}
}
}
}
}
private fun Application.openApiModule() {
routing {
route("/openapi.json") {
get {
call.respond(
Kompendium.openApiSpec.copy(
info = OpenApiSpecInfo(
title = "Test API",
version = "1.33.7",
description = "An amazing, fully-ish 😉 generated API spec",
termsOfService = URI("https://example.com"),
contact = OpenApiSpecInfoContact(
name = "Homer Simpson",
email = "chunkylover53@aol.com",
url = URI("https://gph.is/1NPUDiM")
),
license = OpenApiSpecInfoLicense(
name = "MIT",
url = URI("https://github.com/lg-backbone/kompendium/blob/main/LICENSE")
)
),
servers = mutableListOf(
OpenApiSpecServer(
url = URI("https://myawesomeapi.com"),
description = "Production instance of my API"
),
OpenApiSpecServer(
url = URI("https://staging.myawesomeapi.com"),
description = "Where the fun stuff happens"
)
)
)
)
}
}
}
}
} }

View File

@ -14,6 +14,7 @@ import org.leafygreens.kompendium.models.oas.OpenApiSpecOAuthFlows
import org.leafygreens.kompendium.models.oas.OpenApiSpecParameter import org.leafygreens.kompendium.models.oas.OpenApiSpecParameter
import org.leafygreens.kompendium.models.oas.OpenApiSpecPathItem import org.leafygreens.kompendium.models.oas.OpenApiSpecPathItem
import org.leafygreens.kompendium.models.oas.OpenApiSpecPathItemOperation import org.leafygreens.kompendium.models.oas.OpenApiSpecPathItemOperation
import org.leafygreens.kompendium.models.oas.OpenApiSpecReferenceObject
import org.leafygreens.kompendium.models.oas.OpenApiSpecRequest import org.leafygreens.kompendium.models.oas.OpenApiSpecRequest
import org.leafygreens.kompendium.models.oas.OpenApiSpecResponse import org.leafygreens.kompendium.models.oas.OpenApiSpecResponse
import org.leafygreens.kompendium.models.oas.OpenApiSpecSchemaArray import org.leafygreens.kompendium.models.oas.OpenApiSpecSchemaArray
@ -92,25 +93,25 @@ object TestData {
requestBody = OpenApiSpecRequest( requestBody = OpenApiSpecRequest(
description = "Pet object that needs to be added to the store", description = "Pet object that needs to be added to the store",
content = mapOf( content = mapOf(
"application/json" to OpenApiSpecMediaType( "application/json" to OpenApiSpecMediaType.Explicit(
schema = OpenApiSpecSchemaRef(`$ref` = "#/components/schemas/Pet") schema = OpenApiSpecSchemaRef(`$ref` = "#/components/schemas/Pet")
), ),
"application/xml" to OpenApiSpecMediaType( "application/xml" to OpenApiSpecMediaType.Explicit(
schema = OpenApiSpecSchemaRef(`$ref` = "#/components/schemas/Pet") schema = OpenApiSpecSchemaRef(`$ref` = "#/components/schemas/Pet")
) )
), ),
required = true required = true
), ),
responses = mapOf( responses = mapOf(
"400" to OpenApiSpecResponse( 400 to OpenApiSpecResponse(
description = "Invalid ID supplied", description = "Invalid ID supplied",
content = emptyMap() content = emptyMap()
), ),
"404" to OpenApiSpecResponse( 404 to OpenApiSpecResponse(
description = "Pet not found", description = "Pet not found",
content = emptyMap() content = emptyMap()
), ),
"405" to OpenApiSpecResponse( 405 to OpenApiSpecResponse(
description = "Validation exception", description = "Validation exception",
content = emptyMap() content = emptyMap()
) )
@ -129,16 +130,16 @@ object TestData {
requestBody = OpenApiSpecRequest( requestBody = OpenApiSpecRequest(
description = "Pet object that needs to be added to the store", description = "Pet object that needs to be added to the store",
content = mapOf( content = mapOf(
"application/json" to OpenApiSpecMediaType( "application/json" to OpenApiSpecMediaType.Referenced(
schema = OpenApiSpecSchemaRef(`$ref` = "#/components/schemas/Pet") schema = OpenApiSpecReferenceObject(`$ref` = "#/components/schemas/Pet")
), ),
"application/xml" to OpenApiSpecMediaType( "application/xml" to OpenApiSpecMediaType.Referenced(
schema = OpenApiSpecSchemaRef(`$ref` = "#/components/schemas/Pet") schema = OpenApiSpecReferenceObject(`$ref` = "#/components/schemas/Pet")
) )
) )
), ),
responses = mapOf( responses = mapOf(
"405" to OpenApiSpecResponse( 405 to OpenApiSpecResponse(
description = "Invalid Input", description = "Invalid Input",
content = emptyMap() content = emptyMap()
) )
@ -174,22 +175,22 @@ object TestData {
) )
), ),
responses = mapOf( responses = mapOf(
"200" to OpenApiSpecResponse( 200 to OpenApiSpecResponse(
description = "successful operation", description = "successful operation",
content = mapOf( content = mapOf(
"application/xml" to OpenApiSpecMediaType( "application/xml" to OpenApiSpecMediaType.Explicit(
schema = OpenApiSpecSchemaArray( schema = OpenApiSpecSchemaArray(
items = OpenApiSpecSchemaRef("#/components/schemas/Pet") items = OpenApiSpecSchemaRef("#/components/schemas/Pet")
) )
), ),
"application/json" to OpenApiSpecMediaType( "application/json" to OpenApiSpecMediaType.Explicit(
schema = OpenApiSpecSchemaArray( schema = OpenApiSpecSchemaArray(
items = OpenApiSpecSchemaRef("#/components/schemas/Pet") items = OpenApiSpecSchemaRef("#/components/schemas/Pet")
) )
) )
) )
), ),
"400" to OpenApiSpecResponse( 400 to OpenApiSpecResponse(
description = "Invalid status value", description = "Invalid status value",
content = mapOf() content = mapOf()
) )

View File

@ -0,0 +1,26 @@
package org.leafygreens.kompendium.util
import org.leafygreens.kompendium.annotations.KompendiumField
import org.leafygreens.kompendium.annotations.KompendiumRequest
import org.leafygreens.kompendium.annotations.KompendiumResponse
data class TestParams(val a: String, val aa: Int)
data class TestNested(val nesty: String)
@KompendiumRequest("Example Request")
data class TestRequest(
@KompendiumField(name = "field_name")
val fieldName: TestNested,
val b: Double,
val aaa: List<Long>
)
@KompendiumResponse(200, "A Successful Endeavor")
data class TestResponse(val c: String)
@KompendiumResponse(201, "Created Successfully")
data class TestCreatedResponse(val id: Int, val c: String)
@KompendiumResponse(status = 204, "Entity was deleted successfully")
object TestDeleteResponse

View File

@ -0,0 +1,58 @@
{
"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" : {
"delete" : {
"tags" : [ ],
"summary" : "Test delete endpoint",
"description" : "testing my deletes",
"responses" : {
"204" : {
"description" : "Entity was deleted successfully",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/TestDeleteResponse"
}
}
}
}
},
"deprecated" : false
}
}
},
"components" : {
"schemas" : {
"TestDeleteResponse" : {
"properties" : { },
"type" : "object"
}
},
"securitySchemes" : { }
},
"security" : [ ],
"tags" : [ ]
}

View File

@ -0,0 +1,62 @@
{
"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",
"responses" : {
"200" : {
"description" : "A Successful Endeavor",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/TestResponse"
}
}
}
}
},
"deprecated" : false
}
}
},
"components" : {
"schemas" : {
"TestResponse" : {
"properties" : {
"c" : {
"type" : "string"
}
},
"type" : "object"
}
},
"securitySchemes" : { }
},
"security" : [ ],
"tags" : [ ]
}

View File

@ -0,0 +1,101 @@
{
"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" : {
"post" : {
"tags" : [ ],
"summary" : "Test post endpoint",
"description" : "Post your tests here!",
"requestBody" : {
"description" : "Example Request",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/TestRequest"
}
}
},
"required" : false
},
"responses" : {
"201" : {
"description" : "Created Successfully",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/TestCreatedResponse"
}
}
}
}
},
"deprecated" : false
}
}
},
"components" : {
"schemas" : {
"TestCreatedResponse" : {
"properties" : {
"c" : {
"type" : "string"
},
"id" : {
"format" : "int32",
"type" : "integer"
}
},
"type" : "object"
},
"TestRequest" : {
"properties" : {
"aaa" : {
"items" : {
"format" : "int64",
"type" : "integer"
},
"type" : "array"
},
"b" : {
"format" : "double",
"type" : "number"
},
"field_name" : {
"properties" : {
"nesty" : {
"type" : "string"
}
},
"type" : "object"
}
},
"type" : "object"
}
},
"securitySchemes" : { }
},
"security" : [ ],
"tags" : [ ]
}

View File

@ -0,0 +1,101 @@
{
"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" : {
"put" : {
"tags" : [ ],
"summary" : "Test put endpoint",
"description" : "Put your tests here!",
"requestBody" : {
"description" : "Example Request",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/TestRequest"
}
}
},
"required" : false
},
"responses" : {
"201" : {
"description" : "Created Successfully",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/TestCreatedResponse"
}
}
}
}
},
"deprecated" : false
}
}
},
"components" : {
"schemas" : {
"TestCreatedResponse" : {
"properties" : {
"c" : {
"type" : "string"
},
"id" : {
"format" : "int32",
"type" : "integer"
}
},
"type" : "object"
},
"TestRequest" : {
"properties" : {
"aaa" : {
"items" : {
"format" : "int64",
"type" : "integer"
},
"type" : "array"
},
"b" : {
"format" : "double",
"type" : "number"
},
"field_name" : {
"properties" : {
"nesty" : {
"type" : "string"
}
},
"type" : "object"
}
},
"type" : "object"
}
},
"securitySchemes" : { }
},
"security" : [ ],
"tags" : [ ]
}

View File

@ -0,0 +1,62 @@
{
"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" : {
"/this/is/a/complex/path/with/an/{id}" : {
"get" : {
"tags" : [ ],
"summary" : "Another get test",
"description" : "testing more",
"responses" : {
"200" : {
"description" : "A Successful Endeavor",
"content" : {
"application/json" : {
"schema" : {
"$ref" : "#/components/schemas/TestResponse"
}
}
}
}
},
"deprecated" : false
}
}
},
"components" : {
"schemas" : {
"TestResponse" : {
"properties" : {
"c" : {
"type" : "string"
}
},
"type" : "object"
}
},
"securitySchemes" : { }
},
"security" : [ ],
"tags" : [ ]
}

View File

@ -4,6 +4,7 @@ import io.ktor.application.Application
import io.ktor.application.call import io.ktor.application.call
import io.ktor.application.install import io.ktor.application.install
import io.ktor.features.ContentNegotiation import io.ktor.features.ContentNegotiation
import io.ktor.http.HttpStatusCode
import io.ktor.jackson.jackson import io.ktor.jackson.jackson
import io.ktor.response.respond import io.ktor.response.respond
import io.ktor.response.respondText import io.ktor.response.respondText
@ -12,14 +13,22 @@ import io.ktor.routing.route
import io.ktor.routing.routing import io.ktor.routing.routing
import io.ktor.server.engine.embeddedServer import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty import io.ktor.server.netty.Netty
import java.net.URI
import org.leafygreens.kompendium.Kompendium.notarizedDelete
import org.leafygreens.kompendium.Kompendium.notarizedGet import org.leafygreens.kompendium.Kompendium.notarizedGet
import org.leafygreens.kompendium.Kompendium.notarizedPost import org.leafygreens.kompendium.Kompendium.notarizedPost
import org.leafygreens.kompendium.Kompendium.notarizedPut import org.leafygreens.kompendium.Kompendium.notarizedPut
import org.leafygreens.kompendium.Kompendium.openApiSpec import org.leafygreens.kompendium.Kompendium.openApiSpec
import org.leafygreens.kompendium.annotations.KompendiumField import org.leafygreens.kompendium.annotations.KompendiumField
import org.leafygreens.kompendium.annotations.KompendiumRequest
import org.leafygreens.kompendium.annotations.KompendiumResponse
import org.leafygreens.kompendium.models.meta.MethodInfo import org.leafygreens.kompendium.models.meta.MethodInfo
import org.leafygreens.kompendium.models.oas.OpenApiSpecInfo 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.playground.KompendiumTOC.testIdGetInfo 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.testSingleGetInfo
import org.leafygreens.kompendium.playground.KompendiumTOC.testSinglePostInfo import org.leafygreens.kompendium.playground.KompendiumTOC.testSinglePostInfo
import org.leafygreens.kompendium.playground.KompendiumTOC.testSinglePutInfo import org.leafygreens.kompendium.playground.KompendiumTOC.testSinglePutInfo
@ -32,21 +41,36 @@ fun main() {
).start(wait = true) ).start(wait = true)
} }
data class A(val a: String, val aa: Int, val aaa: List<Long>) data class ExampleParams(val a: String, val aa: Int)
data class B(
@KompendiumField(name = "AYY")
val a: A,
val b: Double,
)
data class C(val c: String)
data class D(val a: A, val b: B, val c: C) data class ExampleNested(val nesty: String)
@KompendiumResponse(status = 204, "Entity was deleted successfully")
object DeleteResponse
@KompendiumRequest("Example Request")
data class ExampleRequest(
@KompendiumField(name = "field_name")
val fieldName: ExampleNested,
val b: Double,
val aaa: List<Long>
)
private const val HTTP_OK = 200
private const val HTTP_CREATED = 201
@KompendiumResponse(HTTP_OK, "A Successful Endeavor")
data class ExampleResponse(val c: String)
@KompendiumResponse(HTTP_CREATED, "Created Successfully")
data class ExampleCreatedResponse(val id: Int, val c: String)
object KompendiumTOC { object KompendiumTOC {
val testIdGetInfo = MethodInfo("Get Test", "Test for getting", tags = setOf("test", "example", "get")) val testIdGetInfo = MethodInfo("Get Test", "Test for getting", tags = setOf("test", "example", "get"))
val testSingleGetInfo = MethodInfo("Another get test", "testing more") val testSingleGetInfo = MethodInfo("Another get test", "testing more")
val testSinglePostInfo = MethodInfo("Test post endpoint", "Post your tests here!") val testSinglePostInfo = MethodInfo("Test post endpoint", "Post your tests here!")
val testSinglePutInfo = MethodInfo("Test put endpoint", "Put your tests here!") val testSinglePutInfo = MethodInfo("Test put endpoint", "Put your tests here!")
val testSingleDeleteInfo = MethodInfo("Test delete endpoint", "testing my deletes")
} }
fun Application.mainModule() { fun Application.mainModule() {
@ -56,31 +80,56 @@ fun Application.mainModule() {
routing { routing {
route("/test") { route("/test") {
route("/{id}") { route("/{id}") {
notarizedGet(testIdGetInfo) { notarizedGet<ExampleParams, ExampleResponse>(testIdGetInfo) {
call.respondText("get by id") call.respondText("get by id")
} }
} }
route("/single") { route("/single") {
notarizedGet(testSingleGetInfo) { notarizedGet<ExampleRequest, ExampleResponse>(testSingleGetInfo) {
call.respondText("get single") call.respondText("get single")
} }
notarizedPost<A, B, C>(testSinglePostInfo) { notarizedPost<ExampleParams, ExampleRequest, ExampleCreatedResponse>(testSinglePostInfo) {
call.respondText("test post") call.respondText("test post")
} }
notarizedPut<A, B, D>(testSinglePutInfo) { notarizedPut<ExampleParams, ExampleRequest, ExampleCreatedResponse>(testSinglePutInfo) {
call.respondText { "hey" } call.respondText { "hey" }
} }
notarizedDelete<Unit, DeleteResponse>(testSingleDeleteInfo) {
call.respondText { "heya" }
}
} }
} }
route("/openapi.json") { route("/openapi.json") {
get { get {
call.respond(openApiSpec.copy( call.respond(
openApiSpec.copy(
info = OpenApiSpecInfo( info = OpenApiSpecInfo(
title = "Test API", title = "Test API",
version = "1.3.3.7", version = "1.33.7",
description = "An amazing, fully-ish 😉 generated API spec" description = "An amazing, fully-ish 😉 generated API spec",
termsOfService = URI("https://example.com"),
contact = OpenApiSpecInfoContact(
name = "Homer Simpson",
email = "chunkylover53@aol.com",
url = URI("https://gph.is/1NPUDiM")
),
license = OpenApiSpecInfoLicense(
name = "MIT",
url = URI("https://github.com/lg-backbone/kompendium/blob/main/LICENSE")
)
),
servers = mutableListOf(
OpenApiSpecServer(
url = URI("https://myawesomeapi.com"),
description = "Production instance of my API"
),
OpenApiSpecServer(
url = URI("https://staging.myawesomeapi.com"),
description = "Where the fun stuff happens"
)
)
)
) )
))
} }
} }
} }