From 9ffb515fccffee2ca0cd426e61a28cb61d218b67 Mon Sep 17 00:00:00 2001 From: ezienecker Date: Sun, 16 Mar 2025 10:24:05 +0100 Subject: [PATCH 1/2] ISSUE-704 Add support for http methode based NotarizedResources --- .../ResourcesMethodTypedPlayground.kt | 284 ++++++++++++++++++ ...Resource.kt => NotarizedMethodResource.kt} | 13 +- .../resources/KompendiumResourcesTest.kt | 38 +++ .../resources/T0004__simple_resource_v2.json | 92 ++++++ 4 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 playground/src/main/kotlin/io/bkbn/kompendium/playground/ResourcesMethodTypedPlayground.kt rename resources/src/main/kotlin/io/bkbn/kompendium/resources/{NotarizedResource.kt => NotarizedMethodResource.kt} (70%) create mode 100644 resources/src/test/resources/T0004__simple_resource_v2.json diff --git a/playground/src/main/kotlin/io/bkbn/kompendium/playground/ResourcesMethodTypedPlayground.kt b/playground/src/main/kotlin/io/bkbn/kompendium/playground/ResourcesMethodTypedPlayground.kt new file mode 100644 index 0000000000..3d7610c729 --- /dev/null +++ b/playground/src/main/kotlin/io/bkbn/kompendium/playground/ResourcesMethodTypedPlayground.kt @@ -0,0 +1,284 @@ +package io.bkbn.kompendium.playground + +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.PatchInfo +import io.bkbn.kompendium.core.metadata.PostInfo +import io.bkbn.kompendium.core.metadata.PutInfo +import io.bkbn.kompendium.core.plugin.NotarizedApplication +import io.bkbn.kompendium.core.routes.redoc +import io.bkbn.kompendium.core.routes.swagger +import io.bkbn.kompendium.oas.serialization.KompendiumSerializersModule +import io.bkbn.kompendium.playground.util.Util.baseSpec +import io.bkbn.kompendium.resources.NotarizedDeleteResource +import io.bkbn.kompendium.resources.NotarizedGetResource +import io.bkbn.kompendium.resources.NotarizedHeadResource +import io.bkbn.kompendium.resources.NotarizedPatchResource +import io.bkbn.kompendium.resources.NotarizedPostResource +import io.bkbn.kompendium.resources.NotarizedPutResource +import io.bkbn.kompendium.resources.NotarizedResource +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.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.delete +import io.ktor.server.resources.get +import io.ktor.server.resources.head +import io.ktor.server.resources.patch +import io.ktor.server.resources.post +import io.ktor.server.resources.put +import io.ktor.server.response.respondText +import io.ktor.server.routing.Route +import io.ktor.server.routing.routing +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 } + } + routing { + swagger(pageTitle = "Simple API Docs") + redoc(pageTitle = "Simple API Docs") + + listUserRoute() + createUserRoute() + userMetadataRoute() + + getUserRoute() + updateUserRoute() + updateUserPartialRoute() + deleteUserRoute() + getUserMetadataRoute() + + getUserAssignedRolesRoute() + } +} + +@Resource("/users") +class Users { + @Resource("{id}") + data class Id(val parent: Users = Users(), val id: Long) { + @Resource("/roles") + data class Roles(val parent: Id) + } +} + +fun Route.listUserRoute() { + listUserDocumentation() + + get { + call.respondText("List user") + } +} + +private fun Route.listUserDocumentation() { + install(NotarizedGetResource()) { + get = GetInfo.builder { + summary("List users") + description("List all users") + response { + responseCode(HttpStatusCode.OK) + responseType() + description("List of users") + } + } + } +} + +fun Route.createUserRoute() { + createUserDocumentation() + + post { + call.respondText("Successfully created user", status = HttpStatusCode.Created) + } +} + +private fun Route.createUserDocumentation() { + install(NotarizedPostResource()) { + post = PostInfo.builder { + summary("Create user") + description("Create a new user") + response { + responseCode(HttpStatusCode.Created) + responseType() + description("User created") + } + } + } +} + +fun Route.userMetadataRoute() { + userMetadataDocumentation() + + head { + call.respondText("List users metadata") + } +} + +private fun Route.userMetadataDocumentation() { + install(NotarizedHeadResource()) { + head = HeadInfo.builder { + summary("List users metadata") + description("List metadata via headers") + response { + responseCode(HttpStatusCode.OK) + responseType() + description("List of users metadata") + } + } + } +} + +fun Route.getUserRoute() { + getUserDocumentation() + + get { + call.respondText("Get user") + } +} + +private fun Route.getUserDocumentation() { + install(NotarizedGetResource()) { + get = GetInfo.builder { + summary("Get user") + description("Get a user by ID") + response { + responseCode(HttpStatusCode.OK) + responseType() + description("User found") + } + } + } +} + +fun Route.updateUserRoute() { + updateUserDocumentation() + + put { + call.respondText("User updated") + } +} + +private fun Route.updateUserDocumentation() { + install(NotarizedPutResource()) { + put = PutInfo.builder { + summary("Update user") + description("Update a user by ID") + response { + responseCode(HttpStatusCode.OK) + responseType() + description("User updated") + } + } + } +} + +fun Route.updateUserPartialRoute() { + updateUserPartialDocumentation() + + patch { + call.respondText("User fields updated", status = HttpStatusCode.NoContent) + } +} + +private fun Route.updateUserPartialDocumentation() { + install(NotarizedPatchResource()) { + patch = PatchInfo.builder { + summary("Update specific user fields") + description("Update specific fields of a user by ID") + response { + responseCode(HttpStatusCode.NoContent) + responseType() + description("User fields updated") + } + } + } +} + +fun Route.deleteUserRoute() { + deleteUserDocumentation() + + delete { + call.respondText("User deleted", status = HttpStatusCode.NoContent) + } +} + +private fun Route.deleteUserDocumentation() { + install(NotarizedDeleteResource()) { + delete = DeleteInfo.builder { + summary("Delete user") + description("Delete a user by ID") + response { + responseCode(HttpStatusCode.NoContent) + responseType() + description("User deleted") + } + } + } +} + +fun Route.getUserMetadataRoute() { + getUserMetadataDocumentation() + + head { + call.respondText("Get user metadata") + } +} + +private fun Route.getUserMetadataDocumentation() { + install(NotarizedHeadResource()) { + head = HeadInfo.builder { + summary("Get user metadata") + description("Get metadata for a user by ID") + response { + responseCode(HttpStatusCode.OK) + responseType() + description("User metadata found") + } + } + } +} + +fun Route.getUserAssignedRolesRoute() { + getUserAssignedRolesDocumentation() + + get { + call.respondText("Get user assigned roles") + } +} + +private fun Route.getUserAssignedRolesDocumentation() { + install(NotarizedResource()) { + get = GetInfo.builder { + summary("Get user assigned roles") + description("Get roles assigned to a user by ID") + response { + responseCode(HttpStatusCode.OK) + responseType() + description("User assigned roles") + } + } + } +} diff --git a/resources/src/main/kotlin/io/bkbn/kompendium/resources/NotarizedResource.kt b/resources/src/main/kotlin/io/bkbn/kompendium/resources/NotarizedMethodResource.kt similarity index 70% rename from resources/src/main/kotlin/io/bkbn/kompendium/resources/NotarizedResource.kt rename to resources/src/main/kotlin/io/bkbn/kompendium/resources/NotarizedMethodResource.kt index 2f4aa5596b..b273dc2765 100644 --- a/resources/src/main/kotlin/io/bkbn/kompendium/resources/NotarizedResource.kt +++ b/resources/src/main/kotlin/io/bkbn/kompendium/resources/NotarizedMethodResource.kt @@ -10,7 +10,16 @@ import io.ktor.server.application.Hook import io.ktor.server.application.createRouteScopedPlugin import io.ktor.server.routing.Route -object NotarizedResource { +object NotarizedResource : NotarizedMethodResource() +object NotarizedGetResource : NotarizedMethodResource() +object NotarizedPostResource : NotarizedMethodResource() +object NotarizedPutResource : NotarizedMethodResource() +object NotarizedDeleteResource : NotarizedMethodResource() +object NotarizedHeadResource : NotarizedMethodResource() +object NotarizedPatchResource : NotarizedMethodResource() +object NotarizedOptionsResource : NotarizedMethodResource() + +abstract class NotarizedMethodResource { object InstallHook : Hook<(ApplicationCallPipeline) -> Unit> { override fun install(pipeline: ApplicationCallPipeline, handler: (ApplicationCallPipeline) -> Unit) { handler(pipeline) @@ -18,7 +27,7 @@ object NotarizedResource { } inline operator fun invoke() = createRouteScopedPlugin( - name = "NotarizedResource<${T::class.qualifiedName}>", + name = "$this<${T::class.qualifiedName}>", createConfiguration = NotarizedRoute::Config ) { on(InstallHook) { 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 aa7018cc27..fa47e5f4cd 100644 --- a/resources/src/test/kotlin/io/bkbn/kompendium/resources/KompendiumResourcesTest.kt +++ b/resources/src/test/kotlin/io/bkbn/kompendium/resources/KompendiumResourcesTest.kt @@ -139,6 +139,44 @@ class KompendiumResourcesTest : DescribeSpec({ } } } + describe("NotarizedGetResource Tests") { + it("Can notarize a simple resource") { + openApiTestAllSerializers( + // TODO: This can be replaced with T0001__simple_resource.json once https://github.com/bkbnio/kompendium/pull/664 is merged + snapshotName = "T0004__simple_resource_v2.json", + applicationSetup = { + install(Resources) + } + ) { + 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("Resource") + description("example resource") + response { + responseCode(HttpStatusCode.OK) + responseType() + description("does great things") + } + } + } + get { listing -> + call.respondText("Listing ${listing.name}, page ${listing.page}") + } + } + } + } }) private fun Route.typeOtherDocumentation() { diff --git a/resources/src/test/resources/T0004__simple_resource_v2.json b/resources/src/test/resources/T0004__simple_resource_v2.json new file mode 100644 index 0000000000..ff405923fd --- /dev/null +++ b/resources/src/test/resources/T0004__simple_resource_v2.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": [] +} From c77102d5e4cc715a58a8bd0a247088ccafd93fcd Mon Sep 17 00:00:00 2001 From: ezienecker Date: Sun, 16 Mar 2025 10:46:41 +0100 Subject: [PATCH 2/2] ISSUE-704 Add changelog entry and documentation --- CHANGELOG.md | 4 ++ docs/playground.md | 21 +++--- docs/plugins/notarized_resources.md | 68 +++++++++++++++++++ .../ResourcesMethodTypedPlayground.kt | 26 +++++++ 4 files changed, 109 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7be87299dc..49f9b1cbd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +- Add support for method type NotarizedResource (#704) + ## [4.0.3] - 2024-11-11 ### 🐛 Bug Fixes diff --git a/docs/playground.md b/docs/playground.md index c970fffd25..95f6f5c96e 100644 --- a/docs/playground.md +++ b/docs/playground.md @@ -3,16 +3,17 @@ Kompendium features. At the moment, the following playground applications are -| Example | Description | -|--------------|------------------------------------------------------------| -| Basic | A minimally viable Kompendium application | -| Auth | Documenting authenticated routes | -| Custom Types | Documenting custom scalars to be used by Kompendium | -| Exceptions | Documenting exception responses | -| Gson | Serialization using Gson instead of the default Kotlinx | -| Hidden Docs | Place your generated documentation behind authorization | -| Jackson | Serialization using Jackson instead of the default KotlinX | -| Resources | Using the Ktor Resources API to define routes | +| Example | Description | +|--------------|-------------------------------------------------------------------------------------------------------------| +| Basic | A minimally viable Kompendium application | +| Auth | Documenting authenticated routes | +| Custom Types | Documenting custom scalars to be used by Kompendium | +| Exceptions | Documenting exception responses | +| Gson | Serialization using Gson instead of the default Kotlinx | +| Hidden Docs | Place your generated documentation behind authorization | +| Jackson | Serialization using Jackson instead of the default KotlinX | +| Resources | Using the Ktor Resources API to define routes | +| Resources v2 | Using the Ktor Resources API to define routes and use method type NotarizedResources to document the 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 index cff17e4a96..c0efe2760b 100644 --- a/docs/plugins/notarized_resources.md +++ b/docs/plugins/notarized_resources.md @@ -123,3 +123,71 @@ 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 %} + +## NotarizedResource (method typed) + +If you prefer a more fine granular route-based approach similar to NotarizedResource(), you can use one of the following method typed NotarizedResources: +- `NotarizedGetResource` +- `NotarizedPostResource` +- `NotarizedPutResource` +- `NotarizedDeleteResource` +- `NotarizedHeadResource` +- `NotarizedPatchResource` +- `NotarizedOptionsResource` + +```kotlin +@Resource("/users") +class Users + +private fun Application.mainModule() { + install(Resources) + route("/api") { + listUserRoute() + createUserRoute() + } +} + +fun Route.listUserRoute() { + listUserDocumentation() + + get { + call.respondText("List user") + } +} + +private fun Route.listUserDocumentation() { + install(NotarizedGetResource()) { + get = GetInfo.builder { + summary("List users") + description("List all users") + response { + responseCode(HttpStatusCode.OK) + responseType() + description("List of users") + } + } + } +} + +fun Route.createUserRoute() { + createUserDocumentation() + + post { + call.respondText("Successfully created user", status = HttpStatusCode.Created) + } +} + +private fun Route.createUserDocumentation() { + install(NotarizedPostResource()) { + post = PostInfo.builder { + summary("Create user") + description("Create a new user") + response { + responseCode(HttpStatusCode.Created) + responseType() + description("User created") + } + } + } +} +``` diff --git a/playground/src/main/kotlin/io/bkbn/kompendium/playground/ResourcesMethodTypedPlayground.kt b/playground/src/main/kotlin/io/bkbn/kompendium/playground/ResourcesMethodTypedPlayground.kt index 3d7610c729..840ee79a07 100644 --- a/playground/src/main/kotlin/io/bkbn/kompendium/playground/ResourcesMethodTypedPlayground.kt +++ b/playground/src/main/kotlin/io/bkbn/kompendium/playground/ResourcesMethodTypedPlayground.kt @@ -3,6 +3,7 @@ package io.bkbn.kompendium.playground 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 @@ -14,6 +15,7 @@ import io.bkbn.kompendium.playground.util.Util.baseSpec import io.bkbn.kompendium.resources.NotarizedDeleteResource import io.bkbn.kompendium.resources.NotarizedGetResource import io.bkbn.kompendium.resources.NotarizedHeadResource +import io.bkbn.kompendium.resources.NotarizedOptionsResource import io.bkbn.kompendium.resources.NotarizedPatchResource import io.bkbn.kompendium.resources.NotarizedPostResource import io.bkbn.kompendium.resources.NotarizedPutResource @@ -30,6 +32,7 @@ import io.ktor.server.resources.Resources import io.ktor.server.resources.delete import io.ktor.server.resources.get import io.ktor.server.resources.head +import io.ktor.server.resources.options import io.ktor.server.resources.patch import io.ktor.server.resources.post import io.ktor.server.resources.put @@ -73,6 +76,7 @@ private fun Application.mainModule() { getUserMetadataRoute() getUserAssignedRolesRoute() + getUserAssignedRolesOptionsRoute() } } @@ -282,3 +286,25 @@ private fun Route.getUserAssignedRolesDocumentation() { } } } + +fun Route.getUserAssignedRolesOptionsRoute() { + getUserAssignedRolesOptionsDocumentation() + + options { + call.respondText("Get user assigned roles") + } +} + +private fun Route.getUserAssignedRolesOptionsDocumentation() { + install(NotarizedOptionsResource()) { + options = OptionsInfo.builder { + summary("Get options for this endpoint") + description("Get options for this endpoint") + response { + responseCode(HttpStatusCode.OK) + responseType() + description("Test the allowed HTTP methods for this endpoint") + } + } + } +}