feat: NotarizedResource plugin

This commit is contained in:
Geir Sagberg
2022-11-09 14:11:42 +01:00
committed by GitHub
parent 01b6b59cf5
commit 3c57c8f5e4
8 changed files with 350 additions and 44 deletions

View File

@ -4,6 +4,8 @@
### Added ### Added
- Add support for NotarizedResource plugin scoped to route
### Changed ### Changed
- Support registering same path with different authentication and methods - Support registering same path with different authentication and methods

View File

@ -10,15 +10,16 @@ import io.bkbn.kompendium.core.metadata.PostInfo
import io.bkbn.kompendium.core.metadata.PutInfo import io.bkbn.kompendium.core.metadata.PutInfo
import io.bkbn.kompendium.core.util.Helpers.addToSpec import io.bkbn.kompendium.core.util.Helpers.addToSpec
import io.bkbn.kompendium.core.util.SpecConfig 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.path.Path
import io.bkbn.kompendium.oas.payload.Parameter import io.bkbn.kompendium.oas.payload.Parameter
import io.ktor.server.application.ApplicationCallPipeline import io.ktor.server.application.ApplicationCallPipeline
import io.ktor.server.application.Hook import io.ktor.server.application.Hook
import io.ktor.server.application.PluginBuilder
import io.ktor.server.application.createRouteScopedPlugin import io.ktor.server.application.createRouteScopedPlugin
import io.ktor.server.routing.Route import io.ktor.server.routing.Route
object NotarizedRoute { object NotarizedRoute {
class Config : SpecConfig { class Config : SpecConfig {
override var tags: Set<String> = emptySet() override var tags: Set<String> = emptySet()
override var parameters: List<Parameter> = emptyList() override var parameters: List<Parameter> = emptyList()
@ -50,25 +51,33 @@ object NotarizedRoute {
val routePath = route.calculateRoutePath() val routePath = route.calculateRoutePath()
val authMethods = route.collectAuthMethods() 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 path.parameters = path.parameters?.plus(pluginConfig.parameters) ?: pluginConfig.parameters
val serializableReader = application.attributes[KompendiumAttributes.schemaConfigurator] val serializableReader = application.attributes[KompendiumAttributes.schemaConfigurator]
pluginConfig.get?.addToSpec(path, spec, pluginConfig, serializableReader, routePath, authMethods) pluginConfig.get?.addToSpec(path, spec, pluginConfig, serializableReader, fullPath, authMethods)
pluginConfig.delete?.addToSpec(path, spec, pluginConfig, serializableReader, routePath, authMethods) pluginConfig.delete?.addToSpec(path, spec, pluginConfig, serializableReader, fullPath, authMethods)
pluginConfig.head?.addToSpec(path, spec, pluginConfig, serializableReader, routePath, authMethods) pluginConfig.head?.addToSpec(path, spec, pluginConfig, serializableReader, fullPath, authMethods)
pluginConfig.options?.addToSpec(path, spec, pluginConfig, serializableReader, routePath, authMethods) pluginConfig.options?.addToSpec(path, spec, pluginConfig, serializableReader, fullPath, authMethods)
pluginConfig.post?.addToSpec(path, spec, pluginConfig, serializableReader, routePath, authMethods) pluginConfig.post?.addToSpec(path, spec, pluginConfig, serializableReader, fullPath, authMethods)
pluginConfig.put?.addToSpec(path, spec, pluginConfig, serializableReader, routePath, authMethods) pluginConfig.put?.addToSpec(path, spec, pluginConfig, serializableReader, fullPath, authMethods)
pluginConfig.patch?.addToSpec(path, spec, pluginConfig, serializableReader, routePath, 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("/\\(.+\\)"), "") fun Route.calculateRoutePath() = toString().replace(Regex("/\\(.+\\)"), "")
private fun Route.collectAuthMethods() = toString() fun Route.collectAuthMethods() = toString()
.split("/") .split("/")
.filter { it.contains(Regex("\\(authenticate .*\\)")) } .filter { it.contains(Regex("\\(authenticate .*\\)")) }
.map { it.replace("(authenticate ", "").replace(")", "") } .map { it.replace("(authenticate ", "").replace(")", "") }

View File

@ -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` 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 ## Adding the Artifact
Prior to documenting your resources, you will need to add the artifact to your gradle build file. 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 ```kotlin
private fun Application.mainModule() { 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 {% hint style="danger" %}
> exception! 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 %}

View File

@ -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
}
}

View File

@ -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)
}
}
}

View File

@ -12,12 +12,8 @@ import io.bkbn.kompendium.core.util.Helpers.addToSpec
import io.bkbn.kompendium.core.util.SpecConfig import io.bkbn.kompendium.core.util.SpecConfig
import io.bkbn.kompendium.oas.path.Path import io.bkbn.kompendium.oas.path.Path
import io.bkbn.kompendium.oas.payload.Parameter import io.bkbn.kompendium.oas.payload.Parameter
import io.ktor.resources.Resource
import io.ktor.server.application.createApplicationPlugin import io.ktor.server.application.createApplicationPlugin
import kotlin.reflect.KClass import kotlin.reflect.KClass
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.hasAnnotation
import kotlin.reflect.full.memberProperties
object NotarizedResources { object NotarizedResources {
@ -45,7 +41,7 @@ object NotarizedResources {
val spec = application.attributes[KompendiumAttributes.openApiSpec] val spec = application.attributes[KompendiumAttributes.openApiSpec]
val serializableReader = application.attributes[KompendiumAttributes.schemaConfigurator] val serializableReader = application.attributes[KompendiumAttributes.schemaConfigurator]
pluginConfig.resources.forEach { (k, v) -> pluginConfig.resources.forEach { (k, v) ->
val resource = k.getResourcesFromClass() val resource = k.getResourcePathFromClass()
val path = spec.paths[resource] ?: Path() val path = spec.paths[resource] ?: Path()
path.parameters = path.parameters?.plus(v.parameters) ?: v.parameters path.parameters = path.parameters?.plus(v.parameters) ?: v.parameters
v.get?.addToSpec(path, spec, v, serializableReader, resource) v.get?.addToSpec(path, spec, v, serializableReader, resource)
@ -59,20 +55,4 @@ object NotarizedResources {
spec.paths[resource] = path 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
}
}
} }

View File

@ -14,9 +14,11 @@ import io.ktor.server.application.install
import io.ktor.server.resources.Resources import io.ktor.server.resources.Resources
import io.ktor.server.resources.get import io.ktor.server.resources.get
import io.ktor.server.response.respondText import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
import io.ktor.server.routing.route
class KompendiumResourcesTest : DescribeSpec({ class KompendiumResourcesTest : DescribeSpec({
describe("Resource Tests") { describe("NotarizedResources Tests") {
it("Can notarize a simple resource") { it("Can notarize a simple resource") {
openApiTestAllSerializers( openApiTestAllSerializers(
snapshotName = "T0001__simple_resource.json", 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")
}
}
}
}

View 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": []
}