Compare commits

..

10 Commits

Author SHA1 Message Date
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
37 changed files with 863 additions and 112 deletions

View File

@ -12,6 +12,19 @@
## Released ## Released
## [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

@ -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
@ -108,7 +110,10 @@ object Helpers {
Unit::class -> null Unit::class -> null
else -> mapOf( else -> mapOf(
"application/json" to MediaType( "application/json" to MediaType(
schema = ReferenceDefinition(this.getReferenceSlug()), schema = if (this.isMarkedNullable) OneOfDefinition(
NullableDefinition(),
ReferenceDefinition(this.getReferenceSlug())
) else ReferenceDefinition(this.getReferenceSlug()),
examples = examples examples = examples
) )
) )

View File

@ -49,6 +49,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
@ -243,6 +244,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
@ -568,7 +568,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 +613,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

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

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