Compare commits

..

19 Commits

Author SHA1 Message Date
377a60614e feat: constraints (#409) 2023-01-05 14:44:24 +00:00
e34bea1769 fix(deps): update ktor (#397)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-01-04 21:49:06 -05:00
91bf93a866 feat: type enrichment (#408) 2023-01-04 21:32:31 -05:00
73fb8b137f fix(deps): update dependency org.jetbrains.kotlin:kotlin-reflect to v1.8.0 (#405)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-12-28 22:11:08 +00:00
d3d6e79329 fix(deps): update dependency com.google.protobuf:protobuf-java to v3.21.12 (#402)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-12-15 10:02:49 +00:00
f2b7d924e0 fix(deps): update dependency org.slf4j:slf4j-simple to v2.0.6 (#401)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-12-13 10:38:53 +00:00
f62010b4e7 fix(deps): update dependency org.slf4j:slf4j-api to v2.0.6 (#400)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-12-13 07:39:57 +00:00
5bd1534270 fix(deps): update dependency com.google.protobuf:protobuf-java to v3.21.11 (#398)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-12-08 22:19:05 +00:00
4c151ffeea fix(deps): update dependency joda-time:joda-time to v2.12.2 (#396)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-12-01 18:21:42 +00:00
08a0d2e47c fix(deps): update dependency com.google.protobuf:protobuf-java to v3.21.10 (#395)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-12-01 09:58:10 +00:00
f19476d3a3 chore: bump gradle version 2022-11-30 12:48:46 -05:00
8ede53fd5c fix(deps): update dependency org.jetbrains.kotlin:kotlin-reflect to v1.7.22 (#394)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-11-28 21:50:54 +00:00
31a9f44ec1 chore(deps): update plugin org.jetbrains.kotlin.plugin.serialization to v1.7.22 (#393)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-11-28 15:31:11 +00:00
0eb2f126b3 chore(deps): update plugin org.jetbrains.kotlin.jvm to v1.7.22 (#392)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-11-28 15:24:18 +00:00
0d658bd6a8 fix(deps): update dependency org.slf4j:slf4j-simple to v2.0.5 (#390)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-11-25 19:01:28 +00:00
e924671c2b fix(deps): update dependency org.slf4j:slf4j-api to v2.0.5 (#389)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-11-25 18:54:39 +00:00
04996631b9 fix(deps): update dependency ch.qos.logback:logback-classic to v1.4.5 (#387)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-11-19 06:38:07 +00:00
82d80873c8 fix(deps): update dependency org.slf4j:slf4j-simple to v2.0.4 (#386)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-11-18 09:50:16 +00:00
487eaba741 fix(deps): update dependency org.slf4j:slf4j-api to v2.0.4 (#385)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-11-18 06:42:30 +00:00
64 changed files with 2226 additions and 124 deletions

View File

@ -12,6 +12,18 @@
## Released
## [3.11.0] - January 5th, 2023
### Added
- Support for type constraints.
## [3.10.0] - January 4th, 2023
### Added
- Support for type enrichments! `deprecated` and `description` to start
## [3.9.0] - November 15th, 2022
### Added

View File

@ -1,6 +1,6 @@
plugins {
kotlin("jvm") version "1.7.21" apply false
kotlin("plugin.serialization") version "1.7.21" apply false
kotlin("jvm") version "1.8.0" apply false
kotlin("plugin.serialization") version "1.8.0" apply false
id("io.bkbn.sourdough.library.jvm") version "0.12.0" apply false
id("io.bkbn.sourdough.application.jvm") version "0.12.0" apply false
id("io.bkbn.sourdough.root") version "0.12.0"

View File

@ -33,7 +33,7 @@ dependencies {
implementation("io.ktor:ktor-server-html-builder:$ktorVersion")
implementation("io.ktor:ktor-server-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
implementation("ch.qos.logback:logback-classic:1.4.4")
implementation("ch.qos.logback:logback-classic:1.4.5")
// Formatting
detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:$detektVersion")
@ -58,7 +58,7 @@ dependencies {
testFixturesApi("io.ktor:ktor-client:$ktorVersion")
testFixturesApi("io.ktor:ktor-client-cio:$ktorVersion")
testFixturesApi("dev.forst:ktor-api-key:2.1.3")
testFixturesApi("dev.forst:ktor-api-key:2.2.1")
testFixturesApi("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1")
}

View File

@ -1,11 +1,13 @@
package io.bkbn.kompendium.core.metadata
import io.bkbn.kompendium.enrichment.TypeEnrichment
import io.bkbn.kompendium.oas.payload.MediaType
import kotlin.reflect.KType
import kotlin.reflect.typeOf
class RequestInfo private constructor(
val requestType: KType,
val typeEnrichment: TypeEnrichment<*>?,
val description: String,
val examples: Map<String, MediaType.Example>?,
val mediaTypes: Set<String>
@ -21,6 +23,7 @@ class RequestInfo private constructor(
class Builder {
private var requestType: KType? = null
private var typeEnrichment: TypeEnrichment<*>? = null
private var description: String? = null
private var examples: Map<String, MediaType.Example>? = null
private var mediaTypes: Set<String>? = null
@ -29,7 +32,14 @@ class RequestInfo private constructor(
this.requestType = t
}
inline fun <reified T> requestType() = apply { requestType(typeOf<T>()) }
fun enrichment(t: TypeEnrichment<*>) = apply {
this.typeEnrichment = t
}
inline fun <reified T> requestType(enrichment: TypeEnrichment<T>? = null) = apply {
requestType(typeOf<T>())
enrichment?.let { enrichment(it) }
}
fun description(s: String) = apply { this.description = s }
@ -44,6 +54,7 @@ class RequestInfo private constructor(
fun build() = RequestInfo(
requestType = requestType ?: error("Request type must be present"),
description = description ?: error("Description must be present"),
typeEnrichment = typeEnrichment,
examples = examples,
mediaTypes = mediaTypes ?: setOf("application/json")
)

View File

@ -1,5 +1,6 @@
package io.bkbn.kompendium.core.metadata
import io.bkbn.kompendium.enrichment.TypeEnrichment
import io.bkbn.kompendium.oas.payload.MediaType
import io.ktor.http.HttpStatusCode
import kotlin.reflect.KType
@ -8,6 +9,7 @@ import kotlin.reflect.typeOf
class ResponseInfo private constructor(
val responseCode: HttpStatusCode,
val responseType: KType,
val typeEnrichment: TypeEnrichment<*>?,
val description: String,
val examples: Map<String, MediaType.Example>?,
val mediaTypes: Set<String>
@ -24,6 +26,7 @@ class ResponseInfo private constructor(
class Builder {
private var responseCode: HttpStatusCode? = null
private var responseType: KType? = null
private var typeEnrichment: TypeEnrichment<*>? = null
private var description: String? = null
private var examples: Map<String, MediaType.Example>? = null
private var mediaTypes: Set<String>? = null
@ -36,7 +39,14 @@ class ResponseInfo private constructor(
this.responseType = t
}
inline fun <reified T> responseType() = apply { responseType(typeOf<T>()) }
fun enrichment(t: TypeEnrichment<*>) = apply {
this.typeEnrichment = t
}
inline fun <reified T> responseType(enrichment: TypeEnrichment<T>? = null) = apply {
responseType(typeOf<T>())
enrichment?.let { enrichment(it) }
}
fun description(s: String) = apply { this.description = s }
@ -52,6 +62,7 @@ class ResponseInfo private constructor(
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!"),
description = description ?: error("You must provide a description in order to build a Response!"),
typeEnrichment = typeEnrichment,
examples = examples,
mediaTypes = mediaTypes ?: setOf("application/json")
)

View File

@ -10,13 +10,14 @@ 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.metadata.ResponseInfo
import io.bkbn.kompendium.enrichment.TypeEnrichment
import io.bkbn.kompendium.json.schema.SchemaConfigurator
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.util.Helpers.getReferenceSlug
import io.bkbn.kompendium.json.schema.util.Helpers.getSimpleSlug
import io.bkbn.kompendium.json.schema.util.Helpers.getSlug
import io.bkbn.kompendium.oas.OpenApiSpec
import io.bkbn.kompendium.oas.path.Path
import io.bkbn.kompendium.oas.path.PathOperation
@ -50,26 +51,34 @@ object Helpers {
authMethods: List<String> = emptyList()
) {
SchemaGenerator.fromTypeOrUnit(
this.response.responseType,
spec.components.schemas, schemaConfigurator
type = this.response.responseType,
cache = spec.components.schemas,
schemaConfigurator = schemaConfigurator,
enrichment = this.response.typeEnrichment,
)?.let { schema ->
spec.components.schemas[this.response.responseType.getSimpleSlug()] = schema
spec.components.schemas[this.response.responseType.getSlug(this.response.typeEnrichment)] = schema
}
errors.forEach { error ->
SchemaGenerator.fromTypeOrUnit(error.responseType, spec.components.schemas, schemaConfigurator)?.let { schema ->
spec.components.schemas[error.responseType.getSimpleSlug()] = schema
SchemaGenerator.fromTypeOrUnit(
type = error.responseType,
cache = spec.components.schemas,
schemaConfigurator = schemaConfigurator,
enrichment = error.typeEnrichment,
)?.let { schema ->
spec.components.schemas[error.responseType.getSlug(error.typeEnrichment)] = schema
}
}
when (this) {
is MethodInfoWithRequest -> {
SchemaGenerator.fromTypeOrUnit(
this.request.requestType,
spec.components.schemas,
schemaConfigurator
type = this.request.requestType,
cache = spec.components.schemas,
schemaConfigurator = schemaConfigurator,
enrichment = this.request.typeEnrichment,
)?.let { schema ->
spec.components.schemas[this.request.requestType.getSimpleSlug()] = schema
spec.components.schemas[this.request.requestType.getSlug(this.request.typeEnrichment)] = schema
}
}
@ -114,7 +123,11 @@ object Helpers {
requestBody = when (this) {
is MethodInfoWithRequest -> Request(
description = this.request.description,
content = this.request.requestType.toReferenceContent(this.request.examples, this.request.mediaTypes),
content = this.request.requestType.toReferenceContent(
examples = this.request.examples,
mediaTypes = this.request.mediaTypes,
enrichment = this.request.typeEnrichment
),
required = true
)
@ -123,7 +136,11 @@ object Helpers {
responses = mapOf(
this.response.responseCode.value to Response(
description = this.response.description,
content = this.response.responseType.toReferenceContent(this.response.examples, this.response.mediaTypes)
content = this.response.responseType.toReferenceContent(
examples = this.response.examples,
mediaTypes = this.response.mediaTypes,
enrichment = this.response.typeEnrichment
)
)
).plus(this.errors.toResponseMap())
)
@ -131,22 +148,31 @@ object Helpers {
private fun List<ResponseInfo>.toResponseMap(): Map<Int, Response> = associate { error ->
error.responseCode.value to Response(
description = error.description,
content = error.responseType.toReferenceContent(error.examples, error.mediaTypes)
content = error.responseType.toReferenceContent(
examples = error.examples,
mediaTypes = error.mediaTypes,
enrichment = error.typeEnrichment
)
)
}
private fun KType.toReferenceContent(
examples: Map<String, MediaType.Example>?,
mediaTypes: Set<String>
mediaTypes: Set<String>,
enrichment: TypeEnrichment<*>?
): Map<String, MediaType>? =
when (this.classifier as KClass<*>) {
Unit::class -> null
else -> mediaTypes.associateWith {
MediaType(
schema = if (this.isMarkedNullable) OneOfDefinition(
schema = if (this.isMarkedNullable) {
OneOfDefinition(
NullableDefinition(),
ReferenceDefinition(this.getReferenceSlug())
) else ReferenceDefinition(this.getReferenceSlug()),
ReferenceDefinition(this.getReferenceSlug(enrichment))
)
} else {
ReferenceDefinition(this.getReferenceSlug(enrichment))
},
examples = examples
)
}

View File

@ -2,6 +2,7 @@ package io.bkbn.kompendium.core
import dev.forst.ktor.apikey.apiKey
import io.bkbn.kompendium.core.fixtures.TestHelpers.openApiTestAllSerializers
import io.bkbn.kompendium.core.util.arrayConstraints
import io.bkbn.kompendium.core.util.complexRequest
import io.bkbn.kompendium.core.util.customAuthConfig
import io.bkbn.kompendium.core.util.customFieldNameResponse
@ -9,6 +10,11 @@ import io.bkbn.kompendium.core.util.dateTimeString
import io.bkbn.kompendium.core.util.defaultAuthConfig
import io.bkbn.kompendium.core.util.defaultField
import io.bkbn.kompendium.core.util.defaultParameter
import io.bkbn.kompendium.core.util.doubleConstraints
import io.bkbn.kompendium.core.util.enrichedComplexGenericType
import io.bkbn.kompendium.core.util.enrichedNestedCollection
import io.bkbn.kompendium.core.util.enrichedSimpleRequest
import io.bkbn.kompendium.core.util.enrichedSimpleResponse
import io.bkbn.kompendium.core.util.exampleParams
import io.bkbn.kompendium.core.util.genericException
import io.bkbn.kompendium.core.util.genericPolymorphicResponse
@ -16,6 +22,7 @@ import io.bkbn.kompendium.core.util.genericPolymorphicResponseMultipleImpls
import io.bkbn.kompendium.core.util.gnarlyGenericResponse
import io.bkbn.kompendium.core.util.headerParameter
import io.bkbn.kompendium.core.util.ignoredFieldsResponse
import io.bkbn.kompendium.core.util.intConstraints
import io.bkbn.kompendium.core.util.multipleAuthStrategies
import io.bkbn.kompendium.core.util.multipleExceptions
import io.bkbn.kompendium.core.util.nestedGenericCollection
@ -52,6 +59,9 @@ import io.bkbn.kompendium.core.util.simpleGenericResponse
import io.bkbn.kompendium.core.util.simplePathParsing
import io.bkbn.kompendium.core.util.simpleRecursive
import io.bkbn.kompendium.core.util.singleException
import io.bkbn.kompendium.core.util.stringConstraints
import io.bkbn.kompendium.core.util.stringContentEncodingConstraints
import io.bkbn.kompendium.core.util.stringPatternConstraints
import io.bkbn.kompendium.core.util.topLevelNullable
import io.bkbn.kompendium.core.util.trailingSlash
import io.bkbn.kompendium.core.util.unbackedFieldsResponse
@ -297,16 +307,23 @@ class KompendiumTest : DescribeSpec({
}
it("Throws an exception when same method for same path has been previously registered") {
val exception = shouldThrow<IllegalArgumentException> {
openApiTestAllSerializers("") {
openApiTestAllSerializers(
snapshotName = "",
applicationSetup = {
install(Authentication) {
basic("basic") {
realm = "Ktor Server"
validate { UserIdPrincipal("Placeholder") }
}
}
},
) {
samePathSameMethod()
}
}
exception.message should startWith("A route has already been registered for path: /test/{a} and method: GET")
}
}
describe("Constraints") {
// TODO Assess strategies here
}
describe("Formats") {
it("Can set a format for a simple type schema") {
openApiTestAllSerializers(
@ -414,4 +431,40 @@ class KompendiumTest : DescribeSpec({
) { multipleAuthStrategies() }
}
}
describe("Enrichment") {
it("Can enrich a simple request") {
openApiTestAllSerializers("T0055__enriched_simple_request.json") { enrichedSimpleRequest() }
}
it("Can enrich a simple response") {
openApiTestAllSerializers("T0058__enriched_simple_response.json") { enrichedSimpleResponse() }
}
it("Can enrich a nested collection") {
openApiTestAllSerializers("T0056__enriched_nested_collection.json") { enrichedNestedCollection() }
}
it("Can enrich a complex generic type") {
openApiTestAllSerializers("T0057__enriched_complex_generic_type.json") { enrichedComplexGenericType() }
}
}
describe("Constraints") {
it("Can apply constraints to an int field") {
openApiTestAllSerializers("T0059__int_constraints.json") { intConstraints() }
}
it("Can apply constraints to a double field") {
openApiTestAllSerializers("T0060__double_constraints.json") { doubleConstraints() }
}
it("Can apply a min and max length to a string field") {
openApiTestAllSerializers("T0061__string_min_max_constraints.json") { stringConstraints() }
}
it("Can apply a pattern to a string field") {
openApiTestAllSerializers("T0062__string_pattern_constraints.json") { stringPatternConstraints() }
}
it("Can apply a content encoding and media type to a string field") {
openApiTestAllSerializers("T0063__string_content_encoding_constraints.json") {
stringContentEncodingConstraints()
}
}
it("Can apply constraints to an array field") {
openApiTestAllSerializers("T0064__array_constraints.json") { arrayConstraints() }
}
}
})

View File

@ -0,0 +1,160 @@
package io.bkbn.kompendium.core.util
import io.bkbn.kompendium.core.fixtures.DoubleResponse
import io.bkbn.kompendium.core.fixtures.Page
import io.bkbn.kompendium.core.fixtures.TestCreatedResponse
import io.bkbn.kompendium.core.fixtures.TestNested
import io.bkbn.kompendium.core.metadata.GetInfo
import io.bkbn.kompendium.core.plugin.NotarizedRoute
import io.bkbn.kompendium.core.util.TestModules.defaultPath
import io.bkbn.kompendium.enrichment.TypeEnrichment
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.install
import io.ktor.server.routing.Routing
import io.ktor.server.routing.route
fun Routing.intConstraints() {
route(defaultPath) {
install(NotarizedRoute()) {
get = GetInfo.builder {
summary("Get an int")
description("Get an int")
response {
responseCode(HttpStatusCode.OK)
description("An int")
responseType(
enrichment = TypeEnrichment("example") {
TestCreatedResponse::id {
minimum = 2
maximum = 100
multipleOf = 2
}
}
)
responseCode(HttpStatusCode.OK)
}
}
}
}
}
fun Routing.doubleConstraints() {
route(defaultPath) {
install(NotarizedRoute()) {
get = GetInfo.builder {
summary("Get a double")
description("Get a double")
response {
responseCode(HttpStatusCode.OK)
description("A double")
responseType(
enrichment = TypeEnrichment("example") {
DoubleResponse::payload {
minimum = 2.0
maximum = 100.0
multipleOf = 2.0
}
}
)
responseCode(HttpStatusCode.OK)
}
}
}
}
}
fun Routing.stringConstraints() {
route(defaultPath) {
install(NotarizedRoute()) {
get = GetInfo.builder {
summary("Get a string")
description("Get a string with constraints")
response {
responseCode(HttpStatusCode.OK)
description("A string")
responseType(
enrichment = TypeEnrichment("example") {
TestNested::nesty {
maxLength = 10
minLength = 2
}
}
)
responseCode(HttpStatusCode.OK)
}
}
}
}
}
fun Routing.stringPatternConstraints() {
route(defaultPath) {
install(NotarizedRoute()) {
get = GetInfo.builder {
summary("Get a string")
description("This is a description")
response {
responseCode(HttpStatusCode.OK)
description("A string")
responseType(
enrichment = TypeEnrichment("example") {
TestNested::nesty {
pattern = "[a-z]+"
}
}
)
responseCode(HttpStatusCode.OK)
}
}
}
}
}
fun Routing.stringContentEncodingConstraints() {
route(defaultPath) {
install(NotarizedRoute()) {
get = GetInfo.builder {
summary("Get a string")
description("This is a description")
response {
responseCode(HttpStatusCode.OK)
description("A string")
responseType(
enrichment = TypeEnrichment("example") {
TestNested::nesty {
contentEncoding = "base64"
contentMediaType = "image/png"
}
}
)
responseCode(HttpStatusCode.OK)
}
}
}
}
}
fun Routing.arrayConstraints() {
route(defaultPath) {
install(NotarizedRoute()) {
get = GetInfo.builder {
summary("Get an array")
description("Get an array of strings")
response {
responseCode(HttpStatusCode.OK)
description("An array")
responseType(
enrichment = TypeEnrichment("example") {
Page<String>::content {
minItems = 2
maxItems = 10
uniqueItems = true
}
}
)
responseCode(HttpStatusCode.OK)
}
}
}
}
}

View File

@ -0,0 +1,137 @@
package io.bkbn.kompendium.core.util
import io.bkbn.kompendium.core.fixtures.ComplexRequest
import io.bkbn.kompendium.core.fixtures.MultiNestedGenerics
import io.bkbn.kompendium.core.fixtures.NestedComplexItem
import io.bkbn.kompendium.core.fixtures.TestCreatedResponse
import io.bkbn.kompendium.core.fixtures.TestResponse
import io.bkbn.kompendium.core.fixtures.TestSimpleRequest
import io.bkbn.kompendium.core.metadata.GetInfo
import io.bkbn.kompendium.core.metadata.PostInfo
import io.bkbn.kompendium.core.plugin.NotarizedRoute
import io.bkbn.kompendium.enrichment.TypeEnrichment
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.install
import io.ktor.server.routing.Routing
import io.ktor.server.routing.route
fun Routing.enrichedSimpleResponse() {
route("/enriched") {
install(NotarizedRoute()) {
get = GetInfo.builder {
summary(TestModules.defaultPathSummary)
description(TestModules.defaultPathDescription)
response {
responseType(
enrichment = TypeEnrichment("simple") {
TestResponse::c {
description = "A simple description"
}
}
)
description("A good response")
responseCode(HttpStatusCode.Created)
}
}
}
}
}
fun Routing.enrichedSimpleRequest() {
route("/example") {
install(NotarizedRoute()) {
parameters = TestModules.defaultParams
post = PostInfo.builder {
summary(TestModules.defaultPathSummary)
description(TestModules.defaultPathDescription)
request {
requestType(
enrichment = TypeEnrichment("simple") {
TestSimpleRequest::a {
description = "A simple description"
}
TestSimpleRequest::b {
deprecated = true
}
}
)
description("A test request")
}
response {
responseCode(HttpStatusCode.Created)
responseType<TestCreatedResponse>()
description(TestModules.defaultResponseDescription)
}
}
}
}
}
fun Routing.enrichedNestedCollection() {
route("/example") {
install(NotarizedRoute()) {
parameters = TestModules.defaultParams
post = PostInfo.builder {
summary(TestModules.defaultPathSummary)
description(TestModules.defaultPathDescription)
request {
requestType(
enrichment = TypeEnrichment("simple") {
ComplexRequest::tables {
description = "A nested item"
typeEnrichment = TypeEnrichment("nested") {
NestedComplexItem::name {
description = "A nested description"
}
}
}
}
)
description("A test request")
}
response {
responseCode(HttpStatusCode.Created)
responseType<TestCreatedResponse>()
description(TestModules.defaultResponseDescription)
}
}
}
}
}
fun Routing.enrichedComplexGenericType() {
route("/example") {
install(NotarizedRoute()) {
parameters = TestModules.defaultParams
post = PostInfo.builder {
summary(TestModules.defaultPathSummary)
description(TestModules.defaultPathDescription)
request {
requestType(
enrichment = TypeEnrichment("simple") {
MultiNestedGenerics<String, ComplexRequest>::content {
description = "Getting pretty crazy"
typeEnrichment = TypeEnrichment("nested") {
ComplexRequest::tables {
description = "A nested item"
typeEnrichment = TypeEnrichment("nested") {
NestedComplexItem::name {
description = "A nested description"
}
}
}
}
}
}
)
description("A test request")
}
response {
responseCode(HttpStatusCode.Created)
responseType<TestCreatedResponse>()
description(TestModules.defaultResponseDescription)
}
}
}
}
}

View File

@ -9,7 +9,7 @@ import io.ktor.server.routing.route
fun Routing.samePathSameMethod() {
route(defaultPath) {
basicGetGenerator<TestResponse>()
authenticate {
authenticate("basic") {
basicGetGenerator<TestResponse>()
}
}

View File

@ -0,0 +1,126 @@
{
"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": {
"/example": {
"post": {
"tags": [],
"summary": "Great Summary!",
"description": "testing more",
"parameters": [],
"requestBody": {
"description": "A test request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestSimpleRequest-simple"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestCreatedResponse"
}
}
}
}
},
"deprecated": false
},
"parameters": [
{
"name": "a",
"in": "path",
"schema": {
"type": "string"
},
"required": true,
"deprecated": false
},
{
"name": "aa",
"in": "query",
"schema": {
"type": "number",
"format": "int32"
},
"required": true,
"deprecated": false
}
]
}
},
"webhooks": {},
"components": {
"schemas": {
"TestCreatedResponse": {
"type": "object",
"properties": {
"c": {
"type": "string"
},
"id": {
"type": "number",
"format": "int32"
}
},
"required": [
"c",
"id"
]
},
"TestSimpleRequest-simple": {
"type": "object",
"properties": {
"a": {
"type": "string",
"description": "A simple description"
},
"b": {
"type": "number",
"format": "int32",
"deprecated": true
}
},
"required": [
"a",
"b"
]
}
},
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -0,0 +1,168 @@
{
"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": {
"/example": {
"post": {
"tags": [],
"summary": "Great Summary!",
"description": "testing more",
"parameters": [],
"requestBody": {
"description": "A test request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ComplexRequest-simple"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestCreatedResponse"
}
}
}
}
},
"deprecated": false
},
"parameters": [
{
"name": "a",
"in": "path",
"schema": {
"type": "string"
},
"required": true,
"deprecated": false
},
{
"name": "aa",
"in": "query",
"schema": {
"type": "number",
"format": "int32"
},
"required": true,
"deprecated": false
}
]
}
},
"webhooks": {},
"components": {
"schemas": {
"TestCreatedResponse": {
"type": "object",
"properties": {
"c": {
"type": "string"
},
"id": {
"type": "number",
"format": "int32"
}
},
"required": [
"c",
"id"
]
},
"ComplexRequest-simple": {
"type": "object",
"properties": {
"amazingField": {
"type": "string"
},
"org": {
"type": "string"
},
"tables": {
"items": {
"$ref": "#/components/schemas/NestedComplexItem-nested"
},
"description": "A nested item",
"type": "array"
}
},
"required": [
"amazingField",
"org",
"tables"
]
},
"NestedComplexItem-nested": {
"type": "object",
"properties": {
"alias": {
"additionalProperties": {
"$ref": "#/components/schemas/CrazyItem"
},
"type": "object"
},
"name": {
"type": "string",
"description": "A nested description"
}
},
"required": [
"alias",
"name"
]
},
"CrazyItem": {
"type": "object",
"properties": {
"enumeration": {
"$ref": "#/components/schemas/SimpleEnum"
}
},
"required": [
"enumeration"
]
},
"SimpleEnum": {
"type": "string",
"enum": [
"ONE",
"TWO"
]
}
},
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -0,0 +1,183 @@
{
"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": {
"/example": {
"post": {
"tags": [],
"summary": "Great Summary!",
"description": "testing more",
"parameters": [],
"requestBody": {
"description": "A test request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MultiNestedGenerics-String-ComplexRequest-simple"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestCreatedResponse"
}
}
}
}
},
"deprecated": false
},
"parameters": [
{
"name": "a",
"in": "path",
"schema": {
"type": "string"
},
"required": true,
"deprecated": false
},
{
"name": "aa",
"in": "query",
"schema": {
"type": "number",
"format": "int32"
},
"required": true,
"deprecated": false
}
]
}
},
"webhooks": {},
"components": {
"schemas": {
"TestCreatedResponse": {
"type": "object",
"properties": {
"c": {
"type": "string"
},
"id": {
"type": "number",
"format": "int32"
}
},
"required": [
"c",
"id"
]
},
"MultiNestedGenerics-String-ComplexRequest-simple": {
"type": "object",
"properties": {
"content": {
"additionalProperties": {
"$ref": "#/components/schemas/ComplexRequest-nested"
},
"description": "Getting pretty crazy",
"type": "object"
}
},
"required": [
"content"
]
},
"ComplexRequest-nested": {
"type": "object",
"properties": {
"amazingField": {
"type": "string"
},
"org": {
"type": "string"
},
"tables": {
"items": {
"$ref": "#/components/schemas/NestedComplexItem-nested"
},
"description": "A nested item",
"type": "array"
}
},
"required": [
"amazingField",
"org",
"tables"
]
},
"NestedComplexItem-nested": {
"type": "object",
"properties": {
"alias": {
"additionalProperties": {
"$ref": "#/components/schemas/CrazyItem"
},
"type": "object"
},
"name": {
"type": "string",
"description": "A nested description"
}
},
"required": [
"alias",
"name"
]
},
"CrazyItem": {
"type": "object",
"properties": {
"enumeration": {
"$ref": "#/components/schemas/SimpleEnum"
}
},
"required": [
"enumeration"
]
},
"SimpleEnum": {
"type": "string",
"enum": [
"ONE",
"TWO"
]
}
},
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

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

View File

@ -0,0 +1,80 @@
{
"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": {
"/test/{a}": {
"get": {
"tags": [],
"summary": "Get an int",
"description": "Get an int",
"parameters": [],
"responses": {
"200": {
"description": "An int",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestCreatedResponse-example"
}
}
}
}
},
"deprecated": false
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"TestCreatedResponse-example": {
"type": "object",
"properties": {
"c": {
"type": "string"
},
"id": {
"type": "number",
"format": "int32",
"multipleOf": 2,
"maximum": 100,
"minimum": 2
}
},
"required": [
"c",
"id"
]
}
},
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -0,0 +1,76 @@
{
"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": {
"/test/{a}": {
"get": {
"tags": [],
"summary": "Get a double",
"description": "Get a double",
"parameters": [],
"responses": {
"200": {
"description": "A double",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DoubleResponse-example"
}
}
}
}
},
"deprecated": false
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"DoubleResponse-example": {
"type": "object",
"properties": {
"payload": {
"type": "number",
"format": "double",
"multipleOf": 2.0,
"maximum": 100.0,
"minimum": 2.0
}
},
"required": [
"payload"
]
}
},
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -0,0 +1,74 @@
{
"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": {
"/test/{a}": {
"get": {
"tags": [],
"summary": "Get a string",
"description": "Get a string with constraints",
"parameters": [],
"responses": {
"200": {
"description": "A string",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestNested-example"
}
}
}
}
},
"deprecated": false
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"TestNested-example": {
"type": "object",
"properties": {
"nesty": {
"type": "string",
"maxLength": 10,
"minLength": 2
}
},
"required": [
"nesty"
]
}
},
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -0,0 +1,73 @@
{
"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": {
"/test/{a}": {
"get": {
"tags": [],
"summary": "Get a string",
"description": "This is a description",
"parameters": [],
"responses": {
"200": {
"description": "A string",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestNested-example"
}
}
}
}
},
"deprecated": false
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"TestNested-example": {
"type": "object",
"properties": {
"nesty": {
"type": "string",
"pattern": "[a-z]+"
}
},
"required": [
"nesty"
]
}
},
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -0,0 +1,74 @@
{
"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": {
"/test/{a}": {
"get": {
"tags": [],
"summary": "Get a string",
"description": "This is a description",
"parameters": [],
"responses": {
"200": {
"description": "A string",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestNested-example"
}
}
}
}
},
"deprecated": false
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"TestNested-example": {
"type": "object",
"properties": {
"nesty": {
"type": "string",
"contentEncoding": "base64",
"contentMediaType": "image/png"
}
},
"required": [
"nesty"
]
}
},
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -0,0 +1,103 @@
{
"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": {
"/test/{a}": {
"get": {
"tags": [],
"summary": "Get an array",
"description": "Get an array of strings",
"parameters": [],
"responses": {
"200": {
"description": "An array",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Page-String-example"
}
}
}
}
},
"deprecated": false
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"Page-String-example": {
"type": "object",
"properties": {
"content": {
"items": {
"type": "string"
},
"maxItems": 10,
"minItems": 2,
"uniqueItems": true,
"type": "array"
},
"number": {
"type": "number",
"format": "int32"
},
"numberOfElements": {
"type": "number",
"format": "int32"
},
"size": {
"type": "number",
"format": "int32"
},
"totalElements": {
"type": "number",
"format": "int64"
},
"totalPages": {
"type": "number",
"format": "int32"
}
},
"required": [
"content",
"number",
"numberOfElements",
"size",
"totalElements",
"totalPages"
]
}
},
"securitySchemes": {}
},
"security": [],
"tags": []
}

View File

@ -12,6 +12,9 @@ import java.time.Instant
@Serializable
data class TestNested(val nesty: String)
@Serializable
data class DoubleResponse(val payload: Double)
@Serializable
data class TestRequest(
val fieldName: TestNested,

View File

@ -3,6 +3,8 @@
* [Introduction](index.md)
* [Helpers](helpers/index.md)
* [Protobuf java converter](helpers/protobuf_java_converter.md)
* [Concepts](concepts/index.md)
* [Enrichment](concepts/enrichment.md)
* [Plugins](plugins/index.md)
* [Notarized Application](plugins/notarized_application.md)
* [Notarized Route](plugins/notarized_route.md)

107
docs/concepts/enrichment.md Normal file
View File

@ -0,0 +1,107 @@
Kompendium allows users to enrich their data types with additional information. This can be done by defining a
`TypeEnrichment` object and passing it to the `enrich` function on the `NotarizedRoute` builder. Enrichments
can be added to any request or response.
```kotlin
data class SimpleData(val a: String, val b: Int? = null)
val myEnrichment = TypeEnrichment<SimpleData>(id = "simple-enrichment") {
SimpleData::a {
description = "This will update the field description"
}
SimpleData::b {
// Will indicate in the UI that the field will be removed soon
deprecated = true
}
}
// In your route documentation
fun Routing.enrichedSimpleRequest() {
route("/example") {
install(NotarizedRoute()) {
parameters = TestModules.defaultParams
post = PostInfo.builder {
summary(TestModules.defaultPathSummary)
description(TestModules.defaultPathDescription)
request {
requestType<SimpleData>(enrichment = myEnrichment) // Simply attach the enrichment to the request
description("A test request")
}
response {
responseCode(HttpStatusCode.Created)
responseType<TestCreatedResponse>()
description(TestModules.defaultResponseDescription)
}
}
}
}
}
```
{% hint style="warning" %}
An enrichment must provide an `id` field that is unique to the data class that is being enriched. This is because
under the hood, Kompendium appends this id to the data class identifier in order to support multiple different
enrichments
on the same data class.
If you provide duplicate ids, all but the first enrichment will be ignored, as Kompendium will view that as a cache hit,
and skip analyzing the new enrichment.
{% endhint %}
### Nested Enrichments
Enrichments are portable and composable, meaning that we can take an enrichment for a child data class
and apply it inside a parent data class using the `typeEnrichment` property.
```kotlin
data class ParentData(val a: String, val b: ChildData)
data class ChildData(val c: String, val d: Int? = null)
val childEnrichment = TypeEnrichment<ChildData>(id = "child-enrichment") {
ChildData::c {
description = "This will update the field description of field c on child data"
}
ChildData::d {
description = "This will update the field description of field d on child data"
}
}
val parentEnrichment = TypeEnrichment<ParentData>(id = "parent-enrichment") {
ParentData::a {
description = "This will update the field description"
}
ParentData::b {
description = "This will update the field description of field b on parent data"
typeEnrichment = childEnrichment // Will apply the child enrichment to the internals of field b
}
}
```
## Available Enrichments
All enrichments support the following properties:
- description -> Provides a reader friendly description of the field in the object
- deprecated -> Indicates that the field is deprecated and should not be used
### String
- minLength -> The minimum length of the string
- maxLength -> The maximum length of the string
- pattern -> A regex pattern that the string must match
- contentEncoding -> The encoding of the string
- contentMediaType -> The media type of the string
### Numbers
- minimum -> The minimum value of the number
- maximum -> The maximum value of the number
- exclusiveMinimum -> Indicates that the minimum value is exclusive
- exclusiveMaximum -> Indicates that the maximum value is exclusive
- multipleOf -> Indicates that the number must be a multiple of the provided value
### Arrays
- minItems -> The minimum number of items in the array
- maxItems -> The maximum number of items in the array
- uniqueItems -> Indicates that the array must contain unique items

2
docs/concepts/index.md Normal file
View File

@ -0,0 +1,2 @@
Various concepts that are core to Kompendium but not necessarily exclusive
to any given module or plugin

View File

@ -0,0 +1,34 @@
plugins {
kotlin("jvm")
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 Type Enrichment")
libraryDescription.set("Utility library for creating portable type enrichments")
compilerArgs.set(listOf("-opt-in=kotlin.RequiresOptIn"))
}
dependencies {
// Versions
val detektVersion: String by project
// Formatting
detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:$detektVersion")
testImplementation(testFixtures(projects.kompendiumCore))
}
testing {
suites {
named("test", JvmTestSuite::class) {
useJUnitJupiter()
}
}
}

View File

@ -0,0 +1,3 @@
package io.bkbn.kompendium.enrichment
sealed interface Enrichment

View File

@ -0,0 +1,36 @@
package io.bkbn.kompendium.enrichment
/**
* Reference https://json-schema.org/draft/2020-12/json-schema-validation.html#name-multipleof
*/
class PropertyEnrichment : Enrichment {
// Metadata
var deprecated: Boolean? = null
var description: String? = null
var typeEnrichment: TypeEnrichment<*>? = null
// Number and Integer Constraints
var multipleOf: Number? = null
var maximum: Number? = null
var exclusiveMaximum: Number? = null
var minimum: Number? = null
var exclusiveMinimum: Number? = null
// String constraints
var maxLength: Int? = null
var minLength: Int? = null
var pattern: String? = null
var contentEncoding: String? = null
var contentMediaType: String? = null
// TODO how to handle contentSchema?
// Array constraints
var maxItems: Int? = null
var minItems: Int? = null
var uniqueItems: Boolean? = null
// TODO How to handle contains, minContains, maxContains?
// Object constraints
var maxProperties: Int? = null
var minProperties: Int? = null
}

View File

@ -0,0 +1,25 @@
package io.bkbn.kompendium.enrichment
import kotlin.reflect.KProperty
import kotlin.reflect.KProperty1
class TypeEnrichment<T>(val id: String) : Enrichment {
private val enrichments: MutableMap<KProperty1<*, *>, Enrichment> = mutableMapOf()
fun getEnrichmentForProperty(property: KProperty<*>): Enrichment? = enrichments[property]
operator fun <R> KProperty1<T, R>.invoke(init: PropertyEnrichment.() -> Unit) {
require(!enrichments.containsKey(this)) { "${this.name} has already been registered" }
val propertyEnrichment = PropertyEnrichment()
init.invoke(propertyEnrichment)
enrichments[this] = propertyEnrichment
}
companion object {
inline operator fun <reified T> invoke(id: String, init: TypeEnrichment<T>.() -> Unit): TypeEnrichment<T> {
val builder = TypeEnrichment<T>(id)
return builder.apply(init)
}
}
}

View File

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

Binary file not shown.

View File

@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

6
gradlew vendored
View File

@ -205,6 +205,12 @@ set -- \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.

14
gradlew.bat vendored
View File

@ -14,7 +14,7 @@
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@ -25,7 +25,7 @@
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
if "%DIRNAME%"=="" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@ -40,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
@ -75,13 +75,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal

View File

@ -20,7 +20,10 @@ dependencies {
// Versions
val detektVersion: String by project
implementation("org.jetbrains.kotlin:kotlin-reflect:1.7.21")
// Kompendium
api(projects.kompendiumEnrichment)
implementation("org.jetbrains.kotlin:kotlin-reflect:1.8.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1")
// Formatting

View File

@ -1,5 +1,6 @@
package io.bkbn.kompendium.json.schema
import io.bkbn.kompendium.enrichment.TypeEnrichment
import io.bkbn.kompendium.json.schema.definition.JsonSchema
import io.bkbn.kompendium.json.schema.definition.NullableDefinition
import io.bkbn.kompendium.json.schema.definition.OneOfDefinition
@ -9,28 +10,26 @@ import io.bkbn.kompendium.json.schema.handler.EnumHandler
import io.bkbn.kompendium.json.schema.handler.MapHandler
import io.bkbn.kompendium.json.schema.handler.SealedObjectHandler
import io.bkbn.kompendium.json.schema.handler.SimpleObjectHandler
import io.bkbn.kompendium.json.schema.util.Helpers.getSimpleSlug
import io.bkbn.kompendium.json.schema.util.Helpers.getSlug
import java.util.UUID
import kotlin.reflect.KClass
import kotlin.reflect.KType
import kotlin.reflect.full.isSubclassOf
import kotlin.reflect.typeOf
import java.util.UUID
object SchemaGenerator {
inline fun <reified T : Any?> fromTypeToSchema(
cache: MutableMap<String, JsonSchema> = mutableMapOf(),
schemaConfigurator: SchemaConfigurator = SchemaConfigurator.Default()
) = fromTypeToSchema(typeOf<T>(), cache, schemaConfigurator)
fun fromTypeToSchema(
type: KType,
cache: MutableMap<String, JsonSchema>,
schemaConfigurator: SchemaConfigurator
schemaConfigurator: SchemaConfigurator,
enrichment: TypeEnrichment<*>? = null
): JsonSchema {
cache[type.getSimpleSlug()]?.let {
val slug = type.getSlug(enrichment)
cache[slug]?.let {
return it
}
return when (val clazz = type.classifier as KClass<*>) {
Unit::class -> error(
"""
@ -48,14 +47,14 @@ object SchemaGenerator {
Boolean::class -> checkForNull(type, TypeDefinition.BOOLEAN)
UUID::class -> checkForNull(type, TypeDefinition.UUID)
else -> when {
clazz.isSubclassOf(Enum::class) -> EnumHandler.handle(type, clazz, cache)
clazz.isSubclassOf(Collection::class) -> CollectionHandler.handle(type, cache, schemaConfigurator)
clazz.isSubclassOf(Map::class) -> MapHandler.handle(type, cache, schemaConfigurator)
clazz.isSubclassOf(Enum::class) -> EnumHandler.handle(type, clazz, cache, enrichment)
clazz.isSubclassOf(Collection::class) -> CollectionHandler.handle(type, cache, schemaConfigurator, enrichment)
clazz.isSubclassOf(Map::class) -> MapHandler.handle(type, cache, schemaConfigurator, enrichment)
else -> {
if (clazz.isSealed) {
SealedObjectHandler.handle(type, clazz, cache, schemaConfigurator)
SealedObjectHandler.handle(type, clazz, cache, schemaConfigurator, enrichment)
} else {
SimpleObjectHandler.handle(type, clazz, cache, schemaConfigurator)
SimpleObjectHandler.handle(type, clazz, cache, schemaConfigurator, enrichment)
}
}
}
@ -65,11 +64,12 @@ object SchemaGenerator {
fun fromTypeOrUnit(
type: KType,
cache: MutableMap<String, JsonSchema> = mutableMapOf(),
schemaConfigurator: SchemaConfigurator
schemaConfigurator: SchemaConfigurator,
enrichment: TypeEnrichment<*>? = null
): JsonSchema? =
when (type.classifier as KClass<*>) {
Unit::class -> null
else -> fromTypeToSchema(type, cache, schemaConfigurator)
else -> fromTypeToSchema(type, cache, schemaConfigurator, enrichment)
}
private fun checkForNull(type: KType, schema: JsonSchema): JsonSchema = when (type.isMarkedNullable) {

View File

@ -3,4 +3,8 @@ package io.bkbn.kompendium.json.schema.definition
import kotlinx.serialization.Serializable
@Serializable
data class AnyOfDefinition(val anyOf: Set<JsonSchema>) : JsonSchema
data class AnyOfDefinition(
val anyOf: Set<JsonSchema>,
override val deprecated: Boolean? = null,
override val description: String? = null,
) : JsonSchema

View File

@ -4,7 +4,14 @@ import kotlinx.serialization.Serializable
@Serializable
data class ArrayDefinition(
val items: JsonSchema
val items: JsonSchema,
override val deprecated: Boolean? = null,
override val description: String? = null,
// Constraints
val maxItems: Int? = null,
val minItems: Int? = null,
val uniqueItems: Boolean? = null,
) : JsonSchema {
val type: String = "array"
}

View File

@ -5,5 +5,7 @@ import kotlinx.serialization.Serializable
@Serializable
data class EnumDefinition(
val type: String,
val enum: Set<String>
val enum: Set<String>,
override val deprecated: Boolean? = null,
override val description: String? = null,
) : JsonSchema

View File

@ -11,6 +11,9 @@ import kotlinx.serialization.encoding.Encoder
@Serializable(with = JsonSchema.Serializer::class)
sealed interface JsonSchema {
val description: String?
val deprecated: Boolean?
object Serializer : KSerializer<JsonSchema> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("JsonSchema", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): JsonSchema {

View File

@ -4,7 +4,9 @@ import kotlinx.serialization.Serializable
@Serializable
data class MapDefinition(
val additionalProperties: JsonSchema
val additionalProperties: JsonSchema,
override val deprecated: Boolean? = null,
override val description: String? = null,
) : JsonSchema {
val type: String = "object"
}

View File

@ -3,4 +3,8 @@ package io.bkbn.kompendium.json.schema.definition
import kotlinx.serialization.Serializable
@Serializable
data class NullableDefinition(val type: String = "null") : JsonSchema
data class NullableDefinition(
val type: String = "null",
override val deprecated: Boolean? = null,
override val description: String? = null,
) : JsonSchema

View File

@ -3,6 +3,10 @@ package io.bkbn.kompendium.json.schema.definition
import kotlinx.serialization.Serializable
@Serializable
data class OneOfDefinition(val oneOf: Set<JsonSchema>) : JsonSchema {
data class OneOfDefinition(
val oneOf: Set<JsonSchema>,
override val deprecated: Boolean? = null,
override val description: String? = null,
) : JsonSchema {
constructor(vararg types: JsonSchema) : this(types.toSet())
}

View File

@ -3,4 +3,8 @@ package io.bkbn.kompendium.json.schema.definition
import kotlinx.serialization.Serializable
@Serializable
data class ReferenceDefinition(val `$ref`: String) : JsonSchema
data class ReferenceDefinition(
val `$ref`: String,
override val deprecated: Boolean? = null,
override val description: String? = null,
) : JsonSchema

View File

@ -1,5 +1,6 @@
package io.bkbn.kompendium.json.schema.definition
import io.bkbn.kompendium.json.schema.util.Serializers
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
@ -7,10 +8,35 @@ import kotlinx.serialization.Serializable
data class TypeDefinition(
val type: String,
val format: String? = null,
val description: String? = null,
val properties: Map<String, JsonSchema>? = null,
val required: Set<String>? = null,
@Contextual val default: Any? = null,
override val deprecated: Boolean? = null,
override val description: String? = null,
// Constraints
// Number
@Serializable(with = Serializers.Number::class)
val multipleOf: Number? = null,
@Serializable(with = Serializers.Number::class)
val maximum: Number? = null,
@Serializable(with = Serializers.Number::class)
val exclusiveMaximum: Number? = null,
@Serializable(with = Serializers.Number::class)
val minimum: Number? = null,
@Serializable(with = Serializers.Number::class)
val exclusiveMinimum: Number? = null,
// String
val maxLength: Int? = null,
val minLength: Int? = null,
val pattern: String? = null,
val contentEncoding: String? = null,
val contentMediaType: String? = null,
// Object
val maxProperties: Int? = null,
val minProperties: Int? = null,
) : JsonSchema {
fun withDefault(default: Any): TypeDefinition = this.copy(default = default)

View File

@ -1,5 +1,6 @@
package io.bkbn.kompendium.json.schema.handler
import io.bkbn.kompendium.enrichment.TypeEnrichment
import io.bkbn.kompendium.json.schema.SchemaConfigurator
import io.bkbn.kompendium.json.schema.SchemaGenerator
import io.bkbn.kompendium.json.schema.definition.ArrayDefinition
@ -9,17 +10,22 @@ import io.bkbn.kompendium.json.schema.definition.OneOfDefinition
import io.bkbn.kompendium.json.schema.definition.ReferenceDefinition
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
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.getSlug
import kotlin.reflect.KType
object CollectionHandler {
fun handle(type: KType, cache: MutableMap<String, JsonSchema>, schemaConfigurator: SchemaConfigurator): JsonSchema {
fun handle(
type: KType,
cache: MutableMap<String, JsonSchema>,
schemaConfigurator: SchemaConfigurator,
enrichment: TypeEnrichment<*>? = null
): JsonSchema {
val collectionType = type.arguments.first().type
?: error("This indicates a bug in Kompendium, please open a GitHub issue!")
val typeSchema = SchemaGenerator.fromTypeToSchema(collectionType, cache, schemaConfigurator).let {
val typeSchema = SchemaGenerator.fromTypeToSchema(collectionType, cache, schemaConfigurator, enrichment).let {
if (it is TypeDefinition && it.type == "object") {
cache[collectionType.getSimpleSlug()] = it
ReferenceDefinition(collectionType.getReferenceSlug())
cache[collectionType.getSlug(enrichment)] = it
ReferenceDefinition(collectionType.getReferenceSlug(enrichment))
} else {
it
}

View File

@ -1,16 +1,22 @@
package io.bkbn.kompendium.json.schema.handler
import io.bkbn.kompendium.enrichment.TypeEnrichment
import io.bkbn.kompendium.json.schema.definition.EnumDefinition
import io.bkbn.kompendium.json.schema.definition.JsonSchema
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.getSimpleSlug
import io.bkbn.kompendium.json.schema.util.Helpers.getSlug
import kotlin.reflect.KClass
import kotlin.reflect.KType
object EnumHandler {
fun handle(type: KType, clazz: KClass<*>, cache: MutableMap<String, JsonSchema>): JsonSchema {
cache[type.getSimpleSlug()] = ReferenceDefinition(type.getReferenceSlug())
fun handle(
type: KType,
clazz: KClass<*>,
cache: MutableMap<String, JsonSchema>,
enrichment: TypeEnrichment<*>? = null
): JsonSchema {
cache[type.getSlug(enrichment)] = ReferenceDefinition(type.getReferenceSlug(enrichment))
val options = clazz.java.enumConstants.map { it.toString() }.toSet()
return EnumDefinition(type = "string", enum = options)

View File

@ -1,5 +1,6 @@
package io.bkbn.kompendium.json.schema.handler
import io.bkbn.kompendium.enrichment.TypeEnrichment
import io.bkbn.kompendium.json.schema.SchemaConfigurator
import io.bkbn.kompendium.json.schema.SchemaGenerator
import io.bkbn.kompendium.json.schema.definition.JsonSchema
@ -9,21 +10,26 @@ import io.bkbn.kompendium.json.schema.definition.OneOfDefinition
import io.bkbn.kompendium.json.schema.definition.ReferenceDefinition
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
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.getSlug
import kotlin.reflect.KClass
import kotlin.reflect.KType
object MapHandler {
fun handle(type: KType, cache: MutableMap<String, JsonSchema>, schemaConfigurator: SchemaConfigurator): JsonSchema {
fun handle(
type: KType,
cache: MutableMap<String, JsonSchema>,
schemaConfigurator: SchemaConfigurator,
enrichment: TypeEnrichment<*>? = null
): JsonSchema {
require(type.arguments.first().type?.classifier as KClass<*> == String::class) {
"JSON requires that map keys MUST be Strings. You provided ${type.arguments.first().type}"
}
val valueType = type.arguments[1].type ?: error("this indicates a bug in Kompendium, please open a GitHub issue")
val valueSchema = SchemaGenerator.fromTypeToSchema(valueType, cache, schemaConfigurator).let {
val valueSchema = SchemaGenerator.fromTypeToSchema(valueType, cache, schemaConfigurator, enrichment).let {
if (it is TypeDefinition && it.type == "object") {
cache[valueType.getSimpleSlug()] = it
ReferenceDefinition(valueType.getReferenceSlug())
cache[valueType.getSlug(enrichment)] = it
ReferenceDefinition(valueType.getReferenceSlug(enrichment))
} else {
it
}

View File

@ -1,5 +1,6 @@
package io.bkbn.kompendium.json.schema.handler
import io.bkbn.kompendium.enrichment.TypeEnrichment
import io.bkbn.kompendium.json.schema.SchemaConfigurator
import io.bkbn.kompendium.json.schema.SchemaGenerator
import io.bkbn.kompendium.json.schema.definition.AnyOfDefinition
@ -7,7 +8,7 @@ import io.bkbn.kompendium.json.schema.definition.JsonSchema
import io.bkbn.kompendium.json.schema.definition.ReferenceDefinition
import io.bkbn.kompendium.json.schema.definition.TypeDefinition
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.getSlug
import kotlin.reflect.KClass
import kotlin.reflect.KType
import kotlin.reflect.full.createType
@ -18,15 +19,17 @@ object SealedObjectHandler {
type: KType,
clazz: KClass<*>,
cache: MutableMap<String, JsonSchema>,
schemaConfigurator: SchemaConfigurator
schemaConfigurator: SchemaConfigurator,
enrichment: TypeEnrichment<*>? = null,
): JsonSchema {
val subclasses = clazz.sealedSubclasses
.map { it.createType(type.arguments) }
.map { t ->
SchemaGenerator.fromTypeToSchema(t, cache, schemaConfigurator).let { js ->
SchemaGenerator.fromTypeToSchema(t, cache, schemaConfigurator, enrichment).let { js ->
if (js is TypeDefinition && js.type == "object") {
cache[t.getSimpleSlug()] = js
ReferenceDefinition(t.getReferenceSlug())
val slug = t.getSlug(enrichment)
cache[slug] = js
ReferenceDefinition(t.getReferenceSlug(enrichment))
} else {
js
}

View File

@ -1,16 +1,21 @@
package io.bkbn.kompendium.json.schema.handler
import io.bkbn.kompendium.enrichment.PropertyEnrichment
import io.bkbn.kompendium.enrichment.TypeEnrichment
import io.bkbn.kompendium.json.schema.SchemaConfigurator
import io.bkbn.kompendium.json.schema.SchemaGenerator
import io.bkbn.kompendium.json.schema.definition.AnyOfDefinition
import io.bkbn.kompendium.json.schema.definition.ArrayDefinition
import io.bkbn.kompendium.json.schema.definition.EnumDefinition
import io.bkbn.kompendium.json.schema.definition.JsonSchema
import io.bkbn.kompendium.json.schema.definition.MapDefinition
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.TypeDefinition
import io.bkbn.kompendium.json.schema.exception.UnknownSchemaException
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.getSlug
import kotlin.reflect.KClass
import kotlin.reflect.KProperty
import kotlin.reflect.KType
@ -26,26 +31,34 @@ object SimpleObjectHandler {
type: KType,
clazz: KClass<*>,
cache: MutableMap<String, JsonSchema>,
schemaConfigurator: SchemaConfigurator
schemaConfigurator: SchemaConfigurator,
enrichment: TypeEnrichment<*>?,
): JsonSchema {
cache[type.getSimpleSlug()] = ReferenceDefinition(type.getReferenceSlug())
cache[type.getSlug(enrichment)] = ReferenceDefinition(type.getReferenceSlug(enrichment))
val typeMap = clazz.typeParameters.zip(type.arguments).toMap()
val props = schemaConfigurator.serializableMemberProperties(clazz)
.filterNot { it.javaField == null }
.associate { prop ->
val propTypeEnrichment = when (val pe = enrichment?.getEnrichmentForProperty(prop)) {
is PropertyEnrichment -> pe
else -> null
}
val schema = when (prop.needsToInjectGenerics(typeMap)) {
true -> handleNestedGenerics(typeMap, prop, cache, schemaConfigurator)
true -> handleNestedGenerics(typeMap, prop, cache, schemaConfigurator, propTypeEnrichment)
false -> when (typeMap.containsKey(prop.returnType.classifier)) {
true -> handleGenericProperty(prop, typeMap, cache, schemaConfigurator)
false -> handleProperty(prop, cache, schemaConfigurator)
true -> handleGenericProperty(prop, typeMap, cache, schemaConfigurator, propTypeEnrichment)
false -> handleProperty(prop, cache, schemaConfigurator, propTypeEnrichment?.typeEnrichment)
}
}
val nullCheckSchema = when (prop.returnType.isMarkedNullable && !schema.isNullable()) {
true -> OneOfDefinition(NullableDefinition(), schema)
false -> schema
val enrichedSchema = propTypeEnrichment?.applyToSchema(schema) ?: schema
val nullCheckSchema = when (prop.returnType.isMarkedNullable && !enrichedSchema.isNullable()) {
true -> OneOfDefinition(NullableDefinition(), enrichedSchema)
false -> enrichedSchema
}
schemaConfigurator.serializableName(prop) to nullCheckSchema
@ -90,7 +103,8 @@ object SimpleObjectHandler {
typeMap: Map<KTypeParameter, KTypeProjection>,
prop: KProperty<*>,
cache: MutableMap<String, JsonSchema>,
schemaConfigurator: SchemaConfigurator
schemaConfigurator: SchemaConfigurator,
propEnrichment: PropertyEnrichment?
): JsonSchema {
val propClass = prop.returnType.classifier as KClass<*>
val types = prop.returnType.arguments.map {
@ -98,10 +112,11 @@ object SimpleObjectHandler {
typeMap.filterKeys { k -> k.name == typeSymbol }.values.first()
}
val constructedType = propClass.createType(types)
return SchemaGenerator.fromTypeToSchema(constructedType, cache, schemaConfigurator).let {
return SchemaGenerator.fromTypeToSchema(constructedType, cache, schemaConfigurator, propEnrichment?.typeEnrichment)
.let {
if (it.isOrContainsObjectOrEnumDef()) {
cache[constructedType.getSimpleSlug()] = it
ReferenceDefinition(prop.returnType.getReferenceSlug())
cache[constructedType.getSlug(propEnrichment)] = it
ReferenceDefinition(prop.returnType.getReferenceSlug(propEnrichment))
} else {
it
}
@ -112,14 +127,15 @@ object SimpleObjectHandler {
prop: KProperty<*>,
typeMap: Map<KTypeParameter, KTypeProjection>,
cache: MutableMap<String, JsonSchema>,
schemaConfigurator: SchemaConfigurator
schemaConfigurator: SchemaConfigurator,
propEnrichment: PropertyEnrichment?
): JsonSchema {
val type = typeMap[prop.returnType.classifier]?.type
?: 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, propEnrichment?.typeEnrichment).let {
if (it.isOrContainsObjectOrEnumDef()) {
cache[type.getSimpleSlug()] = it
ReferenceDefinition(type.getReferenceSlug())
cache[type.getSlug(propEnrichment)] = it
ReferenceDefinition(type.getReferenceSlug(propEnrichment))
} else {
it
}
@ -129,12 +145,13 @@ object SimpleObjectHandler {
private fun handleProperty(
prop: KProperty<*>,
cache: MutableMap<String, JsonSchema>,
schemaConfigurator: SchemaConfigurator
schemaConfigurator: SchemaConfigurator,
propEnrichment: TypeEnrichment<*>?
): JsonSchema =
SchemaGenerator.fromTypeToSchema(prop.returnType, cache, schemaConfigurator).let {
SchemaGenerator.fromTypeToSchema(prop.returnType, cache, schemaConfigurator, propEnrichment).let {
if (it.isOrContainsObjectOrEnumDef()) {
cache[prop.returnType.getSimpleSlug()] = it
ReferenceDefinition(prop.returnType.getReferenceSlug())
cache[prop.returnType.getSlug(propEnrichment)] = it
ReferenceDefinition(prop.returnType.getReferenceSlug(propEnrichment))
} else {
it
}
@ -149,4 +166,36 @@ object SimpleObjectHandler {
}
private fun JsonSchema.isNullable(): Boolean = this is OneOfDefinition && this.oneOf.any { it is NullableDefinition }
private fun PropertyEnrichment.applyToSchema(schema: JsonSchema): JsonSchema = when (schema) {
is AnyOfDefinition -> schema.copy(deprecated = deprecated, description = description)
is ArrayDefinition -> schema.copy(
deprecated = deprecated,
description = description,
minItems = minItems,
maxItems = maxItems,
uniqueItems = uniqueItems,
)
is EnumDefinition -> schema.copy(deprecated = deprecated, description = description)
is MapDefinition -> schema.copy(deprecated = deprecated, description = description)
is NullableDefinition -> schema.copy(deprecated = deprecated, description = description)
is OneOfDefinition -> schema.copy(deprecated = deprecated, description = description)
is ReferenceDefinition -> schema.copy(deprecated = deprecated, description = description)
is TypeDefinition -> schema.copy(
deprecated = deprecated,
description = description,
multipleOf = multipleOf,
maximum = maximum,
exclusiveMaximum = exclusiveMaximum,
minimum = minimum,
exclusiveMinimum = exclusiveMinimum,
maxLength = maxLength,
minLength = minLength,
pattern = pattern,
contentEncoding = contentEncoding,
contentMediaType = contentMediaType,
maxProperties = maxProperties,
minProperties = minProperties,
)
}
}

View File

@ -1,5 +1,8 @@
package io.bkbn.kompendium.json.schema.util
import io.bkbn.kompendium.enrichment.Enrichment
import io.bkbn.kompendium.enrichment.PropertyEnrichment
import io.bkbn.kompendium.enrichment.TypeEnrichment
import kotlin.reflect.KClass
import kotlin.reflect.KType
@ -7,12 +10,26 @@ object Helpers {
private const val COMPONENT_SLUG = "#/components/schemas"
fun KType.getSlug(enrichment: Enrichment? = null) = when (enrichment) {
is TypeEnrichment<*> -> getEnrichedSlug(enrichment)
is PropertyEnrichment -> error("Slugs should not be generated for field enrichments")
null -> getSimpleSlug()
}
fun KType.getSimpleSlug(): String = when {
this.arguments.isNotEmpty() -> genericNameAdapter(this, classifier as KClass<*>)
else -> (classifier as KClass<*>).kompendiumSlug() ?: error("Could not determine simple name for $this")
}
fun KType.getReferenceSlug(): String = when {
private fun KType.getEnrichedSlug(enrichment: TypeEnrichment<*>) = getSimpleSlug() + "-${enrichment.id}"
fun KType.getReferenceSlug(enrichment: Enrichment? = null): String = when (enrichment) {
is TypeEnrichment<*> -> getSimpleReferenceSlug() + "-${enrichment.id}"
is PropertyEnrichment -> error("Reference slugs should never be generated for field enrichments")
null -> getSimpleReferenceSlug()
}
private fun KType.getSimpleReferenceSlug() = when {
arguments.isNotEmpty() -> "$COMPONENT_SLUG/${genericNameAdapter(this, classifier as KClass<*>)}"
else -> "$COMPONENT_SLUG/${(classifier as KClass<*>).kompendiumSlug()}"
}

View File

@ -0,0 +1,43 @@
package io.bkbn.kompendium.json.schema.util
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.util.UUID
import kotlin.Number as KNumber
object Serializers {
object Uuid : KSerializer<UUID> {
override val descriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): UUID {
return UUID.fromString(decoder.decodeString())
}
override fun serialize(encoder: Encoder, value: UUID) {
encoder.encodeString(value.toString())
}
}
object Number : KSerializer<KNumber> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Number", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): KNumber {
TODO("Not yet implemented")
}
override fun serialize(encoder: Encoder, value: KNumber) {
when (value) {
is Int -> encoder.encodeInt(value)
is Long -> encoder.encodeLong(value)
is Double -> encoder.encodeDouble(value)
is Float -> encoder.encodeFloat(value)
else -> throw IllegalArgumentException("Number is not a valid type")
}
}
}
}

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.FlibbityGibbit
import io.bkbn.kompendium.core.fixtures.NestedComplexItem
import io.bkbn.kompendium.core.fixtures.ObjectWithEnum
import io.bkbn.kompendium.core.fixtures.SerialNameObject
import io.bkbn.kompendium.core.fixtures.SimpleEnum
@ -11,12 +12,14 @@ import io.bkbn.kompendium.core.fixtures.TestResponse
import io.bkbn.kompendium.core.fixtures.TestSimpleRequest
import io.bkbn.kompendium.core.fixtures.TransientObject
import io.bkbn.kompendium.core.fixtures.UnbackedObject
import io.bkbn.kompendium.enrichment.TypeEnrichment
import io.bkbn.kompendium.json.schema.definition.JsonSchema
import io.kotest.assertions.json.shouldEqualJson
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.DescribeSpec
import kotlinx.serialization.json.Json
import java.util.UUID
import kotlin.reflect.typeOf
class SchemaGeneratorTest : DescribeSpec({
describe("Scalars") {
@ -88,7 +91,13 @@ class SchemaGeneratorTest : DescribeSpec({
jsonSchemaTest<Map<String, Int>>("T0012__scalar_map.json")
}
it("Throws an error when map keys are not strings") {
shouldThrow<IllegalArgumentException> { SchemaGenerator.fromTypeToSchema<Map<Int, Int>>() }
shouldThrow<IllegalArgumentException> {
SchemaGenerator.fromTypeToSchema(
typeOf<Map<Int, Int>>(),
cache = mutableMapOf(),
schemaConfigurator = KotlinXSchemaConfigurator()
)
}
}
it("Can generate the schema for a map of objects") {
jsonSchemaTest<Map<String, TestResponse>>("T0013__object_map.json")
@ -97,6 +106,36 @@ class SchemaGeneratorTest : DescribeSpec({
jsonSchemaTest<Map<String, Int>?>("T0014__nullable_map.json")
}
}
describe("Enrichment") {
it("Can attach an enrichment to a simple type") {
jsonSchemaTest<TestSimpleRequest>(
snapshotName = "T0022__enriched_simple_object.json",
enrichment = TypeEnrichment("simple") {
TestSimpleRequest::a {
description = "This is a simple description"
}
TestSimpleRequest::b {
deprecated = true
}
}
)
}
it("Can properly assign a reference to a nested enrichment") {
jsonSchemaTest<ComplexRequest>(
snapshotName = "T0023__enriched_nested_reference.json",
enrichment = TypeEnrichment("example") {
ComplexRequest::tables {
description = "Collection of important items"
typeEnrichment = TypeEnrichment("table") {
NestedComplexItem::name {
description = "The name of the table"
}
}
}
}
)
}
}
}) {
companion object {
private val json = Json {
@ -107,11 +146,14 @@ class SchemaGeneratorTest : DescribeSpec({
private fun JsonSchema.serialize() = json.encodeToString(JsonSchema.serializer(), this)
private inline fun <reified T> jsonSchemaTest(snapshotName: String) {
private inline fun <reified T> jsonSchemaTest(snapshotName: String, enrichment: TypeEnrichment<*>? = null) {
// act
val schema = SchemaGenerator.fromTypeToSchema<T>(schemaConfigurator = KotlinXSchemaConfigurator())
// todo add cache assertions!!!
val schema = SchemaGenerator.fromTypeToSchema(
type = typeOf<T>(),
cache = mutableMapOf(),
schemaConfigurator = KotlinXSchemaConfigurator(),
enrichment = enrichment,
)
// assert
schema.serialize() shouldEqualJson getFileSnapshot(snapshotName)

View File

@ -0,0 +1,18 @@
{
"type": "object",
"properties": {
"a": {
"type": "string",
"description": "This is a simple description"
},
"b": {
"type": "number",
"format": "int32",
"deprecated": true
}
},
"required": [
"a",
"b"
]
}

View File

@ -0,0 +1,23 @@
{
"type": "object",
"properties": {
"amazingField": {
"type": "string"
},
"org": {
"type": "string"
},
"tables": {
"items": {
"$ref": "#/components/schemas/NestedComplexItem-table"
},
"description": "Collection of important items",
"type": "array"
}
},
"required": [
"amazingField",
"org",
"tables"
]
}

View File

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

View File

@ -21,6 +21,7 @@ dependencies {
val detektVersion: String by project
api(projects.kompendiumJsonSchema)
api(projects.kompendiumEnrichment)
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1")
// Formatting

View File

@ -37,12 +37,12 @@ dependencies {
implementation("org.apache.logging.log4j:log4j-api-kotlin:1.2.0")
implementation("org.apache.logging.log4j:log4j-api:2.19.0")
implementation("org.apache.logging.log4j:log4j-core:2.19.0")
implementation("org.slf4j:slf4j-api:2.0.3")
implementation("org.slf4j:slf4j-simple:2.0.3")
implementation("org.slf4j:slf4j-api:2.0.6")
implementation("org.slf4j:slf4j-simple:2.0.6")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0")
implementation("joda-time:joda-time:2.12.1")
implementation("joda-time:joda-time:2.12.2")
}

View File

@ -50,7 +50,6 @@ private fun Application.mainModule() {
}
routing {
redoc(pageTitle = "Simple API Docs")
route("/{id}") {
idDocumentation()
get {

View File

@ -0,0 +1,156 @@
package io.bkbn.kompendium.playground
import io.bkbn.kompendium.core.metadata.GetInfo
import io.bkbn.kompendium.core.metadata.PostInfo
import io.bkbn.kompendium.core.plugin.NotarizedApplication
import io.bkbn.kompendium.core.plugin.NotarizedRoute
import io.bkbn.kompendium.core.routes.redoc
import io.bkbn.kompendium.enrichment.TypeEnrichment
import io.bkbn.kompendium.json.schema.KotlinXSchemaConfigurator
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.ExampleRequest
import io.bkbn.kompendium.playground.util.ExampleResponse
import io.bkbn.kompendium.playground.util.ExceptionResponse
import io.bkbn.kompendium.playground.util.InnerRequest
import io.bkbn.kompendium.playground.util.Util.baseSpec
import io.ktor.http.HttpStatusCode
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.response.respond
import io.ktor.server.routing.*
import kotlinx.serialization.json.Json
fun main() {
embeddedServer(
CIO,
port = 8081,
module = Application::mainModule
).start(wait = true)
}
private fun Application.mainModule() {
install(ContentNegotiation) {
json(Json {
serializersModule = KompendiumSerializersModule.module
encodeDefaults = true
explicitNulls = false
})
}
install(NotarizedApplication()) {
spec = baseSpec
// Adds support for @Transient and @SerialName
// If you are not using them this is not required.
schemaConfigurator = KotlinXSchemaConfigurator()
}
routing {
redoc(pageTitle = "Simple API Docs")
enrichedDocumentation()
post {
call.respond(HttpStatusCode.OK, ExampleResponse(false))
}
}
}
private val testEnrichment = TypeEnrichment("testerino") {
ExampleRequest::thingA {
description = "This is a thing"
}
ExampleRequest::thingB {
description = "This is another thing"
}
ExampleRequest::thingC {
deprecated = true
description = "A good but old field"
typeEnrichment = TypeEnrichment("big-tings") {
InnerRequest::d {
exclusiveMaximum = 10.0
exclusiveMinimum = 1.1
description = "THE BIG D"
}
}
}
}
private val testResponseEnrichment = TypeEnrichment("testerino") {
ExampleResponse::isReal {
description = "Is this thing real or not?"
}
}
private fun Route.enrichedDocumentation() {
install(NotarizedRoute()) {
post = PostInfo.builder {
summary("Do a thing")
description("This is a thing")
request {
requestType(enrichment = testEnrichment)
description("This is the request")
}
response {
responseCode(HttpStatusCode.OK)
responseType(enrichment = testResponseEnrichment)
description("This is the response")
}
}
}
}
private fun Route.idDocumentation() {
install(NotarizedRoute()) {
parameters = listOf(
Parameter(
name = "id",
`in` = Parameter.Location.path,
schema = TypeDefinition.STRING
)
)
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 😱")
}
canRespond {
responseType<ExceptionResponse>()
responseCode(HttpStatusCode.NotFound)
description("Indicates that a user with this id does not exist")
}
}
}
}
private fun Route.profileDocumentation() {
install(NotarizedRoute()) {
parameters = listOf(
Parameter(
name = "id",
`in` = Parameter.Location.path,
schema = TypeDefinition.STRING
)
)
get = GetInfo.builder {
summary("Get a users profile")
description("A cool endpoint!")
response {
responseCode(HttpStatusCode.OK)
responseType<ExampleResponse>()
description("Returns user profile information")
}
canRespond {
responseType<ExceptionResponse>()
responseCode(HttpStatusCode.NotFound)
description("Indicates that a user with this id does not exist")
}
}
}
}

View File

@ -5,6 +5,19 @@ import io.ktor.server.locations.Location
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
@Serializable
data class ExampleRequest(
val thingA: String,
val thingB: Int,
val thingC: InnerRequest,
)
@Serializable
data class InnerRequest(
val d: Float,
val e: Boolean,
)
@Serializable
data class ExampleResponse(val isReal: Boolean)

View File

@ -22,8 +22,8 @@ dependencies {
implementation(projects.kompendiumJsonSchema)
implementation("com.google.protobuf:protobuf-java:3.21.9")
implementation("org.jetbrains.kotlin:kotlin-reflect:1.7.21")
implementation("com.google.protobuf:protobuf-java:3.21.12")
implementation("org.jetbrains.kotlin:kotlin-reflect:1.8.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1")
// Formatting

View File

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

View File

@ -1,6 +1,7 @@
rootProject.name = "kompendium"
include("core")
include("enrichment")
include("oas")
include("playground")
include("locations")