diff --git a/CHANGELOG.md b/CHANGELOG.md index 554f58abc..9c9c24555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### Added diff --git a/README.md b/README.md index f68ca2027..77d5fb2c9 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Ktor native functions when implementing their API, and will supplement with Kompendium code in order to generate the appropriate spec. -## Modules +## In depth TODO @@ -25,20 +25,23 @@ fun Application.mainModule() { routing { route("/test") { route("/{id}") { - notarizedGet(testIdGetInfo) { + notarizedGet(testIdGetInfo) { call.respondText("get by id") } } route("/single") { - notarizedGet(testSingleGetInfo) { + notarizedGet(testSingleGetInfo) { call.respondText("get single") } - notarizedPost(testSinglePostInfo) { + notarizedPost(testSinglePostInfo) { call.respondText("test post") } - notarizedPut(testSinglePutInfo) { + notarizedPut(testSinglePutInfo) { call.respondText { "hey" } } + notarizedDelete(testSingleDeleteInfo) { + call.respondText { "heya" } + } } } 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 +) + +@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") +} ``` diff --git a/gradle.properties b/gradle.properties index 9294b51fb..b5cf24255 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ # Backbone -project.version=0.0.1 +project.version=0.0.3 # Kotlin kotlin.code.style=official # Gradle diff --git a/kompendium-core/build.gradle.kts b/kompendium-core/build.gradle.kts index 50c08531f..0d5e9dd02 100644 --- a/kompendium-core/build.gradle.kts +++ b/kompendium-core/build.gradle.kts @@ -10,6 +10,7 @@ dependencies { testImplementation("org.jetbrains.kotlin:kotlin-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit") testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.12.0") + testImplementation("io.ktor:ktor-server-test-host:1.5.3") } publishing { diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kompendium.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kompendium.kt index 43e813eb3..4da130838 100644 --- a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kompendium.kt +++ b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/Kompendium.kt @@ -3,7 +3,6 @@ package org.leafygreens.kompendium import io.ktor.application.ApplicationCall import io.ktor.http.HttpMethod import io.ktor.routing.Route -import io.ktor.routing.createRouteFromPath import io.ktor.routing.method import io.ktor.util.pipeline.PipelineInterceptor import java.lang.reflect.ParameterizedType @@ -13,83 +12,128 @@ import kotlin.reflect.full.findAnnotation import kotlin.reflect.full.memberProperties import kotlin.reflect.jvm.javaField 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.FormatSchema import org.leafygreens.kompendium.models.oas.ObjectSchema import org.leafygreens.kompendium.models.oas.OpenApiSpec import org.leafygreens.kompendium.models.oas.OpenApiSpecComponentSchema 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.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.meta.MethodInfo import org.leafygreens.kompendium.util.Helpers.calculatePath import org.leafygreens.kompendium.util.Helpers.putPairIfAbsent object Kompendium { - val openApiSpec = OpenApiSpec( + + const val COMPONENT_SLUG = "#/components/schemas" + + var openApiSpec = OpenApiSpec( info = OpenApiSpecInfo(), servers = mutableListOf(), paths = mutableMapOf() ) - fun Route.notarizedGet(info: MethodInfo, body: PipelineInterceptor): Route { + inline fun Route.notarizedGet( + info: MethodInfo, + noinline body: PipelineInterceptor + ): Route = generateComponentSchemas() { val path = calculatePath() openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() } - openApiSpec.paths[path]?.get = OpenApiSpecPathItemOperation( - summary = info.summary, - description = info.description, - tags = info.tags - ) + openApiSpec.paths[path]?.get = info.parseMethodInfo() return method(HttpMethod.Get) { handle(body) } } - inline fun Route.notarizedPost( + inline fun Route.notarizedPost( info: MethodInfo, noinline body: PipelineInterceptor - ): Route = generateComponentSchemas(info, body) { i, b -> + ): Route = generateComponentSchemas() { val path = calculatePath() openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() } - openApiSpec.paths[path]?.post = OpenApiSpecPathItemOperation( - summary = i.summary, - description = i.description, - tags = i.tags - ) - return method(HttpMethod.Post) { handle(b) } + openApiSpec.paths[path]?.post = info.parseMethodInfo() + return method(HttpMethod.Post) { handle(body) } } - inline fun Route.notarizedPut( + inline fun Route.notarizedPut( info: MethodInfo, noinline body: PipelineInterceptor, - ): Route = generateComponentSchemas(info, body) { i, b -> + ): Route = generateComponentSchemas() { val path = calculatePath() openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() } - openApiSpec.paths[path]?.put = OpenApiSpecPathItemOperation( - summary = i.summary, - description = i.description, - tags = i.tags - ) - return method(HttpMethod.Put) { handle(b) } + openApiSpec.paths[path]?.put = info.parseMethodInfo() + return method(HttpMethod.Put) { handle(body) } } - @OptIn(KompendiumInternal::class) - inline fun generateComponentSchemas( + inline fun Route.notarizedDelete( info: MethodInfo, - noinline body: PipelineInterceptor, - block: (MethodInfo, PipelineInterceptor) -> Route + noinline body: PipelineInterceptor + ): Route = generateComponentSchemas { + val path = calculatePath() + openApiSpec.paths.getOrPut(path) { OpenApiSpecPathItem() } + openApiSpec.paths[path]?.delete = info.parseMethodInfo() + return method(HttpMethod.Delete) { handle(body) } + } + + inline fun MethodInfo.parseMethodInfo() = OpenApiSpecPathItemOperation( + summary = this.summary, + description = this.description, + tags = this.tags, + deprecated = this.deprecated, + responses = parseResponseAnnotation()?.let { mapOf(it) }, + requestBody = parseRequestAnnotation() + ) + + inline fun generateComponentSchemas( + block: () -> Route ): Route { - openApiSpec.components.schemas.putPairIfAbsent(objectSchemaPair(TQ::class)) - openApiSpec.components.schemas.putPairIfAbsent(objectSchemaPair(TR::class)) - openApiSpec.components.schemas.putPairIfAbsent(objectSchemaPair(TP::class)) - return block.invoke(info, body) + if (TResp::class != Unit::class) openApiSpec.components.schemas.putPairIfAbsent(objectSchemaPair(TResp::class)) + if (TReq::class != Unit::class) openApiSpec.components.schemas.putPairIfAbsent(objectSchemaPair(TReq::class)) +// openApiSpec.components.schemas.putPairIfAbsent(objectSchemaPair(TParam::class)) + return block.invoke() + } + + inline fun parseRequestAnnotation(): OpenApiSpecRequest? = when (TReq::class) { + Unit::class -> null + else -> { + val anny = TReq::class.findAnnotation() ?: 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 parseResponseAnnotation(): Pair? = when (TResp::class) { + Unit::class -> null + else -> { + val anny = TResp::class.findAnnotation() ?: 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 fun objectSchemaPair(clazz: KClass<*>): Pair { val o = objectSchema(clazz) - return Pair(clazz.qualifiedName!!, o) + return Pair(clazz.simpleName!!, o) } private fun objectSchema(clazz: KClass<*>): ObjectSchema = @@ -115,7 +159,6 @@ object Kompendium { return ArraySchema(fieldToSchema(listType)) } - @OptIn(KompendiumInternal::class) private fun fieldToSchema(field: KClass<*>): OpenApiSpecComponentSchema = when (field) { Int::class -> FormatSchema("int32", "integer") Long::class -> FormatSchema("int64", "integer") @@ -125,4 +168,12 @@ object Kompendium { Boolean::class -> SimpleSchema("boolean") else -> objectSchema(field) } + + internal fun resetSchema() { + openApiSpec = OpenApiSpec( + info = OpenApiSpecInfo(), + servers = mutableListOf(), + paths = mutableMapOf() + ) + } } diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/annotations/KompendiumContact.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/annotations/KompendiumContact.kt deleted file mode 100644 index bf075271e..000000000 --- a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/annotations/KompendiumContact.kt +++ /dev/null @@ -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 = "" -) diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/annotations/KompendiumInfo.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/annotations/KompendiumInfo.kt deleted file mode 100644 index 8ad223688..000000000 --- a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/annotations/KompendiumInfo.kt +++ /dev/null @@ -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 = "" -) diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/annotations/KompendiumInternal.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/annotations/KompendiumInternal.kt deleted file mode 100644 index 2146e2350..000000000 --- a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/annotations/KompendiumInternal.kt +++ /dev/null @@ -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 diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/annotations/KompendiumModule.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/annotations/KompendiumModule.kt deleted file mode 100644 index 1d6bec009..000000000 --- a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/annotations/KompendiumModule.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.leafygreens.kompendium.annotations - -@Retention(AnnotationRetention.SOURCE) -@Target(AnnotationTarget.FUNCTION) -annotation class KompendiumModule diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/annotations/KompendiumRequest.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/annotations/KompendiumRequest.kt new file mode 100644 index 000000000..01756a512 --- /dev/null +++ b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/annotations/KompendiumRequest.kt @@ -0,0 +1,7 @@ +package org.leafygreens.kompendium.annotations + +annotation class KompendiumRequest( + val description: String, + val required: Boolean = true, + val mediaTypes: Array = ["application/json"] +) diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/annotations/KompendiumResponse.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/annotations/KompendiumResponse.kt new file mode 100644 index 000000000..adc61ee19 --- /dev/null +++ b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/annotations/KompendiumResponse.kt @@ -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 = ["application/json"] +) diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/annotations/KompendiumServers.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/annotations/KompendiumServers.kt deleted file mode 100644 index 999523a0b..000000000 --- a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/annotations/KompendiumServers.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.leafygreens.kompendium.annotations - -@Retention(AnnotationRetention.SOURCE) -@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) -annotation class KompendiumServers( - val urls: Array -) diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/meta/MethodInfo.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/meta/MethodInfo.kt index 1d4588229..fd5c2d5f3 100644 --- a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/meta/MethodInfo.kt +++ b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/meta/MethodInfo.kt @@ -1,3 +1,8 @@ package org.leafygreens.kompendium.models.meta -data class MethodInfo(val summary: String, val description: String? = null, val tags: Set = emptySet()) +data class MethodInfo( + val summary: String, + val description: String? = null, + val tags: Set = emptySet(), + val deprecated: Boolean = false +) diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/oas/OpenApiSpecMediaType.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/oas/OpenApiSpecMediaType.kt index b792586fd..aa8b037c2 100644 --- a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/oas/OpenApiSpecMediaType.kt +++ b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/oas/OpenApiSpecMediaType.kt @@ -1,9 +1,16 @@ package org.leafygreens.kompendium.models.oas // TODO Oof -> https://swagger.io/specification/#media-type-object -data class OpenApiSpecMediaType( - val schema: OpenApiSpecSchema, // TODO sheesh -> https://swagger.io/specification/#schema-object - val example: String? = null, // TODO Enforce type? then serialize? - val examples: Map? = null, // needs to be mutually exclusive with example - val encoding: Map? = null // todo encoding object -> https://swagger.io/specification/#encoding-object -) +sealed class OpenApiSpecMediaType { + data class Explicit( + val schema: OpenApiSpecSchema, // TODO sheesh -> https://swagger.io/specification/#schema-object + val example: String? = null, // TODO Enforce type? then serialize? + val examples: Map? = null, // needs to be mutually exclusive with example + val encoding: Map? = null // todo encoding object -> https://swagger.io/specification/#encoding-object + ) : OpenApiSpecMediaType() + + data class Referenced( + val schema: OpenApiSpecReferenceObject + ) : OpenApiSpecMediaType() +} + diff --git a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/oas/OpenApiSpecPathItemOperation.kt b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/oas/OpenApiSpecPathItemOperation.kt index e894f2786..419116382 100644 --- a/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/oas/OpenApiSpecPathItemOperation.kt +++ b/kompendium-core/src/main/kotlin/org/leafygreens/kompendium/models/oas/OpenApiSpecPathItemOperation.kt @@ -9,7 +9,7 @@ data class OpenApiSpecPathItemOperation( var parameters: List? = null, var requestBody: OpenApiSpecReferencable? = null, // TODO How to enforce `default` requirement 🧐 - var responses: Map? = null, + var responses: Map? = null, var callbacks: Map? = null, var deprecated: Boolean = false, // todo big yikes... also needs to reference objects in the security scheme 🤔 diff --git a/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/KompendiumTest.kt b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/KompendiumTest.kt index e0feed44d..6ed614098 100644 --- a/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/KompendiumTest.kt +++ b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/KompendiumTest.kt @@ -1,13 +1,311 @@ 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.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 { + @AfterTest + fun `reset kompendium`() { + Kompendium.resetSchema() + } + @Test 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") } + @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(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(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(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" + ) + ) + ) + ) + } + } + } + } + } diff --git a/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/TestData.kt b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/TestData.kt index 9c422b65d..2b7322fda 100644 --- a/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/TestData.kt +++ b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/TestData.kt @@ -14,6 +14,7 @@ import org.leafygreens.kompendium.models.oas.OpenApiSpecOAuthFlows import org.leafygreens.kompendium.models.oas.OpenApiSpecParameter import org.leafygreens.kompendium.models.oas.OpenApiSpecPathItem import org.leafygreens.kompendium.models.oas.OpenApiSpecPathItemOperation +import org.leafygreens.kompendium.models.oas.OpenApiSpecReferenceObject import org.leafygreens.kompendium.models.oas.OpenApiSpecRequest import org.leafygreens.kompendium.models.oas.OpenApiSpecResponse import org.leafygreens.kompendium.models.oas.OpenApiSpecSchemaArray @@ -92,25 +93,25 @@ object TestData { requestBody = OpenApiSpecRequest( description = "Pet object that needs to be added to the store", content = mapOf( - "application/json" to OpenApiSpecMediaType( + "application/json" to OpenApiSpecMediaType.Explicit( schema = OpenApiSpecSchemaRef(`$ref` = "#/components/schemas/Pet") ), - "application/xml" to OpenApiSpecMediaType( + "application/xml" to OpenApiSpecMediaType.Explicit( schema = OpenApiSpecSchemaRef(`$ref` = "#/components/schemas/Pet") ) ), required = true ), responses = mapOf( - "400" to OpenApiSpecResponse( + 400 to OpenApiSpecResponse( description = "Invalid ID supplied", content = emptyMap() ), - "404" to OpenApiSpecResponse( + 404 to OpenApiSpecResponse( description = "Pet not found", content = emptyMap() ), - "405" to OpenApiSpecResponse( + 405 to OpenApiSpecResponse( description = "Validation exception", content = emptyMap() ) @@ -129,16 +130,16 @@ object TestData { requestBody = OpenApiSpecRequest( description = "Pet object that needs to be added to the store", content = mapOf( - "application/json" to OpenApiSpecMediaType( - schema = OpenApiSpecSchemaRef(`$ref` = "#/components/schemas/Pet") + "application/json" to OpenApiSpecMediaType.Referenced( + schema = OpenApiSpecReferenceObject(`$ref` = "#/components/schemas/Pet") ), - "application/xml" to OpenApiSpecMediaType( - schema = OpenApiSpecSchemaRef(`$ref` = "#/components/schemas/Pet") + "application/xml" to OpenApiSpecMediaType.Referenced( + schema = OpenApiSpecReferenceObject(`$ref` = "#/components/schemas/Pet") ) ) ), responses = mapOf( - "405" to OpenApiSpecResponse( + 405 to OpenApiSpecResponse( description = "Invalid Input", content = emptyMap() ) @@ -174,22 +175,22 @@ object TestData { ) ), responses = mapOf( - "200" to OpenApiSpecResponse( + 200 to OpenApiSpecResponse( description = "successful operation", content = mapOf( - "application/xml" to OpenApiSpecMediaType( + "application/xml" to OpenApiSpecMediaType.Explicit( schema = OpenApiSpecSchemaArray( items = OpenApiSpecSchemaRef("#/components/schemas/Pet") ) ), - "application/json" to OpenApiSpecMediaType( + "application/json" to OpenApiSpecMediaType.Explicit( schema = OpenApiSpecSchemaArray( items = OpenApiSpecSchemaRef("#/components/schemas/Pet") ) ) ) ), - "400" to OpenApiSpecResponse( + 400 to OpenApiSpecResponse( description = "Invalid status value", content = mapOf() ) diff --git a/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/TestModels.kt b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/TestModels.kt new file mode 100644 index 000000000..81360397b --- /dev/null +++ b/kompendium-core/src/test/kotlin/org/leafygreens/kompendium/util/TestModels.kt @@ -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 +) + +@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 diff --git a/kompendium-core/src/test/resources/notarized_delete.json b/kompendium-core/src/test/resources/notarized_delete.json new file mode 100644 index 000000000..dec95e2d8 --- /dev/null +++ b/kompendium-core/src/test/resources/notarized_delete.json @@ -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" : [ ] +} diff --git a/kompendium-core/src/test/resources/notarized_get.json b/kompendium-core/src/test/resources/notarized_get.json new file mode 100644 index 000000000..88e06537f --- /dev/null +++ b/kompendium-core/src/test/resources/notarized_get.json @@ -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" : [ ] +} diff --git a/kompendium-core/src/test/resources/notarized_post.json b/kompendium-core/src/test/resources/notarized_post.json new file mode 100644 index 000000000..2974cf9c9 --- /dev/null +++ b/kompendium-core/src/test/resources/notarized_post.json @@ -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" : [ ] +} diff --git a/kompendium-core/src/test/resources/notarized_put.json b/kompendium-core/src/test/resources/notarized_put.json new file mode 100644 index 000000000..adbb0f791 --- /dev/null +++ b/kompendium-core/src/test/resources/notarized_put.json @@ -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" : [ ] +} diff --git a/kompendium-core/src/test/resources/path_parser.json b/kompendium-core/src/test/resources/path_parser.json new file mode 100644 index 000000000..4f85d5dd2 --- /dev/null +++ b/kompendium-core/src/test/resources/path_parser.json @@ -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" : [ ] +} diff --git a/kompendium-playground/src/main/kotlin/org/leafygreens/kompendium/playground/Main.kt b/kompendium-playground/src/main/kotlin/org/leafygreens/kompendium/playground/Main.kt index 56b70f2c2..0c828dada 100644 --- a/kompendium-playground/src/main/kotlin/org/leafygreens/kompendium/playground/Main.kt +++ b/kompendium-playground/src/main/kotlin/org/leafygreens/kompendium/playground/Main.kt @@ -4,6 +4,7 @@ import io.ktor.application.Application import io.ktor.application.call import io.ktor.application.install import io.ktor.features.ContentNegotiation +import io.ktor.http.HttpStatusCode import io.ktor.jackson.jackson import io.ktor.response.respond import io.ktor.response.respondText @@ -12,14 +13,22 @@ import io.ktor.routing.route import io.ktor.routing.routing import io.ktor.server.engine.embeddedServer 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.notarizedPost import org.leafygreens.kompendium.Kompendium.notarizedPut import org.leafygreens.kompendium.Kompendium.openApiSpec 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.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.testSingleDeleteInfo import org.leafygreens.kompendium.playground.KompendiumTOC.testSingleGetInfo import org.leafygreens.kompendium.playground.KompendiumTOC.testSinglePostInfo import org.leafygreens.kompendium.playground.KompendiumTOC.testSinglePutInfo @@ -32,21 +41,36 @@ fun main() { ).start(wait = true) } -data class A(val a: String, val aa: Int, val aaa: List) -data class B( - @KompendiumField(name = "AYY") - val a: A, - val b: Double, -) -data class C(val c: String) +data class ExampleParams(val a: String, val aa: Int) -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 +) + +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 { 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") } fun Application.mainModule() { @@ -56,31 +80,56 @@ fun Application.mainModule() { routing { route("/test") { route("/{id}") { - notarizedGet(testIdGetInfo) { + notarizedGet(testIdGetInfo) { call.respondText("get by id") } } route("/single") { - notarizedGet(testSingleGetInfo) { + notarizedGet(testSingleGetInfo) { call.respondText("get single") } - notarizedPost(testSinglePostInfo) { + notarizedPost(testSinglePostInfo) { call.respondText("test post") } - notarizedPut(testSinglePutInfo) { + notarizedPut(testSinglePutInfo) { call.respondText { "hey" } } + notarizedDelete(testSingleDeleteInfo) { + call.respondText { "heya" } + } } } route("/openapi.json") { get { - call.respond(openApiSpec.copy( - info = OpenApiSpecInfo( - title = "Test API", - version = "1.3.3.7", - description = "An amazing, fully-ish 😉 generated API spec" + call.respond( + 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" + ) + ) ) - )) + ) } } }