Compare commits

...

11 Commits

Author SHA1 Message Date
a5376cfa82 feat: allow media type overrides (#369) 2022-11-05 16:45:06 -04:00
2492661f1f chore: prep for release 2022-11-05 16:11:38 -04:00
a7b52ec114 feat: create schema reference for enum types (#368) 2022-11-05 16:09:06 -04:00
8ebab04a83 fix(deps): update kotestversion to v5.5.4 (#363)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-11-04 06:00:41 +00:00
921f6f9691 fix(deps): update dependency dev.forst:ktor-api-key to v2.1.3 (#361)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-10-31 22:25:14 +00:00
558b9fea62 fix(deps): update ktor to v2.1.3 (#360)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-10-31 17:01:30 +00:00
752cb238d3 fix(deps): update dependency joda-time:joda-time to v2.12.1 (#359)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-10-30 06:12:11 +00:00
92d4760dbc chore: prep for 3.5.0 release 2022-10-29 08:16:03 -04:00
4946a27327 feat: add plugin to support ktor-server-resources (#358) 2022-10-29 08:14:32 -04:00
e4217843b7 fix(deps): update dependency io.ktor:ktor-server-content-negotiation to v2.1.3 (#357)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-10-28 14:38:54 +00:00
b8f2090a8a fix(deps): update kotestversion to v5.5.3 (#355)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-10-28 05:47:04 +00:00
41 changed files with 1064 additions and 123 deletions

View File

@ -12,6 +12,25 @@
## Released ## Released
## [3.7.0] - November 5th, 2022
### Added
- Allow users to override media type in request and response
## [3.6.0] - November 5th, 2022
### Changed
- Schemas for types in nullable properties are no longer nullable themselves
- Enums are now generated as references, which makes it possible to generate types for them
## [3.5.0] - October 29th, 2022
### Added
- New `kompendium-resources` plugin to support Ktor Resources API
## [3.4.0] - October 26th, 2022 ## [3.4.0] - October 26th, 2022
### Added ### Added

View File

@ -58,7 +58,7 @@ dependencies {
testFixturesApi("io.ktor:ktor-client:$ktorVersion") testFixturesApi("io.ktor:ktor-client:$ktorVersion")
testFixturesApi("io.ktor:ktor-client-cio:$ktorVersion") testFixturesApi("io.ktor:ktor-client-cio:$ktorVersion")
testFixturesApi("dev.forst:ktor-api-key:2.1.2") testFixturesApi("dev.forst:ktor-api-key:2.1.3")
testFixturesApi("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1") testFixturesApi("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1")
} }

View File

@ -7,7 +7,8 @@ import kotlin.reflect.typeOf
class RequestInfo private constructor( class RequestInfo private constructor(
val requestType: KType, val requestType: KType,
val description: String, val description: String,
val examples: Map<String, MediaType.Example>? val examples: Map<String, MediaType.Example>?,
val mediaTypes: Set<String>
) { ) {
companion object { companion object {
@ -22,6 +23,7 @@ class RequestInfo private constructor(
private var requestType: KType? = null private var requestType: KType? = null
private var description: String? = null private var description: String? = null
private var examples: Map<String, MediaType.Example>? = null private var examples: Map<String, MediaType.Example>? = null
private var mediaTypes: Set<String>? = null
fun requestType(t: KType) = apply { fun requestType(t: KType) = apply {
this.requestType = t this.requestType = t
@ -35,10 +37,15 @@ class RequestInfo private constructor(
this.examples = e.toMap().mapValues { (_, v) -> MediaType.Example(v) } this.examples = e.toMap().mapValues { (_, v) -> MediaType.Example(v) }
} }
fun mediaTypes(vararg m: String) = apply {
this.mediaTypes = m.toSet()
}
fun build() = RequestInfo( fun build() = RequestInfo(
requestType = requestType ?: error("Request type must be present"), requestType = requestType ?: error("Request type must be present"),
description = description ?: error("Description must be present"), description = description ?: error("Description must be present"),
examples = examples examples = examples,
mediaTypes = mediaTypes ?: setOf("application/json")
) )
} }
} }

View File

@ -9,7 +9,8 @@ class ResponseInfo private constructor(
val responseCode: HttpStatusCode, val responseCode: HttpStatusCode,
val responseType: KType, val responseType: KType,
val description: String, val description: String,
val examples: Map<String, MediaType.Example>? val examples: Map<String, MediaType.Example>?,
val mediaTypes: Set<String>
) { ) {
companion object { companion object {
@ -25,6 +26,7 @@ class ResponseInfo private constructor(
private var responseType: KType? = null private var responseType: KType? = null
private var description: String? = null private var description: String? = null
private var examples: Map<String, MediaType.Example>? = null private var examples: Map<String, MediaType.Example>? = null
private var mediaTypes: Set<String>? = null
fun responseCode(code: HttpStatusCode) = apply { fun responseCode(code: HttpStatusCode) = apply {
this.responseCode = code this.responseCode = code
@ -42,11 +44,16 @@ class ResponseInfo private constructor(
this.examples = e.toMap().mapValues { (_, v) -> MediaType.Example(v) } this.examples = e.toMap().mapValues { (_, v) -> MediaType.Example(v) }
} }
fun mediaTypes(vararg m: String) = apply {
this.mediaTypes = m.toSet()
}
fun build() = ResponseInfo( fun build() = ResponseInfo(
responseCode = responseCode ?: error("You must provide a response code in order to build a Response!"), responseCode = responseCode ?: error("You must provide a response code in order to build a Response!"),
responseType = responseType ?: error("You must provide a response type in order to build a Response!"), responseType = responseType ?: error("You must provide a response type in order to build a Response!"),
description = description ?: error("You must provide a description in order to build a Response!"), description = description ?: error("You must provide a description in order to build a Response!"),
examples = examples examples = examples,
mediaTypes = mediaTypes ?: setOf("application/json")
) )
} }
} }

View File

@ -12,6 +12,8 @@ import io.bkbn.kompendium.core.metadata.PutInfo
import io.bkbn.kompendium.core.metadata.ResponseInfo import io.bkbn.kompendium.core.metadata.ResponseInfo
import io.bkbn.kompendium.json.schema.SchemaConfigurator import io.bkbn.kompendium.json.schema.SchemaConfigurator
import io.bkbn.kompendium.json.schema.SchemaGenerator import io.bkbn.kompendium.json.schema.SchemaGenerator
import io.bkbn.kompendium.json.schema.definition.NullableDefinition
import io.bkbn.kompendium.json.schema.definition.OneOfDefinition
import io.bkbn.kompendium.json.schema.definition.ReferenceDefinition import io.bkbn.kompendium.json.schema.definition.ReferenceDefinition
import io.bkbn.kompendium.json.schema.util.Helpers.getReferenceSlug import io.bkbn.kompendium.json.schema.util.Helpers.getReferenceSlug
import io.bkbn.kompendium.json.schema.util.Helpers.getSimpleSlug import io.bkbn.kompendium.json.schema.util.Helpers.getSimpleSlug
@ -82,7 +84,7 @@ object Helpers {
requestBody = when (this) { requestBody = when (this) {
is MethodInfoWithRequest -> Request( is MethodInfoWithRequest -> Request(
description = this.request.description, description = this.request.description,
content = this.request.requestType.toReferenceContent(this.request.examples), content = this.request.requestType.toReferenceContent(this.request.examples, this.request.mediaTypes),
required = true required = true
) )
@ -91,7 +93,7 @@ object Helpers {
responses = mapOf( responses = mapOf(
this.response.responseCode.value to Response( this.response.responseCode.value to Response(
description = this.response.description, description = this.response.description,
content = this.response.responseType.toReferenceContent(this.response.examples) content = this.response.responseType.toReferenceContent(this.response.examples, this.response.mediaTypes)
) )
).plus(this.errors.toResponseMap()) ).plus(this.errors.toResponseMap())
) )
@ -99,18 +101,24 @@ object Helpers {
private fun List<ResponseInfo>.toResponseMap(): Map<Int, Response> = associate { error -> private fun List<ResponseInfo>.toResponseMap(): Map<Int, Response> = associate { error ->
error.responseCode.value to Response( error.responseCode.value to Response(
description = error.description, description = error.description,
content = error.responseType.toReferenceContent(error.examples) content = error.responseType.toReferenceContent(error.examples, error.mediaTypes)
) )
} }
private fun KType.toReferenceContent(examples: Map<String, MediaType.Example>?): Map<String, MediaType>? = private fun KType.toReferenceContent(
examples: Map<String, MediaType.Example>?,
mediaTypes: Set<String>
): Map<String, MediaType>? =
when (this.classifier as KClass<*>) { when (this.classifier as KClass<*>) {
Unit::class -> null Unit::class -> null
else -> mapOf( else -> mediaTypes.associateWith {
"application/json" to MediaType( MediaType(
schema = ReferenceDefinition(this.getReferenceSlug()), schema = if (this.isMarkedNullable) OneOfDefinition(
NullableDefinition(),
ReferenceDefinition(this.getReferenceSlug())
) else ReferenceDefinition(this.getReferenceSlug()),
examples = examples examples = examples
) )
) }
} }
} }

View File

@ -36,6 +36,7 @@ import io.bkbn.kompendium.core.util.TestModules.nullableEnumField
import io.bkbn.kompendium.core.util.TestModules.nullableField import io.bkbn.kompendium.core.util.TestModules.nullableField
import io.bkbn.kompendium.core.util.TestModules.nullableNestedObject import io.bkbn.kompendium.core.util.TestModules.nullableNestedObject
import io.bkbn.kompendium.core.util.TestModules.nullableReference import io.bkbn.kompendium.core.util.TestModules.nullableReference
import io.bkbn.kompendium.core.util.TestModules.overrideMediaTypes
import io.bkbn.kompendium.core.util.TestModules.polymorphicCollectionResponse import io.bkbn.kompendium.core.util.TestModules.polymorphicCollectionResponse
import io.bkbn.kompendium.core.util.TestModules.polymorphicException import io.bkbn.kompendium.core.util.TestModules.polymorphicException
import io.bkbn.kompendium.core.util.TestModules.polymorphicMapResponse import io.bkbn.kompendium.core.util.TestModules.polymorphicMapResponse
@ -49,6 +50,7 @@ import io.bkbn.kompendium.core.util.TestModules.simpleGenericResponse
import io.bkbn.kompendium.core.util.TestModules.simplePathParsing import io.bkbn.kompendium.core.util.TestModules.simplePathParsing
import io.bkbn.kompendium.core.util.TestModules.simpleRecursive import io.bkbn.kompendium.core.util.TestModules.simpleRecursive
import io.bkbn.kompendium.core.util.TestModules.singleException import io.bkbn.kompendium.core.util.TestModules.singleException
import io.bkbn.kompendium.core.util.TestModules.topLevelNullable
import io.bkbn.kompendium.core.util.TestModules.trailingSlash import io.bkbn.kompendium.core.util.TestModules.trailingSlash
import io.bkbn.kompendium.core.util.TestModules.unbackedFieldsResponse import io.bkbn.kompendium.core.util.TestModules.unbackedFieldsResponse
import io.bkbn.kompendium.core.util.TestModules.withOperationId import io.bkbn.kompendium.core.util.TestModules.withOperationId
@ -111,6 +113,9 @@ class KompendiumTest : DescribeSpec({
it("Can notarize a route with non-required params") { it("Can notarize a route with non-required params") {
openApiTestAllSerializers("T0011__non_required_params.json") { nonRequiredParams() } openApiTestAllSerializers("T0011__non_required_params.json") { nonRequiredParams() }
} }
it("Can override media types") {
openApiTestAllSerializers("T0052__override_media_types.json") { overrideMediaTypes() }
}
} }
describe("Route Parsing") { describe("Route Parsing") {
it("Can parse a simple path and store it under the expected route") { it("Can parse a simple path and store it under the expected route") {
@ -243,6 +248,9 @@ class KompendiumTest : DescribeSpec({
it("Can handle nested type names") { it("Can handle nested type names") {
openApiTestAllSerializers("T0044__nested_type_name.json") { nestedTypeName() } openApiTestAllSerializers("T0044__nested_type_name.json") { nestedTypeName() }
} }
it("Can handle top level nullable types") {
openApiTestAllSerializers("T0051__top_level_nullable.json") { topLevelNullable() }
}
} }
describe("Error Handling") { describe("Error Handling") {
it("Throws a clear exception when an unidentified type is encountered") { it("Throws a clear exception when an unidentified type is encountered") {

View File

@ -24,7 +24,7 @@ import io.bkbn.kompendium.core.fixtures.TestRequest
import io.bkbn.kompendium.core.fixtures.TestResponse import io.bkbn.kompendium.core.fixtures.TestResponse
import io.bkbn.kompendium.core.fixtures.TestSimpleRequest import io.bkbn.kompendium.core.fixtures.TestSimpleRequest
import io.bkbn.kompendium.core.fixtures.TransientObject import io.bkbn.kompendium.core.fixtures.TransientObject
import io.bkbn.kompendium.core.fixtures.UnbakcedObject import io.bkbn.kompendium.core.fixtures.UnbackedObject
import io.bkbn.kompendium.core.metadata.DeleteInfo import io.bkbn.kompendium.core.metadata.DeleteInfo
import io.bkbn.kompendium.core.metadata.GetInfo import io.bkbn.kompendium.core.metadata.GetInfo
import io.bkbn.kompendium.core.metadata.HeadInfo import io.bkbn.kompendium.core.metadata.HeadInfo
@ -315,6 +315,28 @@ object TestModules {
} }
} }
fun Routing.overrideMediaTypes() {
route("/media_types") {
install(NotarizedRoute()) {
put = PutInfo.builder {
summary(defaultPathSummary)
description(defaultPathDescription)
request {
mediaTypes("multipart/form-data", "application/json")
requestType<TestRequest>()
description("A cool request")
}
response {
mediaTypes("application/xml")
responseType<TestResponse>()
description("A good response")
responseCode(HttpStatusCode.Created)
}
}
}
}
}
fun Routing.simplePathParsing() { fun Routing.simplePathParsing() {
route("/this") { route("/this") {
route("/is") { route("/is") {
@ -568,7 +590,7 @@ object TestModules {
fun Routing.ignoredFieldsResponse() = basicGetGenerator<TransientObject>() fun Routing.ignoredFieldsResponse() = basicGetGenerator<TransientObject>()
fun Routing.unbackedFieldsResponse() = basicGetGenerator<UnbakcedObject>() fun Routing.unbackedFieldsResponse() = basicGetGenerator<UnbackedObject>()
fun Routing.customFieldNameResponse() = basicGetGenerator<SerialNameObject>() fun Routing.customFieldNameResponse() = basicGetGenerator<SerialNameObject>()
@ -613,6 +635,8 @@ object TestModules {
fun Routing.nestedTypeName() = basicGetGenerator<Nested.Response>() fun Routing.nestedTypeName() = basicGetGenerator<Nested.Response>()
fun Routing.topLevelNullable() = basicGetGenerator<TestResponse?>()
fun Routing.simpleRecursive() = basicGetGenerator<ColumnSchema>() fun Routing.simpleRecursive() = basicGetGenerator<ColumnSchema>()
fun Routing.defaultAuthConfig() { fun Routing.defaultAuthConfig() {

View File

@ -124,15 +124,19 @@
"type": "object", "type": "object",
"properties": { "properties": {
"enumeration": { "enumeration": {
"enum": [ "$ref": "#/components/schemas/SimpleEnum"
"ONE",
"TWO"
]
} }
}, },
"required": [ "required": [
"enumeration" "enumeration"
] ]
},
"SimpleEnum": {
"type": "string",
"enum": [
"ONE",
"TWO"
]
} }
}, },
"securitySchemes": {} "securitySchemes": {}

View File

@ -91,37 +91,30 @@
"required": [] "required": []
}, },
"ProfileMetadataUpdateRequest": { "ProfileMetadataUpdateRequest": {
"oneOf": [ "type": "object",
{ "properties": {
"type": "null" "isPrivate": {
}, "oneOf": [
{ {
"type": "object", "type": "null"
"properties": {
"isPrivate": {
"oneOf": [
{
"type": "null"
},
{
"type": "boolean"
}
]
}, },
"otherThing": { {
"oneOf": [ "type": "boolean"
{
"type": "null"
},
{
"type": "string"
}
]
} }
}, ]
"required": [] },
"otherThing": {
"oneOf": [
{
"type": "null"
},
{
"type": "string"
}
]
} }
] },
"required": []
} }
}, },
"securitySchemes": {} "securitySchemes": {}

View File

@ -62,16 +62,21 @@
"type": "null" "type": "null"
}, },
{ {
"enum": [ "$ref": "#/components/schemas/TestEnum"
"YES",
"NO"
]
} }
] ]
} }
}, },
"required": [] "required": []
} },
"TestEnum":
{
"type": "string",
"enum": [
"YES",
"NO"
]
}
}, },
"securitySchemes": {} "securitySchemes": {}
}, },

View File

@ -57,10 +57,7 @@
"type": "object", "type": "object",
"properties": { "properties": {
"enumeration": { "enumeration": {
"enum": [ "$ref": "#/components/schemas/SimpleEnum"
"ONE",
"TWO"
]
} }
}, },
"required": [ "required": [
@ -120,6 +117,13 @@
"required": [ "required": [
"content" "content"
] ]
},
"SimpleEnum": {
"type": "string",
"enum": [
"ONE",
"TWO"
]
} }
}, },
"securitySchemes": {} "securitySchemes": {}

View File

@ -53,6 +53,14 @@
"webhooks": {}, "webhooks": {},
"components": { "components": {
"schemas": { "schemas": {
"ColumnMode": {
"type": "string",
"enum": [
"NULLABLE",
"REQUIRED",
"REPEATED"
]
},
"ColumnSchema": { "ColumnSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -60,11 +68,7 @@
"type": "string" "type": "string"
}, },
"mode": { "mode": {
"enum": [ "$ref": "#/components/schemas/ColumnMode"
"NULLABLE",
"REQUIRED",
"REPEATED"
]
}, },
"name": { "name": {
"type": "string" "type": "string"

View File

@ -39,7 +39,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/UnbakcedObject" "$ref": "#/components/schemas/UnbackedObject"
} }
} }
} }
@ -53,7 +53,7 @@
"webhooks": {}, "webhooks": {},
"components": { "components": {
"schemas": { "schemas": {
"UnbakcedObject": { "UnbackedObject": {
"type": "object", "type": "object",
"properties": { "properties": {
"backed": { "backed": {

View File

@ -0,0 +1,79 @@
{
"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": {
"/": {
"get": {
"tags": [],
"summary": "Great Summary!",
"description": "testing more",
"parameters": [],
"responses": {
"200": {
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/TestResponse"
}
]
}
}
}
}
},
"deprecated": false
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"TestResponse": {
"type": "object",
"properties": {
"c": {
"type": "string"
}
},
"required": [
"c"
]
}
},
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -0,0 +1,123 @@
{
"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": {
"/media_types": {
"put": {
"tags": [],
"summary": "Great Summary!",
"description": "testing more",
"parameters": [],
"requestBody": {
"description": "A cool request",
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/TestRequest"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "A good response",
"content": {
"application/xml": {
"schema": {
"$ref": "#/components/schemas/TestResponse"
}
}
}
}
},
"deprecated": false
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"TestResponse": {
"type": "object",
"properties": {
"c": {
"type": "string"
}
},
"required": [
"c"
]
},
"TestRequest": {
"type": "object",
"properties": {
"aaa": {
"items": {
"type": "number",
"format": "int64"
},
"type": "array"
},
"b": {
"type": "number",
"format": "double"
},
"fieldName": {
"$ref": "#/components/schemas/TestNested"
}
},
"required": [
"aaa",
"b",
"fieldName"
]
},
"TestNested": {
"type": "object",
"properties": {
"nesty": {
"type": "string"
}
},
"required": [
"nesty"
]
}
},
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -163,7 +163,7 @@ data class TransientObject(
) )
@Serializable @Serializable
data class UnbakcedObject( data class UnbackedObject(
val backed: String val backed: String
) { ) {
val unbacked: String get() = "unbacked" val unbacked: String get() = "unbacked"
@ -176,3 +176,14 @@ data class SerialNameObject(
@SerialName("snake_case_name") @SerialName("snake_case_name")
val camelCaseName: String val camelCaseName: String
) )
enum class Color {
RED,
GREEN,
BLUE
}
@Serializable
data class ObjectWithEnum(
val color: Color
)

View File

@ -5,4 +5,5 @@
* [Notarized Application](plugins/notarized_application.md) * [Notarized Application](plugins/notarized_application.md)
* [Notarized Route](plugins/notarized_route.md) * [Notarized Route](plugins/notarized_route.md)
* [Notarized Locations](plugins/notarized_locations.md) * [Notarized Locations](plugins/notarized_locations.md)
* [Notarized Resources](plugins/notarized_resources.md)
* [The Playground](playground.md) * [The Playground](playground.md)

View File

@ -13,6 +13,7 @@ At the moment, the following playground applications are
| Hidden Docs | Place your generated documentation behind authorization | | Hidden Docs | Place your generated documentation behind authorization |
| Jackson | Serialization using Jackson instead of the default KotlinX | | Jackson | Serialization using Jackson instead of the default KotlinX |
| Locations | Using the Ktor Locations API to define routes | | Locations | Using the Ktor Locations API to define routes |
| Resources | Using the Ktor Resources API to define routes |
You can find all of the playground You can find all of the playground
examples [here](https://github.com/bkbnio/kompendium/tree/main/playground/src/main/kotlin/io/bkbn/kompendium/playground) examples [here](https://github.com/bkbnio/kompendium/tree/main/playground/src/main/kotlin/io/bkbn/kompendium/playground)

View File

@ -0,0 +1,60 @@
The Ktor Resources API allows users to define their routes in a type-safe manner.
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`
## Adding the Artifact
Prior to documenting your resources, you will need to add the artifact to your gradle build file.
```kotlin
dependencies {
implementation("io.bkbn:kompendium-resources:$version")
}
```
## Installing Plugin
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.
```kotlin
private fun Application.mainModule() {
install(Resources)
install(NotarizedApplication()) {
spec = baseSpec
}
install(NotarizedResources()) {
resources = mapOf(
Listing::class to NotarizedResources.ResourceMetadata(
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 😱")
}
}
),
)
}
}
```
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!

View File

@ -165,3 +165,21 @@ get = GetInfo.builder {
} }
} }
``` ```
## Media Types
By default, Kompendium will set the only media type to "application/json". If you would like to override the media type
for a specific request or response (including errors), you can do so with the `mediaTypes` method
```kotlin
get = GetInfo.builder {
summary("Get user by id")
description("A very neat endpoint!")
response {
mediaTypes("application/xml")
responseCode(HttpStatusCode.OK)
responseType<ExampleResponse>()
description("Will return whether or not the user is real 😱")
}
}
```

View File

@ -1,5 +1,5 @@
# Kompendium # Kompendium
project.version=3.4.0 project.version=3.6.0
# Kotlin # Kotlin
kotlin.code.style=official kotlin.code.style=official
# Gradle # Gradle
@ -8,6 +8,6 @@ org.gradle.vfs.verbose=true
org.gradle.jvmargs=-Xmx2000m org.gradle.jvmargs=-Xmx2000m
# Dependencies # Dependencies
ktorVersion=2.1.2 ktorVersion=2.1.3
kotestVersion=5.5.2 kotestVersion=5.5.4
detektVersion=1.21.0 detektVersion=1.21.0

View File

@ -48,7 +48,7 @@ object SchemaGenerator {
Boolean::class -> checkForNull(type, TypeDefinition.BOOLEAN) Boolean::class -> checkForNull(type, TypeDefinition.BOOLEAN)
UUID::class -> checkForNull(type, TypeDefinition.UUID) UUID::class -> checkForNull(type, TypeDefinition.UUID)
else -> when { else -> when {
clazz.isSubclassOf(Enum::class) -> EnumHandler.handle(type, clazz) clazz.isSubclassOf(Enum::class) -> EnumHandler.handle(type, clazz, cache)
clazz.isSubclassOf(Collection::class) -> CollectionHandler.handle(type, cache, schemaConfigurator) clazz.isSubclassOf(Collection::class) -> CollectionHandler.handle(type, cache, schemaConfigurator)
clazz.isSubclassOf(Map::class) -> MapHandler.handle(type, cache, schemaConfigurator) clazz.isSubclassOf(Map::class) -> MapHandler.handle(type, cache, schemaConfigurator)
else -> { else -> {

View File

@ -4,5 +4,6 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class EnumDefinition( data class EnumDefinition(
val type: String,
val enum: Set<String> val enum: Set<String>
) : JsonSchema ) : JsonSchema

View File

@ -2,18 +2,17 @@ package io.bkbn.kompendium.json.schema.handler
import io.bkbn.kompendium.json.schema.definition.EnumDefinition import io.bkbn.kompendium.json.schema.definition.EnumDefinition
import io.bkbn.kompendium.json.schema.definition.JsonSchema import io.bkbn.kompendium.json.schema.definition.JsonSchema
import io.bkbn.kompendium.json.schema.definition.NullableDefinition import io.bkbn.kompendium.json.schema.definition.ReferenceDefinition
import io.bkbn.kompendium.json.schema.definition.OneOfDefinition import io.bkbn.kompendium.json.schema.util.Helpers.getReferenceSlug
import io.bkbn.kompendium.json.schema.util.Helpers.getSimpleSlug
import kotlin.reflect.KClass import kotlin.reflect.KClass
import kotlin.reflect.KType import kotlin.reflect.KType
object EnumHandler { object EnumHandler {
fun handle(type: KType, clazz: KClass<*>): JsonSchema { fun handle(type: KType, clazz: KClass<*>, cache: MutableMap<String, JsonSchema>): JsonSchema {
cache[type.getSimpleSlug()] = ReferenceDefinition(type.getReferenceSlug())
val options = clazz.java.enumConstants.map { it.toString() }.toSet() val options = clazz.java.enumConstants.map { it.toString() }.toSet()
val definition = EnumDefinition(enum = options) return EnumDefinition(type = "string", enum = options)
return when (type.isMarkedNullable) {
true -> OneOfDefinition(NullableDefinition(), definition)
false -> definition
}
} }
} }

View File

@ -2,6 +2,7 @@ package io.bkbn.kompendium.json.schema.handler
import io.bkbn.kompendium.json.schema.SchemaConfigurator import io.bkbn.kompendium.json.schema.SchemaConfigurator
import io.bkbn.kompendium.json.schema.SchemaGenerator import io.bkbn.kompendium.json.schema.SchemaGenerator
import io.bkbn.kompendium.json.schema.definition.EnumDefinition
import io.bkbn.kompendium.json.schema.definition.JsonSchema import io.bkbn.kompendium.json.schema.definition.JsonSchema
import io.bkbn.kompendium.json.schema.definition.NullableDefinition import io.bkbn.kompendium.json.schema.definition.NullableDefinition
import io.bkbn.kompendium.json.schema.definition.OneOfDefinition import io.bkbn.kompendium.json.schema.definition.OneOfDefinition
@ -71,16 +72,11 @@ object SimpleObjectHandler {
.map { schemaConfigurator.serializableName(it) } .map { schemaConfigurator.serializableName(it) }
.toSet() .toSet()
val definition = TypeDefinition( return TypeDefinition(
type = "object", type = "object",
properties = props, properties = props,
required = required required = required
) )
return when (type.isMarkedNullable) {
true -> OneOfDefinition(NullableDefinition(), definition)
false -> definition
}
} }
private fun KProperty<*>.needsToInjectGenerics( private fun KProperty<*>.needsToInjectGenerics(
@ -103,7 +99,7 @@ object SimpleObjectHandler {
} }
val constructedType = propClass.createType(types) val constructedType = propClass.createType(types)
return SchemaGenerator.fromTypeToSchema(constructedType, cache, schemaConfigurator).let { return SchemaGenerator.fromTypeToSchema(constructedType, cache, schemaConfigurator).let {
if (it.isOrContainsObjectDef()) { if (it.isOrContainsObjectOrEnumDef()) {
cache[constructedType.getSimpleSlug()] = it cache[constructedType.getSimpleSlug()] = it
ReferenceDefinition(prop.returnType.getReferenceSlug()) ReferenceDefinition(prop.returnType.getReferenceSlug())
} else { } else {
@ -121,7 +117,7 @@ object SimpleObjectHandler {
val type = typeMap[prop.returnType.classifier]?.type val type = typeMap[prop.returnType.classifier]?.type
?: error("This indicates a bug in Kompendium, please open a GitHub issue") ?: error("This indicates a bug in Kompendium, please open a GitHub issue")
return SchemaGenerator.fromTypeToSchema(type, cache, schemaConfigurator).let { return SchemaGenerator.fromTypeToSchema(type, cache, schemaConfigurator).let {
if (it.isOrContainsObjectDef()) { if (it.isOrContainsObjectOrEnumDef()) {
cache[type.getSimpleSlug()] = it cache[type.getSimpleSlug()] = it
ReferenceDefinition(type.getReferenceSlug()) ReferenceDefinition(type.getReferenceSlug())
} else { } else {
@ -136,7 +132,7 @@ object SimpleObjectHandler {
schemaConfigurator: SchemaConfigurator schemaConfigurator: SchemaConfigurator
): JsonSchema = ): JsonSchema =
SchemaGenerator.fromTypeToSchema(prop.returnType, cache, schemaConfigurator).let { SchemaGenerator.fromTypeToSchema(prop.returnType, cache, schemaConfigurator).let {
if (it.isOrContainsObjectDef()) { if (it.isOrContainsObjectOrEnumDef()) {
cache[prop.returnType.getSimpleSlug()] = it cache[prop.returnType.getSimpleSlug()] = it
ReferenceDefinition(prop.returnType.getReferenceSlug()) ReferenceDefinition(prop.returnType.getReferenceSlug())
} else { } else {
@ -144,10 +140,12 @@ object SimpleObjectHandler {
} }
} }
private fun JsonSchema.isOrContainsObjectDef(): Boolean { private fun JsonSchema.isOrContainsObjectOrEnumDef(): Boolean {
val isTypeDef = this is TypeDefinition && type == "object" val isTypeDef = this is TypeDefinition && type == "object"
val isTypeDefOneOf = this is OneOfDefinition && this.oneOf.any { js -> js is TypeDefinition && js.type == "object" } val isTypeDefOneOf = this is OneOfDefinition && this.oneOf.any { js -> js is TypeDefinition && js.type == "object" }
return isTypeDef || isTypeDefOneOf val isEnumDef = this is EnumDefinition
val isEnumDefOneOf = this is OneOfDefinition && this.oneOf.any { js -> js is EnumDefinition }
return isTypeDef || isTypeDefOneOf || isEnumDef || isEnumDefOneOf
} }
private fun JsonSchema.isNullable(): Boolean = this is OneOfDefinition && this.oneOf.any { it is NullableDefinition } private fun JsonSchema.isNullable(): Boolean = this is OneOfDefinition && this.oneOf.any { it is NullableDefinition }

View File

@ -2,6 +2,7 @@ package io.bkbn.kompendium.json.schema
import io.bkbn.kompendium.core.fixtures.ComplexRequest import io.bkbn.kompendium.core.fixtures.ComplexRequest
import io.bkbn.kompendium.core.fixtures.FlibbityGibbit import io.bkbn.kompendium.core.fixtures.FlibbityGibbit
import io.bkbn.kompendium.core.fixtures.ObjectWithEnum
import io.bkbn.kompendium.core.fixtures.SerialNameObject import io.bkbn.kompendium.core.fixtures.SerialNameObject
import io.bkbn.kompendium.core.fixtures.SimpleEnum import io.bkbn.kompendium.core.fixtures.SimpleEnum
import io.bkbn.kompendium.core.fixtures.SlammaJamma import io.bkbn.kompendium.core.fixtures.SlammaJamma
@ -9,7 +10,7 @@ import io.bkbn.kompendium.core.fixtures.TestHelpers.getFileSnapshot
import io.bkbn.kompendium.core.fixtures.TestResponse import io.bkbn.kompendium.core.fixtures.TestResponse
import io.bkbn.kompendium.core.fixtures.TestSimpleRequest import io.bkbn.kompendium.core.fixtures.TestSimpleRequest
import io.bkbn.kompendium.core.fixtures.TransientObject import io.bkbn.kompendium.core.fixtures.TransientObject
import io.bkbn.kompendium.core.fixtures.UnbakcedObject import io.bkbn.kompendium.core.fixtures.UnbackedObject
import io.bkbn.kompendium.json.schema.definition.JsonSchema import io.bkbn.kompendium.json.schema.definition.JsonSchema
import io.kotest.assertions.json.shouldEqualJson import io.kotest.assertions.json.shouldEqualJson
import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.throwables.shouldThrow
@ -40,6 +41,7 @@ class SchemaGeneratorTest : DescribeSpec({
jsonSchemaTest<ComplexRequest>("T0005__complex_object.json") jsonSchemaTest<ComplexRequest>("T0005__complex_object.json")
} }
it("Can generate the schema for a nullable object") { it("Can generate the schema for a nullable object") {
// Same schema as a non-nullable type, since the nullability will be handled on the property
jsonSchemaTest<TestSimpleRequest?>("T0006__nullable_object.json") jsonSchemaTest<TestSimpleRequest?>("T0006__nullable_object.json")
} }
it("Can generate the schema for a polymorphic object") { it("Can generate the schema for a polymorphic object") {
@ -52,7 +54,7 @@ class SchemaGeneratorTest : DescribeSpec({
jsonSchemaTest<TransientObject>("T0018__transient_object.json") jsonSchemaTest<TransientObject>("T0018__transient_object.json")
} }
it("Can generate the schema for object with unbacked property") { it("Can generate the schema for object with unbacked property") {
jsonSchemaTest<UnbakcedObject>("T0019__unbacked_object.json") jsonSchemaTest<UnbackedObject>("T0019__unbacked_object.json")
} }
it("Can generate the schema for object with SerialName annotation") { it("Can generate the schema for object with SerialName annotation") {
jsonSchemaTest<SerialNameObject>("T0020__serial_name_object.json") jsonSchemaTest<SerialNameObject>("T0020__serial_name_object.json")
@ -63,8 +65,12 @@ class SchemaGeneratorTest : DescribeSpec({
jsonSchemaTest<SimpleEnum>("T0007__simple_enum.json") jsonSchemaTest<SimpleEnum>("T0007__simple_enum.json")
} }
it("Can generate the schema for a nullable enum") { it("Can generate the schema for a nullable enum") {
// Same schema as a non-nullable enum, since the nullability will be handled on the property
jsonSchemaTest<SimpleEnum?>("T0008__nullable_enum.json") jsonSchemaTest<SimpleEnum?>("T0008__nullable_enum.json")
} }
it("Can generate the schema for an object with an enum property") {
jsonSchemaTest<ObjectWithEnum>("T0021__object_with_enum.json")
}
} }
describe("Arrays") { describe("Arrays") {
it("Can generate the schema for an array of scalars") { it("Can generate the schema for an array of scalars") {

View File

@ -1,23 +1,16 @@
{ {
"oneOf": [ "type": "object",
{ "properties": {
"type": "null" "a": {
"type": "string"
}, },
{ "b": {
"type": "object", "type": "number",
"properties": { "format": "int32"
"a": {
"type": "string"
},
"b": {
"type": "number",
"format": "int32"
}
},
"required": [
"a",
"b"
]
} }
},
"required": [
"a",
"b"
] ]
} }

View File

@ -1,3 +1,4 @@
{ {
"enum": [ "ONE", "TWO" ] "enum": [ "ONE", "TWO" ],
"type": "string"
} }

View File

@ -1,13 +1,4 @@
{ {
"oneOf": [ "enum": [ "ONE", "TWO" ],
{ "type": "string"
"type": "null"
},
{
"enum": [
"ONE",
"TWO"
]
}
]
} }

View File

@ -0,0 +1,11 @@
{
"type": "object",
"properties": {
"color": {
"$ref": "#/components/schemas/Color"
}
},
"required": [
"color"
]
}

View File

@ -22,8 +22,8 @@ dependencies {
// IMPLEMENTATION // IMPLEMENTATION
implementation(projects.kompendiumCore) implementation(projects.kompendiumCore)
implementation("io.ktor:ktor-server-core:2.1.2") implementation("io.ktor:ktor-server-core:2.1.3")
implementation("io.ktor:ktor-server-locations:2.1.2") implementation("io.ktor:ktor-server-locations:2.1.3")
// TESTING // TESTING

View File

@ -20,6 +20,11 @@ import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.hasAnnotation import kotlin.reflect.full.hasAnnotation
import kotlin.reflect.full.memberProperties import kotlin.reflect.full.memberProperties
@Deprecated(
message = "This functionality is deprecated and will be removed in the future. " +
"Use 'ktor-server-resources' with 'kompendium-resources' plugin instead.",
level = DeprecationLevel.WARNING
)
object NotarizedLocations { object NotarizedLocations {
data class LocationMetadata( data class LocationMetadata(
@ -43,7 +48,6 @@ object NotarizedLocations {
name = "NotarizedLocations", name = "NotarizedLocations",
createConfiguration = ::Config createConfiguration = ::Config
) { ) {
println("hi")
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.locations.forEach { (k, v) -> pluginConfig.locations.forEach { (k, v) ->

View File

@ -14,6 +14,7 @@ dependencies {
// IMPLEMENTATION // IMPLEMENTATION
implementation(projects.kompendiumCore) implementation(projects.kompendiumCore)
implementation(projects.kompendiumLocations) implementation(projects.kompendiumLocations)
implementation(projects.kompendiumResources)
// Ktor // Ktor
val ktorVersion: String by project val ktorVersion: String by project
@ -29,6 +30,7 @@ dependencies {
implementation("io.ktor:ktor-serialization-jackson:$ktorVersion") implementation("io.ktor:ktor-serialization-jackson:$ktorVersion")
implementation("io.ktor:ktor-serialization-gson:$ktorVersion") implementation("io.ktor:ktor-serialization-gson:$ktorVersion")
implementation("io.ktor:ktor-server-locations:$ktorVersion") implementation("io.ktor:ktor-server-locations:$ktorVersion")
implementation("io.ktor:ktor-server-resources:$ktorVersion")
// Logging // Logging
implementation("org.apache.logging.log4j:log4j-api-kotlin:1.2.0") implementation("org.apache.logging.log4j:log4j-api-kotlin:1.2.0")
@ -41,5 +43,5 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0")
implementation("joda-time:joda-time:2.12.0") implementation("joda-time:joda-time:2.12.1")
} }

View File

@ -0,0 +1,85 @@
package io.bkbn.kompendium.playground
import io.bkbn.kompendium.core.metadata.GetInfo
import io.bkbn.kompendium.core.plugin.NotarizedApplication
import io.bkbn.kompendium.core.routes.redoc
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
import io.bkbn.kompendium.oas.payload.Parameter
import io.bkbn.kompendium.oas.serialization.KompendiumSerializersModule
import io.bkbn.kompendium.playground.util.ExampleResponse
import io.bkbn.kompendium.playground.util.Util.baseSpec
import io.bkbn.kompendium.resources.NotarizedResources
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.call
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.get
import io.ktor.server.response.respondText
import io.ktor.server.routing.routing
import kotlinx.serialization.Serializable
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
}
install(NotarizedResources()) {
resources = mapOf(
ListingResource::class to NotarizedResources.ResourceMetadata(
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 😱")
}
}
),
)
}
routing {
redoc(pageTitle = "Simple API Docs")
get<ListingResource> { listing ->
call.respondText("Listing ${listing.name}, page ${listing.page}")
}
}
}
@Serializable
@Resource("/list/{name}/page/{page}")
data class ListingResource(val name: String, val page: Int)

View File

@ -0,0 +1,42 @@
plugins {
kotlin("jvm")
kotlin("plugin.serialization")
id("io.bkbn.sourdough.library.jvm")
id("io.gitlab.arturbosch.detekt")
id("com.adarshr.test-logger")
id("maven-publish")
id("java-library")
id("signing")
id("org.jetbrains.kotlinx.kover")
}
sourdoughLibrary {
libraryName.set("Kompendium Resources")
libraryDescription.set("Supplemental library for Kompendium offering support for Ktor's Resources API")
}
dependencies {
// Versions
val detektVersion: String by project
// IMPLEMENTATION
implementation(projects.kompendiumCore)
implementation("io.ktor:ktor-server-core:2.1.3")
implementation("io.ktor:ktor-server-resources:2.1.3")
// TESTING
testImplementation(testFixtures(projects.kompendiumCore))
// Formatting
detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:$detektVersion")
}
testing {
suites {
named("test", JvmTestSuite::class) {
useJUnitJupiter()
}
}
}

View File

@ -0,0 +1,78 @@
package io.bkbn.kompendium.resources
import io.bkbn.kompendium.core.attribute.KompendiumAttributes
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
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 {
data class ResourceMetadata(
override var tags: Set<String> = emptySet(),
override var parameters: List<Parameter> = emptyList(),
override var get: GetInfo? = null,
override var post: PostInfo? = null,
override var put: PutInfo? = null,
override var delete: DeleteInfo? = null,
override var patch: PatchInfo? = null,
override var head: HeadInfo? = null,
override var options: OptionsInfo? = null,
override var security: Map<String, List<String>>? = null,
) : SpecConfig
class Config {
lateinit var resources: Map<KClass<*>, ResourceMetadata>
}
operator fun invoke() = createApplicationPlugin(
name = "NotarizedResources",
createConfiguration = NotarizedResources::Config
) {
val spec = application.attributes[KompendiumAttributes.openApiSpec]
val serializableReader = application.attributes[KompendiumAttributes.schemaConfigurator]
pluginConfig.resources.forEach { (k, v) ->
val path = Path()
path.parameters = v.parameters
v.get?.addToSpec(path, spec, v, serializableReader)
v.delete?.addToSpec(path, spec, v, serializableReader)
v.head?.addToSpec(path, spec, v, serializableReader)
v.options?.addToSpec(path, spec, v, serializableReader)
v.post?.addToSpec(path, spec, v, serializableReader)
v.put?.addToSpec(path, spec, v, serializableReader)
v.patch?.addToSpec(path, spec, v, serializableReader)
val resource = k.getResourcesFromClass()
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

@ -0,0 +1,120 @@
package io.bkbn.kompendium.resources
import Listing
import Type
import io.bkbn.kompendium.core.fixtures.TestHelpers.openApiTestAllSerializers
import io.bkbn.kompendium.core.fixtures.TestResponse
import io.bkbn.kompendium.core.metadata.GetInfo
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
import io.bkbn.kompendium.oas.payload.Parameter
import io.kotest.core.spec.style.DescribeSpec
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.call
import io.ktor.server.application.install
import io.ktor.server.resources.Resources
import io.ktor.server.resources.get
import io.ktor.server.response.respondText
class KompendiumResourcesTest : DescribeSpec({
describe("Resource Tests") {
it("Can notarize a simple resource") {
openApiTestAllSerializers(
snapshotName = "T0001__simple_resource.json",
applicationSetup = {
install(Resources)
install(NotarizedResources()) {
resources = mapOf(
Listing::class to NotarizedResources.ResourceMetadata(
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<TestResponse>()
description("does great things")
}
}
),
)
}
}
) {
get<Listing> { listing ->
call.respondText("Listing ${listing.name}, page ${listing.page}")
}
}
}
it("Can notarize nested resources") {
openApiTestAllSerializers(
snapshotName = "T0002__nested_resources.json",
applicationSetup = {
install(Resources)
install(NotarizedResources()) {
resources = mapOf(
Type.Edit::class to NotarizedResources.ResourceMetadata(
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")
}
}
),
Type.Other::class to NotarizedResources.ResourceMetadata(
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")
}
}
),
)
}
}
) {
get<Type.Edit> { edit ->
call.respondText("Listing ${edit.parent.name}")
}
get<Type.Other> { other ->
call.respondText("Listing ${other.parent.name}, page ${other.page}")
}
}
}
}
})

View File

@ -0,0 +1,17 @@
import io.ktor.resources.Resource
import kotlinx.serialization.Serializable
@Serializable
@Resource("/list/{name}/page/{page}")
data class Listing(val name: String, val page: Int)
@Serializable
@Resource("/type/{name}")
data class Type(val name: String) {
@Serializable
@Resource("/edit")
data class Edit(val parent: Type)
@Serializable
@Resource("/other/{page}")
data class Other(val parent: Type, val page: Int)
}

View File

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

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": {
"/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
}
]
},
"/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": []
}

View File

@ -5,6 +5,7 @@ include("oas")
include("playground") include("playground")
include("locations") include("locations")
include("json-schema") include("json-schema")
include("resources")
run { run {
rootProject.children.forEach { it.name = "${rootProject.name}-${it.name}" } rootProject.children.forEach { it.name = "${rootProject.name}-${it.name}" }