From 3c57c8f5e4188d741fdcb280eed6162dc940ac97 Mon Sep 17 00:00:00 2001 From: Geir Sagberg Date: Wed, 9 Nov 2022 14:11:42 +0100 Subject: [PATCH] feat: NotarizedResource plugin --- CHANGELOG.md | 2 + .../kompendium/core/plugin/NotarizedRoute.kt | 43 +++--- docs/plugins/notarized_resources.md | 75 ++++++++++- .../io/bkbn/kompendium/resources/Helpers.kt | 21 +++ .../kompendium/resources/NotarizedResource.kt | 35 +++++ .../resources/NotarizedResources.kt | 22 +--- .../resources/KompendiumResourcesTest.kt | 72 +++++++++- .../resources/T0003__resources_in_route.json | 124 ++++++++++++++++++ 8 files changed, 350 insertions(+), 44 deletions(-) create mode 100644 resources/src/main/kotlin/io/bkbn/kompendium/resources/Helpers.kt create mode 100644 resources/src/main/kotlin/io/bkbn/kompendium/resources/NotarizedResource.kt create mode 100644 resources/src/test/resources/T0003__resources_in_route.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 75d5485b1..dcb912da5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- Add support for NotarizedResource plugin scoped to route + ### Changed - Support registering same path with different authentication and methods diff --git a/core/src/main/kotlin/io/bkbn/kompendium/core/plugin/NotarizedRoute.kt b/core/src/main/kotlin/io/bkbn/kompendium/core/plugin/NotarizedRoute.kt index 67ae52ee2..7236289b5 100644 --- a/core/src/main/kotlin/io/bkbn/kompendium/core/plugin/NotarizedRoute.kt +++ b/core/src/main/kotlin/io/bkbn/kompendium/core/plugin/NotarizedRoute.kt @@ -10,15 +10,16 @@ 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.OpenApiSpec import io.bkbn.kompendium.oas.path.Path import io.bkbn.kompendium.oas.payload.Parameter import io.ktor.server.application.ApplicationCallPipeline import io.ktor.server.application.Hook +import io.ktor.server.application.PluginBuilder import io.ktor.server.application.createRouteScopedPlugin import io.ktor.server.routing.Route object NotarizedRoute { - class Config : SpecConfig { override var tags: Set = emptySet() override var parameters: List = emptyList() @@ -50,25 +51,33 @@ object NotarizedRoute { val routePath = route.calculateRoutePath() val authMethods = route.collectAuthMethods() - val path = spec.paths[routePath] ?: Path() - - path.parameters = path.parameters?.plus(pluginConfig.parameters) ?: pluginConfig.parameters - val serializableReader = application.attributes[KompendiumAttributes.schemaConfigurator] - - pluginConfig.get?.addToSpec(path, spec, pluginConfig, serializableReader, routePath, authMethods) - pluginConfig.delete?.addToSpec(path, spec, pluginConfig, serializableReader, routePath, authMethods) - pluginConfig.head?.addToSpec(path, spec, pluginConfig, serializableReader, routePath, authMethods) - pluginConfig.options?.addToSpec(path, spec, pluginConfig, serializableReader, routePath, authMethods) - pluginConfig.post?.addToSpec(path, spec, pluginConfig, serializableReader, routePath, authMethods) - pluginConfig.put?.addToSpec(path, spec, pluginConfig, serializableReader, routePath, authMethods) - pluginConfig.patch?.addToSpec(path, spec, pluginConfig, serializableReader, routePath, authMethods) - - spec.paths[routePath] = path + addToSpec(spec, routePath, authMethods) } } - private fun Route.calculateRoutePath() = toString().replace(Regex("/\\(.+\\)"), "") - private fun Route.collectAuthMethods() = toString() + fun PluginBuilder.addToSpec( + spec: OpenApiSpec, + fullPath: String, + authMethods: List + ) { + val path = spec.paths[fullPath] ?: Path() + + path.parameters = path.parameters?.plus(pluginConfig.parameters) ?: pluginConfig.parameters + val serializableReader = application.attributes[KompendiumAttributes.schemaConfigurator] + + pluginConfig.get?.addToSpec(path, spec, pluginConfig, serializableReader, fullPath, authMethods) + pluginConfig.delete?.addToSpec(path, spec, pluginConfig, serializableReader, fullPath, authMethods) + pluginConfig.head?.addToSpec(path, spec, pluginConfig, serializableReader, fullPath, authMethods) + pluginConfig.options?.addToSpec(path, spec, pluginConfig, serializableReader, fullPath, authMethods) + pluginConfig.post?.addToSpec(path, spec, pluginConfig, serializableReader, fullPath, authMethods) + pluginConfig.put?.addToSpec(path, spec, pluginConfig, serializableReader, fullPath, authMethods) + pluginConfig.patch?.addToSpec(path, spec, pluginConfig, serializableReader, fullPath, authMethods) + + spec.paths[fullPath] = path + } + + fun Route.calculateRoutePath() = toString().replace(Regex("/\\(.+\\)"), "") + fun Route.collectAuthMethods() = toString() .split("/") .filter { it.contains(Regex("\\(authenticate .*\\)")) } .map { it.replace("(authenticate ", "").replace(")", "") } diff --git a/docs/plugins/notarized_resources.md b/docs/plugins/notarized_resources.md index a8fb2a04d..cff17e4a9 100644 --- a/docs/plugins/notarized_resources.md +++ b/docs/plugins/notarized_resources.md @@ -4,6 +4,12 @@ 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` +{% hint style="warning" %} +The resources module contains _two_ plugins: `KompendiumResources` and `KompendiumResource`. You will find more +information on both below, but in a nutshell, the former is an application level plugin intended to define your entire +application, while the latter is a route level approach should you wish to split out your route definitions. +{% endhint %} + ## Adding the Artifact Prior to documenting your resources, you will need to add the artifact to your gradle build file. @@ -14,9 +20,11 @@ dependencies { } ``` -## Installing Plugin +## NotarizedResources -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. +The `NotarizedResources` plugin is an _application_ level plugin, and **must** be installed after both the +`NotarizedApplication` plugin and the Ktor `Resources` plugin. It is intended to be used to document your entire +application in a single block. ```kotlin private fun Application.mainModule() { @@ -54,7 +62,64 @@ private fun Application.mainModule() { } ``` -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. +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! +{% hint style="danger" %} +If you try to map a class that is not annotated with the ktor `@Resource` annotation, you will get a runtime exception! +{% endhint %} + +## NotarizedResource + +If you prefer a route-based approach similar to `NotarizedRoute`, you can use the `NotarizedResource()` +plugin instead of `NotarizedResources`. It will combine paths from any parent route with the route defined in the +resource, exactly as Ktor itself does: + +```kotlin +@Serializable +@Resource("/list/{name}/page/{page}") +data class Listing(val name: String, val page: Int) + +private fun Application.mainModule() { + install(Resources) + route("/api") { + listingDocumentation() + get { listing -> + call.respondText("Listing ${listing.name}, page ${listing.page}") + } + } +} + +private fun Route.listingDocumentation() { + install(NotarizedResource()) { + 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 😱") + } + } + } +} +``` + +In this case, the generated path will be `/api/list/{name}/page/{page}`, combining the route prefix with the path in the +resource. + +{% hint style="danger" %} +If you try to map a class that is not annotated with the ktor `@Resource` annotation, you will get a runtime exception! +{% endhint %} diff --git a/resources/src/main/kotlin/io/bkbn/kompendium/resources/Helpers.kt b/resources/src/main/kotlin/io/bkbn/kompendium/resources/Helpers.kt new file mode 100644 index 000000000..06773ed53 --- /dev/null +++ b/resources/src/main/kotlin/io/bkbn/kompendium/resources/Helpers.kt @@ -0,0 +1,21 @@ +package io.bkbn.kompendium.resources + +import io.ktor.resources.Resource +import kotlin.reflect.KClass +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.hasAnnotation +import kotlin.reflect.full.memberProperties + +fun KClass<*>.getResourcePathFromClass(): String { + 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.getResourcePathFromClass() + path + } +} diff --git a/resources/src/main/kotlin/io/bkbn/kompendium/resources/NotarizedResource.kt b/resources/src/main/kotlin/io/bkbn/kompendium/resources/NotarizedResource.kt new file mode 100644 index 000000000..2f4aa5596 --- /dev/null +++ b/resources/src/main/kotlin/io/bkbn/kompendium/resources/NotarizedResource.kt @@ -0,0 +1,35 @@ +package io.bkbn.kompendium.resources + +import io.bkbn.kompendium.core.attribute.KompendiumAttributes +import io.bkbn.kompendium.core.plugin.NotarizedRoute +import io.bkbn.kompendium.core.plugin.NotarizedRoute.addToSpec +import io.bkbn.kompendium.core.plugin.NotarizedRoute.calculateRoutePath +import io.bkbn.kompendium.core.plugin.NotarizedRoute.collectAuthMethods +import io.ktor.server.application.ApplicationCallPipeline +import io.ktor.server.application.Hook +import io.ktor.server.application.createRouteScopedPlugin +import io.ktor.server.routing.Route + +object NotarizedResource { + object InstallHook : Hook<(ApplicationCallPipeline) -> Unit> { + override fun install(pipeline: ApplicationCallPipeline, handler: (ApplicationCallPipeline) -> Unit) { + handler(pipeline) + } + } + + inline operator fun invoke() = createRouteScopedPlugin( + name = "NotarizedResource<${T::class.qualifiedName}>", + createConfiguration = NotarizedRoute::Config + ) { + on(InstallHook) { + val route = it as? Route ?: return@on + val spec = application.attributes[KompendiumAttributes.openApiSpec] + val routePath = route.calculateRoutePath() + val authMethods = route.collectAuthMethods() + val resourcePath = T::class.getResourcePathFromClass() + val fullPath = "$routePath$resourcePath" + + addToSpec(spec, fullPath, authMethods) + } + } +} diff --git a/resources/src/main/kotlin/io/bkbn/kompendium/resources/NotarizedResources.kt b/resources/src/main/kotlin/io/bkbn/kompendium/resources/NotarizedResources.kt index 47eadf8c5..26860c737 100644 --- a/resources/src/main/kotlin/io/bkbn/kompendium/resources/NotarizedResources.kt +++ b/resources/src/main/kotlin/io/bkbn/kompendium/resources/NotarizedResources.kt @@ -12,12 +12,8 @@ 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 { @@ -45,7 +41,7 @@ object NotarizedResources { val spec = application.attributes[KompendiumAttributes.openApiSpec] val serializableReader = application.attributes[KompendiumAttributes.schemaConfigurator] pluginConfig.resources.forEach { (k, v) -> - val resource = k.getResourcesFromClass() + val resource = k.getResourcePathFromClass() val path = spec.paths[resource] ?: Path() path.parameters = path.parameters?.plus(v.parameters) ?: v.parameters v.get?.addToSpec(path, spec, v, serializableReader, resource) @@ -59,20 +55,4 @@ object NotarizedResources { 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 index ec0370635..70be0d5ab 100644 --- a/resources/src/test/kotlin/io/bkbn/kompendium/resources/KompendiumResourcesTest.kt +++ b/resources/src/test/kotlin/io/bkbn/kompendium/resources/KompendiumResourcesTest.kt @@ -14,9 +14,11 @@ import io.ktor.server.application.install import io.ktor.server.resources.Resources import io.ktor.server.resources.get import io.ktor.server.response.respondText +import io.ktor.server.routing.Route +import io.ktor.server.routing.route class KompendiumResourcesTest : DescribeSpec({ - describe("Resource Tests") { + describe("NotarizedResources Tests") { it("Can notarize a simple resource") { openApiTestAllSerializers( snapshotName = "T0001__simple_resource.json", @@ -117,4 +119,72 @@ class KompendiumResourcesTest : DescribeSpec({ } } } + describe("NotarizedResource Tests") { + it("Can notarize resources in route") { + openApiTestAllSerializers( + snapshotName = "T0003__resources_in_route.json", + applicationSetup = { + install(Resources) + } + ) { + route("/api") { + typeEditDocumentation() + get { edit -> + call.respondText("Listing ${edit.parent.name}") + } + typeOtherDocumentation() + get { other -> + call.respondText("Listing ${other.parent.name}, page ${other.page}") + } + } + } + } + } }) + +private fun Route.typeOtherDocumentation() { + install(NotarizedResource()) { + 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") + } + } + } +} + +private fun Route.typeEditDocumentation() { + install(NotarizedResource()) { + 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") + } + } + } +} diff --git a/resources/src/test/resources/T0003__resources_in_route.json b/resources/src/test/resources/T0003__resources_in_route.json new file mode 100644 index 000000000..43398b012 --- /dev/null +++ b/resources/src/test/resources/T0003__resources_in_route.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": { + "/api/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 + } + ] + }, + "/api/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": [] +}