Repository: bkbnio/kompendium (kotlin)

Issue:
Support nested routes for Resources plugin
**Is your feature request related to a problem? Please describe.**
I am using the Ktor Resources plugin in a nested route, like this:

```kotlin
@Serializable
@Resource("/config")
object ConfigRequest

routing("/api") {
  get<ConfigRequest>() {
    ...
  }
}
```

When using `NotarizedResources()`, the generated path will show `/config`, without the `/api` prefix, while Ktor will instead serve the resource at `/api/config`.

**Describe the solution you'd like**
A way to notarize a map of resources in a given route

Pull request:
# Description

Adds a route-level plugin that is used similarly to NotarizedRoute.

Closes #370

## Type of change

Please delete options that are not relevant.

- [x] New feature (non-breaking change which adds functionality)
- [x] Documentation

# How Has This Been Tested?

Added unit test

# Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] I have updated the CHANGELOG in the `Unreleased` section
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream modules

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 75d5485b1e..dcb912da54 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 67ae52ee2e..7236289b59 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<String> = emptySet()
     override var parameters: List<Parameter> = emptyList()
@@ -50,25 +51,33 @@ object NotarizedRoute {
       val routePath = route.calculateRoutePath()
       val authMethods = route.collectAuthMethods()
 
-      val path = spec.paths[routePath] ?: Path()
+      addToSpec(spec, routePath, authMethods)
+    }
+  }
+
+  fun <T : SpecConfig> PluginBuilder<T>.addToSpec(
+    spec: OpenApiSpec,
+    fullPath: String,
+    authMethods: List<String>
+  ) {
+    val path = spec.paths[fullPath] ?: Path()
 
-      path.parameters = path.parameters?.plus(pluginConfig.parameters) ?: pluginConfig.parameters
-      val serializableReader = application.attributes[KompendiumAttributes.schemaConfigurator]
+    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)
+    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[routePath] = path
-    }
+    spec.paths[fullPath] = path
   }
 
-  private fun Route.calculateRoutePath() = toString().replace(Regex("/\\(.+\\)"), "")
-  private fun Route.collectAuthMethods() = toString()
+  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 a8fb2a04d4..cff17e4a96 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.
+
+{% 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<MyResourceType>()`
+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> { listing ->
+      call.respondText("Listing ${listing.name}, page ${listing.page}")
+    }
+  }
+}
+
+private fun Route.listingDocumentation() {
+  install(NotarizedResource<Listing>()) {
+    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<ExampleResponse>()
+        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.
 
-> ⚠️ 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 %}
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 0000000000..06773ed53f
--- /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<Resource>()
+    ?: 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<Resource>() }
+
+  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 0000000000..2f4aa5596b
--- /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 <reified T> 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 47eadf8c52..26860c7373 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<Resource>()
-      ?: 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<Resource>() }
-
-    return if (parent == null) {
-      path
-    } else {
-      parent.getResourcesFromClass() + path
-    }
-  }
 }


Test patch:
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 ec0370635b..70be0d5ab1 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<Type.Edit> { edit ->
+            call.respondText("Listing ${edit.parent.name}")
+          }
+          typeOtherDocumentation()
+          get<Type.Other> { other ->
+            call.respondText("Listing ${other.parent.name}, page ${other.page}")
+          }
+        }
+      }
+    }
+  }
 })
+
+private fun Route.typeOtherDocumentation() {
+  install(NotarizedResource<Type.Other>()) {
+    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<TestResponse>()
+        description("does great things")
+      }
+    }
+  }
+}
+
+private fun Route.typeEditDocumentation() {
+  install(NotarizedResource<Type.Edit>()) {
+    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<TestResponse>()
+        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 0000000000..43398b0123
--- /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": []
+}