From 4946a27327145e529d9c4084accc82cdd63f4d27 Mon Sep 17 00:00:00 2001 From: Serhii Prodan Date: Sat, 29 Oct 2022 14:14:32 +0200 Subject: [PATCH] feat: add plugin to support ktor-server-resources (#358) --- CHANGELOG.md | 2 + docs/SUMMARY.md | 1 + docs/playground.md | 1 + docs/plugins/notarized_resources.md | 60 +++++++++ .../locations/NotarizedLocations.kt | 6 +- playground/build.gradle.kts | 2 + .../playground/ResourcesPlayground.kt | 85 ++++++++++++ resources/build.gradle.kts | 42 ++++++ .../resources/NotarizedResources.kt | 78 +++++++++++ .../resources/KompendiumResourcesTest.kt | 120 +++++++++++++++++ .../kompendium/resources/util/TestModels.kt | 17 +++ .../resources/T0001__simple_resource.json | 92 +++++++++++++ .../resources/T0002__nested_resources.json | 124 ++++++++++++++++++ settings.gradle.kts | 1 + 14 files changed, 630 insertions(+), 1 deletion(-) create mode 100644 docs/plugins/notarized_resources.md create mode 100644 playground/src/main/kotlin/io/bkbn/kompendium/playground/ResourcesPlayground.kt create mode 100644 resources/build.gradle.kts create mode 100644 resources/src/main/kotlin/io/bkbn/kompendium/resources/NotarizedResources.kt create mode 100644 resources/src/test/kotlin/io/bkbn/kompendium/resources/KompendiumResourcesTest.kt create mode 100644 resources/src/test/kotlin/io/bkbn/kompendium/resources/util/TestModels.kt create mode 100644 resources/src/test/resources/T0001__simple_resource.json create mode 100644 resources/src/test/resources/T0002__nested_resources.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 749546e75..c5a28cf64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- New `kompendium-resources` plugin to support Ktor Resources API + ### Changed ### Remove diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index e10eadc88..be1a61fdc 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -5,4 +5,5 @@ * [Notarized Application](plugins/notarized_application.md) * [Notarized Route](plugins/notarized_route.md) * [Notarized Locations](plugins/notarized_locations.md) + * [Notarized Resources](plugins/notarized_resources.md) * [The Playground](playground.md) diff --git a/docs/playground.md b/docs/playground.md index 69d85e786..5c75f295c 100644 --- a/docs/playground.md +++ b/docs/playground.md @@ -13,6 +13,7 @@ At the moment, the following playground applications are | Hidden Docs | Place your generated documentation behind authorization | | Jackson | Serialization using Jackson instead of the default KotlinX | | Locations | Using the Ktor Locations API to define routes | +| Resources | Using the Ktor Resources API to define routes | You can find all of the playground examples [here](https://github.com/bkbnio/kompendium/tree/main/playground/src/main/kotlin/io/bkbn/kompendium/playground) diff --git a/docs/plugins/notarized_resources.md b/docs/plugins/notarized_resources.md new file mode 100644 index 000000000..a8fb2a04d --- /dev/null +++ b/docs/plugins/notarized_resources.md @@ -0,0 +1,60 @@ +The Ktor Resources API allows users to define their routes in a type-safe manner. + +You can read more about it [here](https://ktor.io/docs/type-safe-routing.html). + +Kompendium supports Ktor-Resources through an ancillary module `kompendium-resources` + +## Adding the Artifact + +Prior to documenting your resources, you will need to add the artifact to your gradle build file. + +```kotlin +dependencies { + implementation("io.bkbn:kompendium-resources:$version") +} +``` + +## Installing Plugin + +Once you have installed the dependency, you can install the plugin. The `NotarizedResources` plugin is an _application_ level plugin, and **must** be install after both the `NotarizedApplication` plugin and the Ktor `Resources` plugin. + +```kotlin +private fun Application.mainModule() { + install(Resources) + install(NotarizedApplication()) { + spec = baseSpec + } + install(NotarizedResources()) { + resources = mapOf( + Listing::class to NotarizedResources.ResourceMetadata( + parameters = listOf( + Parameter( + name = "name", + `in` = Parameter.Location.path, + schema = TypeDefinition.STRING + ), + Parameter( + name = "page", + `in` = Parameter.Location.path, + schema = TypeDefinition.INT + ) + ), + get = GetInfo.builder { + summary("Get user by id") + description("A very neat endpoint!") + response { + responseCode(HttpStatusCode.OK) + responseType() + description("Will return whether or not the user is real 😱") + } + } + ), + ) + } +} +``` + +Here, the `resources` property is a map of `KClass<*>` to `ResourceMetadata` instance describing that resource. This metadata is functionally identical to how a standard `NotarizedRoute` is defined. + +> ⚠️ If you try to map a class that is not annotated with the ktor `@Resource` annotation, you will get a runtime +> exception! diff --git a/locations/src/main/kotlin/io/bkbn/kompendium/locations/NotarizedLocations.kt b/locations/src/main/kotlin/io/bkbn/kompendium/locations/NotarizedLocations.kt index 14b6811f0..2c044b028 100644 --- a/locations/src/main/kotlin/io/bkbn/kompendium/locations/NotarizedLocations.kt +++ b/locations/src/main/kotlin/io/bkbn/kompendium/locations/NotarizedLocations.kt @@ -20,6 +20,11 @@ import kotlin.reflect.full.findAnnotation import kotlin.reflect.full.hasAnnotation import kotlin.reflect.full.memberProperties +@Deprecated( + message = "This functionality is deprecated and will be removed in the future. " + + "Use 'ktor-server-resources' with 'kompendium-resources' plugin instead.", + level = DeprecationLevel.WARNING +) object NotarizedLocations { data class LocationMetadata( @@ -43,7 +48,6 @@ object NotarizedLocations { name = "NotarizedLocations", createConfiguration = ::Config ) { - println("hi") val spec = application.attributes[KompendiumAttributes.openApiSpec] val serializableReader = application.attributes[KompendiumAttributes.schemaConfigurator] pluginConfig.locations.forEach { (k, v) -> diff --git a/playground/build.gradle.kts b/playground/build.gradle.kts index 7348ff580..db85b6284 100644 --- a/playground/build.gradle.kts +++ b/playground/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { // IMPLEMENTATION implementation(projects.kompendiumCore) implementation(projects.kompendiumLocations) + implementation(projects.kompendiumResources) // Ktor val ktorVersion: String by project @@ -29,6 +30,7 @@ dependencies { implementation("io.ktor:ktor-serialization-jackson:$ktorVersion") implementation("io.ktor:ktor-serialization-gson:$ktorVersion") implementation("io.ktor:ktor-server-locations:$ktorVersion") + implementation("io.ktor:ktor-server-resources:$ktorVersion") // Logging implementation("org.apache.logging.log4j:log4j-api-kotlin:1.2.0") diff --git a/playground/src/main/kotlin/io/bkbn/kompendium/playground/ResourcesPlayground.kt b/playground/src/main/kotlin/io/bkbn/kompendium/playground/ResourcesPlayground.kt new file mode 100644 index 000000000..0108002b2 --- /dev/null +++ b/playground/src/main/kotlin/io/bkbn/kompendium/playground/ResourcesPlayground.kt @@ -0,0 +1,85 @@ +package io.bkbn.kompendium.playground + +import io.bkbn.kompendium.core.metadata.GetInfo +import io.bkbn.kompendium.core.plugin.NotarizedApplication +import io.bkbn.kompendium.core.routes.redoc +import io.bkbn.kompendium.json.schema.definition.TypeDefinition +import io.bkbn.kompendium.oas.payload.Parameter +import io.bkbn.kompendium.oas.serialization.KompendiumSerializersModule +import io.bkbn.kompendium.playground.util.ExampleResponse +import io.bkbn.kompendium.playground.util.Util.baseSpec +import io.bkbn.kompendium.resources.NotarizedResources +import io.ktor.http.HttpStatusCode +import io.ktor.resources.Resource +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.Application +import io.ktor.server.application.call +import io.ktor.server.application.install +import io.ktor.server.cio.CIO +import io.ktor.server.engine.embeddedServer +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.resources.Resources +import io.ktor.server.resources.get +import io.ktor.server.response.respondText +import io.ktor.server.routing.routing +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +fun main() { + embeddedServer( + CIO, + port = 8081, + module = Application::mainModule + ).start(wait = true) +} + +private fun Application.mainModule() { + install(Resources) + install(ContentNegotiation) { + json(Json { + serializersModule = KompendiumSerializersModule.module + encodeDefaults = true + explicitNulls = false + }) + } + install(NotarizedApplication()) { + spec = baseSpec + } + install(NotarizedResources()) { + resources = mapOf( + ListingResource::class to NotarizedResources.ResourceMetadata( + parameters = listOf( + Parameter( + name = "name", + `in` = Parameter.Location.path, + schema = TypeDefinition.STRING + ), + Parameter( + name = "page", + `in` = Parameter.Location.path, + schema = TypeDefinition.INT + ) + ), + get = GetInfo.builder { + summary("Get user by id") + description("A very neat endpoint!") + response { + responseCode(HttpStatusCode.OK) + responseType() + description("Will return whether or not the user is real 😱") + } + } + ), + ) + } + routing { + redoc(pageTitle = "Simple API Docs") + get { listing -> + call.respondText("Listing ${listing.name}, page ${listing.page}") + } + } +} + +@Serializable +@Resource("/list/{name}/page/{page}") +data class ListingResource(val name: String, val page: Int) diff --git a/resources/build.gradle.kts b/resources/build.gradle.kts new file mode 100644 index 000000000..7eaf88ba3 --- /dev/null +++ b/resources/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + kotlin("jvm") + kotlin("plugin.serialization") + id("io.bkbn.sourdough.library.jvm") + id("io.gitlab.arturbosch.detekt") + id("com.adarshr.test-logger") + id("maven-publish") + id("java-library") + id("signing") + id("org.jetbrains.kotlinx.kover") +} + +sourdoughLibrary { + libraryName.set("Kompendium Resources") + libraryDescription.set("Supplemental library for Kompendium offering support for Ktor's Resources API") +} + +dependencies { + // Versions + val detektVersion: String by project + + // IMPLEMENTATION + + implementation(projects.kompendiumCore) + implementation("io.ktor:ktor-server-core:2.1.2") + implementation("io.ktor:ktor-server-resources:2.1.2") + + // TESTING + + testImplementation(testFixtures(projects.kompendiumCore)) + + // Formatting + detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:$detektVersion") +} + +testing { + suites { + named("test", JvmTestSuite::class) { + useJUnitJupiter() + } + } +} diff --git a/resources/src/main/kotlin/io/bkbn/kompendium/resources/NotarizedResources.kt b/resources/src/main/kotlin/io/bkbn/kompendium/resources/NotarizedResources.kt new file mode 100644 index 000000000..8b60bf0ca --- /dev/null +++ b/resources/src/main/kotlin/io/bkbn/kompendium/resources/NotarizedResources.kt @@ -0,0 +1,78 @@ +package io.bkbn.kompendium.resources + +import io.bkbn.kompendium.core.attribute.KompendiumAttributes +import io.bkbn.kompendium.core.metadata.DeleteInfo +import io.bkbn.kompendium.core.metadata.GetInfo +import io.bkbn.kompendium.core.metadata.HeadInfo +import io.bkbn.kompendium.core.metadata.OptionsInfo +import io.bkbn.kompendium.core.metadata.PatchInfo +import io.bkbn.kompendium.core.metadata.PostInfo +import io.bkbn.kompendium.core.metadata.PutInfo +import io.bkbn.kompendium.core.util.Helpers.addToSpec +import io.bkbn.kompendium.core.util.SpecConfig +import io.bkbn.kompendium.oas.path.Path +import io.bkbn.kompendium.oas.payload.Parameter +import io.ktor.resources.Resource +import io.ktor.server.application.createApplicationPlugin +import kotlin.reflect.KClass +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.hasAnnotation +import kotlin.reflect.full.memberProperties + +object NotarizedResources { + + data class ResourceMetadata( + override var tags: Set = emptySet(), + override var parameters: List = emptyList(), + override var get: GetInfo? = null, + override var post: PostInfo? = null, + override var put: PutInfo? = null, + override var delete: DeleteInfo? = null, + override var patch: PatchInfo? = null, + override var head: HeadInfo? = null, + override var options: OptionsInfo? = null, + override var security: Map>? = null, + ) : SpecConfig + + class Config { + lateinit var resources: Map, ResourceMetadata> + } + + operator fun invoke() = createApplicationPlugin( + name = "NotarizedResources", + createConfiguration = NotarizedResources::Config + ) { + val spec = application.attributes[KompendiumAttributes.openApiSpec] + val serializableReader = application.attributes[KompendiumAttributes.schemaConfigurator] + pluginConfig.resources.forEach { (k, v) -> + val path = Path() + path.parameters = v.parameters + v.get?.addToSpec(path, spec, v, serializableReader) + v.delete?.addToSpec(path, spec, v, serializableReader) + v.head?.addToSpec(path, spec, v, serializableReader) + v.options?.addToSpec(path, spec, v, serializableReader) + v.post?.addToSpec(path, spec, v, serializableReader) + v.put?.addToSpec(path, spec, v, serializableReader) + v.patch?.addToSpec(path, spec, v, serializableReader) + + val resource = k.getResourcesFromClass() + spec.paths[resource] = path + } + } + + private fun KClass<*>.getResourcesFromClass(): String { + // todo if parent + + val resource = findAnnotation() + ?: error("Cannot notarize a resource without annotating with @Resource") + + val path = resource.path + val parent = memberProperties.map { it.returnType.classifier as KClass<*> }.find { it.hasAnnotation() } + + return if (parent == null) { + path + } else { + parent.getResourcesFromClass() + path + } + } +} diff --git a/resources/src/test/kotlin/io/bkbn/kompendium/resources/KompendiumResourcesTest.kt b/resources/src/test/kotlin/io/bkbn/kompendium/resources/KompendiumResourcesTest.kt new file mode 100644 index 000000000..ec0370635 --- /dev/null +++ b/resources/src/test/kotlin/io/bkbn/kompendium/resources/KompendiumResourcesTest.kt @@ -0,0 +1,120 @@ +package io.bkbn.kompendium.resources + +import Listing +import Type +import io.bkbn.kompendium.core.fixtures.TestHelpers.openApiTestAllSerializers +import io.bkbn.kompendium.core.fixtures.TestResponse +import io.bkbn.kompendium.core.metadata.GetInfo +import io.bkbn.kompendium.json.schema.definition.TypeDefinition +import io.bkbn.kompendium.oas.payload.Parameter +import io.kotest.core.spec.style.DescribeSpec +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.call +import io.ktor.server.application.install +import io.ktor.server.resources.Resources +import io.ktor.server.resources.get +import io.ktor.server.response.respondText + +class KompendiumResourcesTest : DescribeSpec({ + describe("Resource Tests") { + it("Can notarize a simple resource") { + openApiTestAllSerializers( + snapshotName = "T0001__simple_resource.json", + applicationSetup = { + install(Resources) + install(NotarizedResources()) { + resources = mapOf( + Listing::class to NotarizedResources.ResourceMetadata( + parameters = listOf( + Parameter( + name = "name", + `in` = Parameter.Location.path, + schema = TypeDefinition.STRING + ), + Parameter( + name = "page", + `in` = Parameter.Location.path, + schema = TypeDefinition.INT + ) + ), + get = GetInfo.builder { + summary("Resource") + description("example resource") + response { + responseCode(HttpStatusCode.OK) + responseType() + description("does great things") + } + } + ), + ) + } + } + ) { + get { listing -> + call.respondText("Listing ${listing.name}, page ${listing.page}") + } + } + } + it("Can notarize nested resources") { + openApiTestAllSerializers( + snapshotName = "T0002__nested_resources.json", + applicationSetup = { + install(Resources) + install(NotarizedResources()) { + resources = mapOf( + Type.Edit::class to NotarizedResources.ResourceMetadata( + parameters = listOf( + Parameter( + name = "name", + `in` = Parameter.Location.path, + schema = TypeDefinition.STRING + ) + ), + get = GetInfo.builder { + summary("Edit") + description("example resource") + response { + responseCode(HttpStatusCode.OK) + responseType() + description("does great things") + } + } + ), + Type.Other::class to NotarizedResources.ResourceMetadata( + parameters = listOf( + Parameter( + name = "name", + `in` = Parameter.Location.path, + schema = TypeDefinition.STRING + ), + Parameter( + name = "page", + `in` = Parameter.Location.path, + schema = TypeDefinition.INT + ) + ), + get = GetInfo.builder { + summary("Other") + description("example resource") + response { + responseCode(HttpStatusCode.OK) + responseType() + description("does great things") + } + } + ), + ) + } + } + ) { + get { edit -> + call.respondText("Listing ${edit.parent.name}") + } + get { other -> + call.respondText("Listing ${other.parent.name}, page ${other.page}") + } + } + } + } +}) diff --git a/resources/src/test/kotlin/io/bkbn/kompendium/resources/util/TestModels.kt b/resources/src/test/kotlin/io/bkbn/kompendium/resources/util/TestModels.kt new file mode 100644 index 000000000..b625adae3 --- /dev/null +++ b/resources/src/test/kotlin/io/bkbn/kompendium/resources/util/TestModels.kt @@ -0,0 +1,17 @@ +import io.ktor.resources.Resource +import kotlinx.serialization.Serializable + +@Serializable +@Resource("/list/{name}/page/{page}") +data class Listing(val name: String, val page: Int) + +@Serializable +@Resource("/type/{name}") +data class Type(val name: String) { + @Serializable + @Resource("/edit") + data class Edit(val parent: Type) + @Serializable + @Resource("/other/{page}") + data class Other(val parent: Type, val page: Int) +} diff --git a/resources/src/test/resources/T0001__simple_resource.json b/resources/src/test/resources/T0001__simple_resource.json new file mode 100644 index 000000000..97b24f4f8 --- /dev/null +++ b/resources/src/test/resources/T0001__simple_resource.json @@ -0,0 +1,92 @@ +{ + "openapi": "3.1.0", + "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", + "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": { + "/list/{name}/page/{page}": { + "get": { + "tags": [], + "summary": "Resource", + "description": "example resource", + "parameters": [], + "responses": { + "200": { + "description": "does great things", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestResponse" + } + } + } + } + }, + "deprecated": false + }, + "parameters": [ + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true, + "deprecated": false + }, + { + "name": "page", + "in": "path", + "schema": { + "type": "number", + "format": "int32" + }, + "required": true, + "deprecated": false + } + ] + } + }, + "webhooks": {}, + "components": { + "schemas": { + "TestResponse": { + "type": "object", + "properties": { + "c": { + "type": "string" + } + }, + "required": [ + "c" + ] + } + }, + "securitySchemes": {} + }, + "security": [], + "tags": [] +} diff --git a/resources/src/test/resources/T0002__nested_resources.json b/resources/src/test/resources/T0002__nested_resources.json new file mode 100644 index 000000000..59b7fdfe8 --- /dev/null +++ b/resources/src/test/resources/T0002__nested_resources.json @@ -0,0 +1,124 @@ +{ + "openapi": "3.1.0", + "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", + "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": { + "/type/{name}/edit": { + "get": { + "tags": [], + "summary": "Edit", + "description": "example resource", + "parameters": [], + "responses": { + "200": { + "description": "does great things", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestResponse" + } + } + } + } + }, + "deprecated": false + }, + "parameters": [ + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true, + "deprecated": false + } + ] + }, + "/type/{name}/other/{page}": { + "get": { + "tags": [], + "summary": "Other", + "description": "example resource", + "parameters": [], + "responses": { + "200": { + "description": "does great things", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestResponse" + } + } + } + } + }, + "deprecated": false + }, + "parameters": [ + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true, + "deprecated": false + }, + { + "name": "page", + "in": "path", + "schema": { + "type": "number", + "format": "int32" + }, + "required": true, + "deprecated": false + } + ] + } + }, + "webhooks": {}, + "components": { + "schemas": { + "TestResponse": { + "type": "object", + "properties": { + "c": { + "type": "string" + } + }, + "required": [ + "c" + ] + } + }, + "securitySchemes": {} + }, + "security": [], + "tags": [] +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 8affb6a13..a8f66941f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,6 +5,7 @@ include("oas") include("playground") include("locations") include("json-schema") +include("resources") run { rootProject.children.forEach { it.name = "${rootProject.name}-${it.name}" }