feat: NotarizedResource plugin
This commit is contained in:
@ -4,6 +4,8 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Add support for NotarizedResource plugin scoped to route
|
||||
|
||||
### Changed
|
||||
|
||||
- Support registering same path with different authentication and methods
|
||||
|
@ -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]
|
||||
|
||||
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(")", "") }
|
||||
|
@ -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<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.
|
||||
|
||||
{% 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 %}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
124
resources/src/test/resources/T0003__resources_in_route.json
Normal file
124
resources/src/test/resources/T0003__resources_in_route.json
Normal file
@ -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": []
|
||||
}
|
Reference in New Issue
Block a user