From 91bf93a866172cdc8d149cae37a089defa089751 Mon Sep 17 00:00:00 2001 From: Ryan Brink <5607577+unredundant@users.noreply.github.com> Date: Wed, 4 Jan 2023 21:32:31 -0500 Subject: [PATCH] feat: type enrichment (#408) --- CHANGELOG.md | 6 + build.gradle.kts | 4 +- .../kompendium/core/metadata/RequestInfo.kt | 13 +- .../kompendium/core/metadata/ResponseInfo.kt | 13 +- .../io/bkbn/kompendium/core/util/Helpers.kt | 62 ++++-- .../io/bkbn/kompendium/core/KompendiumTest.kt | 30 ++- .../bkbn/kompendium/core/util/Enrichment.kt | 137 +++++++++++++ .../kompendium/core/util/ErrorHandling.kt | 2 +- .../T0055__enriched_simple_request.json | 126 ++++++++++++ .../T0056__enriched_nested_collection.json | 168 ++++++++++++++++ .../T0057__enriched_complex_generic_type.json | 183 ++++++++++++++++++ .../T0058__enriched_simple_response.json | 73 +++++++ docs/plugins/notarized_route.md | 88 ++++++++- enrichment/build.gradle.kts | 34 ++++ .../bkbn/kompendium/enrichment/Enrichment.kt | 3 + .../enrichment/PropertyEnrichment.kt | 7 + .../kompendium/enrichment/TypeEnrichment.kt | 25 +++ gradle.properties | 4 +- json-schema/build.gradle.kts | 3 + .../kompendium/json/schema/SchemaGenerator.kt | 34 ++-- .../json/schema/definition/AnyOfDefinition.kt | 6 +- .../json/schema/definition/ArrayDefinition.kt | 4 +- .../json/schema/definition/EnumDefinition.kt | 4 +- .../json/schema/definition/JsonSchema.kt | 3 + .../json/schema/definition/MapDefinition.kt | 4 +- .../schema/definition/NullableDefinition.kt | 6 +- .../json/schema/definition/OneOfDefinition.kt | 6 +- .../schema/definition/ReferenceDefinition.kt | 6 +- .../json/schema/definition/TypeDefinition.kt | 3 +- .../json/schema/handler/CollectionHandler.kt | 16 +- .../json/schema/handler/EnumHandler.kt | 12 +- .../json/schema/handler/MapHandler.kt | 16 +- .../schema/handler/SealedObjectHandler.kt | 13 +- .../schema/handler/SimpleObjectHandler.kt | 78 +++++--- .../kompendium/json/schema/util/Helpers.kt | 19 +- .../json/schema/SchemaGeneratorTest.kt | 52 ++++- .../T0022__enriched_simple_object.json | 18 ++ .../T0023__enriched_nested_reference.json | 23 +++ oas/build.gradle.kts | 1 + .../kompendium/playground/BasicPlayground.kt | 1 - .../playground/EnrichmentPlayground.kt | 154 +++++++++++++++ .../bkbn/kompendium/playground/util/Models.kt | 13 ++ settings.gradle.kts | 1 + 43 files changed, 1372 insertions(+), 102 deletions(-) create mode 100644 core/src/test/kotlin/io/bkbn/kompendium/core/util/Enrichment.kt create mode 100644 core/src/test/resources/T0055__enriched_simple_request.json create mode 100644 core/src/test/resources/T0056__enriched_nested_collection.json create mode 100644 core/src/test/resources/T0057__enriched_complex_generic_type.json create mode 100644 core/src/test/resources/T0058__enriched_simple_response.json create mode 100644 enrichment/build.gradle.kts create mode 100644 enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/Enrichment.kt create mode 100644 enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/PropertyEnrichment.kt create mode 100644 enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/TypeEnrichment.kt create mode 100644 json-schema/src/test/resources/T0022__enriched_simple_object.json create mode 100644 json-schema/src/test/resources/T0023__enriched_nested_reference.json create mode 100644 playground/src/main/kotlin/io/bkbn/kompendium/playground/EnrichmentPlayground.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 05fd4a000..d36b26d59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ ## Released +## [3.10.0] - January 4th, 2023 + +### Added + +- Support for type enrichments! `deprecated` and `description` to start + ## [3.9.0] - November 15th, 2022 ### Added diff --git a/build.gradle.kts b/build.gradle.kts index d7620cdf4..f1231f1a6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,6 @@ plugins { - kotlin("jvm") version "1.7.22" apply false - kotlin("plugin.serialization") version "1.7.22" 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" diff --git a/core/src/main/kotlin/io/bkbn/kompendium/core/metadata/RequestInfo.kt b/core/src/main/kotlin/io/bkbn/kompendium/core/metadata/RequestInfo.kt index e5062ca67..35af63231 100644 --- a/core/src/main/kotlin/io/bkbn/kompendium/core/metadata/RequestInfo.kt +++ b/core/src/main/kotlin/io/bkbn/kompendium/core/metadata/RequestInfo.kt @@ -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?, val mediaTypes: Set @@ -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? = null private var mediaTypes: Set? = null @@ -29,7 +32,14 @@ class RequestInfo private constructor( this.requestType = t } - inline fun requestType() = apply { requestType(typeOf()) } + fun enrichment(t: TypeEnrichment<*>) = apply { + this.typeEnrichment = t + } + + inline fun requestType(enrichment: TypeEnrichment? = null) = apply { + requestType(typeOf()) + 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") ) diff --git a/core/src/main/kotlin/io/bkbn/kompendium/core/metadata/ResponseInfo.kt b/core/src/main/kotlin/io/bkbn/kompendium/core/metadata/ResponseInfo.kt index b0b6426c2..28380448f 100644 --- a/core/src/main/kotlin/io/bkbn/kompendium/core/metadata/ResponseInfo.kt +++ b/core/src/main/kotlin/io/bkbn/kompendium/core/metadata/ResponseInfo.kt @@ -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?, val mediaTypes: Set @@ -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? = null private var mediaTypes: Set? = null @@ -36,7 +39,14 @@ class ResponseInfo private constructor( this.responseType = t } - inline fun responseType() = apply { responseType(typeOf()) } + fun enrichment(t: TypeEnrichment<*>) = apply { + this.typeEnrichment = t + } + + inline fun responseType(enrichment: TypeEnrichment? = null) = apply { + responseType(typeOf()) + 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") ) diff --git a/core/src/main/kotlin/io/bkbn/kompendium/core/util/Helpers.kt b/core/src/main/kotlin/io/bkbn/kompendium/core/util/Helpers.kt index a2175abb4..3ec287986 100644 --- a/core/src/main/kotlin/io/bkbn/kompendium/core/util/Helpers.kt +++ b/core/src/main/kotlin/io/bkbn/kompendium/core/util/Helpers.kt @@ -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 = 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.toResponseMap(): Map = 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?, - mediaTypes: Set + mediaTypes: Set, + enrichment: TypeEnrichment<*>? ): Map? = when (this.classifier as KClass<*>) { Unit::class -> null else -> mediaTypes.associateWith { MediaType( - schema = if (this.isMarkedNullable) OneOfDefinition( - NullableDefinition(), - ReferenceDefinition(this.getReferenceSlug()) - ) else ReferenceDefinition(this.getReferenceSlug()), + schema = if (this.isMarkedNullable) { + OneOfDefinition( + NullableDefinition(), + ReferenceDefinition(this.getReferenceSlug(enrichment)) + ) + } else { + ReferenceDefinition(this.getReferenceSlug(enrichment)) + }, examples = examples ) } diff --git a/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt index be0136b0c..613f0cc10 100644 --- a/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt @@ -9,6 +9,10 @@ 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.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 @@ -297,7 +301,17 @@ class KompendiumTest : DescribeSpec({ } it("Throws an exception when same method for same path has been previously registered") { val exception = shouldThrow { - openApiTestAllSerializers("") { + openApiTestAllSerializers( + snapshotName = "", + applicationSetup = { + install(Authentication) { + basic("basic") { + realm = "Ktor Server" + validate { UserIdPrincipal("Placeholder") } + } + } + }, + ) { samePathSameMethod() } } @@ -414,4 +428,18 @@ 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() } + } + } }) diff --git a/core/src/test/kotlin/io/bkbn/kompendium/core/util/Enrichment.kt b/core/src/test/kotlin/io/bkbn/kompendium/core/util/Enrichment.kt new file mode 100644 index 000000000..0e8b2451b --- /dev/null +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/util/Enrichment.kt @@ -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() + 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() + 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::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() + description(TestModules.defaultResponseDescription) + } + } + } + } +} diff --git a/core/src/test/kotlin/io/bkbn/kompendium/core/util/ErrorHandling.kt b/core/src/test/kotlin/io/bkbn/kompendium/core/util/ErrorHandling.kt index 2ad8d8003..f3b5b3c19 100644 --- a/core/src/test/kotlin/io/bkbn/kompendium/core/util/ErrorHandling.kt +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/util/ErrorHandling.kt @@ -9,7 +9,7 @@ import io.ktor.server.routing.route fun Routing.samePathSameMethod() { route(defaultPath) { basicGetGenerator() - authenticate { + authenticate("basic") { basicGetGenerator() } } diff --git a/core/src/test/resources/T0055__enriched_simple_request.json b/core/src/test/resources/T0055__enriched_simple_request.json new file mode 100644 index 000000000..0fcc75b2a --- /dev/null +++ b/core/src/test/resources/T0055__enriched_simple_request.json @@ -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": [] +} diff --git a/core/src/test/resources/T0056__enriched_nested_collection.json b/core/src/test/resources/T0056__enriched_nested_collection.json new file mode 100644 index 000000000..1de1b1c91 --- /dev/null +++ b/core/src/test/resources/T0056__enriched_nested_collection.json @@ -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": [] +} diff --git a/core/src/test/resources/T0057__enriched_complex_generic_type.json b/core/src/test/resources/T0057__enriched_complex_generic_type.json new file mode 100644 index 000000000..44954b3ef --- /dev/null +++ b/core/src/test/resources/T0057__enriched_complex_generic_type.json @@ -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": [] +} diff --git a/core/src/test/resources/T0058__enriched_simple_response.json b/core/src/test/resources/T0058__enriched_simple_response.json new file mode 100644 index 000000000..36cec29d3 --- /dev/null +++ b/core/src/test/resources/T0058__enriched_simple_response.json @@ -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": [] +} diff --git a/docs/plugins/notarized_route.md b/docs/plugins/notarized_route.md index 2eab404dd..bb4fa15e8 100644 --- a/docs/plugins/notarized_route.md +++ b/docs/plugins/notarized_route.md @@ -168,7 +168,7 @@ get = GetInfo.builder { ## Media Types -By default, Kompendium will set the only media type to "application/json". If you would like to override the media type +By default, Kompendium will set the only media type to "application/json". If you would like to override the media type for a specific request or response (including errors), you can do so with the `mediaTypes` method ```kotlin @@ -204,3 +204,89 @@ route("/user/{id}") { } } ``` + +## Enrichment + +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(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(enrichment = myEnrichment) // Simply attach the enrichment to the request + description("A test request") + } + response { + responseCode(HttpStatusCode.Created) + responseType() + 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 %} + +At the moment, the only available enrichments are the following + +- description -> Provides a reader friendly description of the field in the object +- deprecated -> Indicates that the field is deprecated and should not be used + +### 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(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(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 + } +} +``` diff --git a/enrichment/build.gradle.kts b/enrichment/build.gradle.kts new file mode 100644 index 000000000..bf2a85953 --- /dev/null +++ b/enrichment/build.gradle.kts @@ -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() + } + } +} diff --git a/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/Enrichment.kt b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/Enrichment.kt new file mode 100644 index 000000000..a8f1aa2bc --- /dev/null +++ b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/Enrichment.kt @@ -0,0 +1,3 @@ +package io.bkbn.kompendium.enrichment + +sealed interface Enrichment diff --git a/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/PropertyEnrichment.kt b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/PropertyEnrichment.kt new file mode 100644 index 000000000..2870f8886 --- /dev/null +++ b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/PropertyEnrichment.kt @@ -0,0 +1,7 @@ +package io.bkbn.kompendium.enrichment + +class PropertyEnrichment : Enrichment { + var deprecated: Boolean? = null + var description: String? = null + var typeEnrichment: TypeEnrichment<*>? = null +} diff --git a/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/TypeEnrichment.kt b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/TypeEnrichment.kt new file mode 100644 index 000000000..4b4dbed23 --- /dev/null +++ b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/TypeEnrichment.kt @@ -0,0 +1,25 @@ +package io.bkbn.kompendium.enrichment + +import kotlin.reflect.KProperty +import kotlin.reflect.KProperty1 + +class TypeEnrichment(val id: String) : Enrichment { + + private val enrichments: MutableMap, Enrichment> = mutableMapOf() + + fun getEnrichmentForProperty(property: KProperty<*>): Enrichment? = enrichments[property] + + operator fun KProperty1.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 invoke(id: String, init: TypeEnrichment.() -> Unit): TypeEnrichment { + val builder = TypeEnrichment(id) + return builder.apply(init) + } + } +} diff --git a/gradle.properties b/gradle.properties index b979b76bc..78dca692d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ # Kompendium -project.version=3.9.0 +project.version=3.10.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.1 kotestVersion=5.5.4 detektVersion=1.21.0 diff --git a/json-schema/build.gradle.kts b/json-schema/build.gradle.kts index 3ee514bf0..5e0eeabc8 100644 --- a/json-schema/build.gradle.kts +++ b/json-schema/build.gradle.kts @@ -20,6 +20,9 @@ dependencies { // Versions val detektVersion: String by project + // Kompendium + api(projects.kompendiumEnrichment) + implementation("org.jetbrains.kotlin:kotlin-reflect:1.8.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.1") diff --git a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/SchemaGenerator.kt b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/SchemaGenerator.kt index 9f3ff76ce..942680dc7 100644 --- a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/SchemaGenerator.kt +++ b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/SchemaGenerator.kt @@ -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 fromTypeToSchema( - cache: MutableMap = mutableMapOf(), - schemaConfigurator: SchemaConfigurator = SchemaConfigurator.Default() - ) = fromTypeToSchema(typeOf(), cache, schemaConfigurator) - fun fromTypeToSchema( type: KType, cache: MutableMap, - 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 = 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) { diff --git a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/AnyOfDefinition.kt b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/AnyOfDefinition.kt index 8aefb84b2..741d4e093 100644 --- a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/AnyOfDefinition.kt +++ b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/AnyOfDefinition.kt @@ -3,4 +3,8 @@ package io.bkbn.kompendium.json.schema.definition import kotlinx.serialization.Serializable @Serializable -data class AnyOfDefinition(val anyOf: Set) : JsonSchema +data class AnyOfDefinition( + val anyOf: Set, + override val deprecated: Boolean? = null, + override val description: String? = null, +) : JsonSchema diff --git a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/ArrayDefinition.kt b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/ArrayDefinition.kt index 94dd359ee..2427589a8 100644 --- a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/ArrayDefinition.kt +++ b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/ArrayDefinition.kt @@ -4,7 +4,9 @@ import kotlinx.serialization.Serializable @Serializable data class ArrayDefinition( - val items: JsonSchema + val items: JsonSchema, + override val deprecated: Boolean? = null, + override val description: String? = null, ) : JsonSchema { val type: String = "array" } diff --git a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/EnumDefinition.kt b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/EnumDefinition.kt index ceece821c..f00b51b62 100644 --- a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/EnumDefinition.kt +++ b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/EnumDefinition.kt @@ -5,5 +5,7 @@ import kotlinx.serialization.Serializable @Serializable data class EnumDefinition( val type: String, - val enum: Set + val enum: Set, + override val deprecated: Boolean? = null, + override val description: String? = null, ) : JsonSchema diff --git a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/JsonSchema.kt b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/JsonSchema.kt index d6d6675e4..8f70df041 100644 --- a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/JsonSchema.kt +++ b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/JsonSchema.kt @@ -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 { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("JsonSchema", PrimitiveKind.STRING) override fun deserialize(decoder: Decoder): JsonSchema { diff --git a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/MapDefinition.kt b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/MapDefinition.kt index 946f5fcc2..02199afb2 100644 --- a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/MapDefinition.kt +++ b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/MapDefinition.kt @@ -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" } diff --git a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/NullableDefinition.kt b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/NullableDefinition.kt index 10e5a3e76..85c4f52d2 100644 --- a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/NullableDefinition.kt +++ b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/NullableDefinition.kt @@ -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 diff --git a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/OneOfDefinition.kt b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/OneOfDefinition.kt index 88e711a7b..cfdd5ae0e 100644 --- a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/OneOfDefinition.kt +++ b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/OneOfDefinition.kt @@ -3,6 +3,10 @@ package io.bkbn.kompendium.json.schema.definition import kotlinx.serialization.Serializable @Serializable -data class OneOfDefinition(val oneOf: Set) : JsonSchema { +data class OneOfDefinition( + val oneOf: Set, + override val deprecated: Boolean? = null, + override val description: String? = null, +) : JsonSchema { constructor(vararg types: JsonSchema) : this(types.toSet()) } diff --git a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/ReferenceDefinition.kt b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/ReferenceDefinition.kt index 62c0587c1..ba66a4357 100644 --- a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/ReferenceDefinition.kt +++ b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/ReferenceDefinition.kt @@ -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 diff --git a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/TypeDefinition.kt b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/TypeDefinition.kt index b7f34157f..db1cd4b20 100644 --- a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/TypeDefinition.kt +++ b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/definition/TypeDefinition.kt @@ -7,10 +7,11 @@ import kotlinx.serialization.Serializable data class TypeDefinition( val type: String, val format: String? = null, - val description: String? = null, val properties: Map? = null, val required: Set? = null, @Contextual val default: Any? = null, + override val deprecated: Boolean? = null, + override val description: String? = null, ) : JsonSchema { fun withDefault(default: Any): TypeDefinition = this.copy(default = default) diff --git a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/CollectionHandler.kt b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/CollectionHandler.kt index 49c8c3801..0880ca303 100644 --- a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/CollectionHandler.kt +++ b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/CollectionHandler.kt @@ -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, schemaConfigurator: SchemaConfigurator): JsonSchema { + fun handle( + type: KType, + cache: MutableMap, + 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 } diff --git a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/EnumHandler.kt b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/EnumHandler.kt index 901fc8da6..2f5b16cdc 100644 --- a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/EnumHandler.kt +++ b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/EnumHandler.kt @@ -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): JsonSchema { - cache[type.getSimpleSlug()] = ReferenceDefinition(type.getReferenceSlug()) + fun handle( + type: KType, + clazz: KClass<*>, + cache: MutableMap, + 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) diff --git a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/MapHandler.kt b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/MapHandler.kt index efaee1b35..d9fab0a36 100644 --- a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/MapHandler.kt +++ b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/MapHandler.kt @@ -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, schemaConfigurator: SchemaConfigurator): JsonSchema { + fun handle( + type: KType, + cache: MutableMap, + 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 } diff --git a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/SealedObjectHandler.kt b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/SealedObjectHandler.kt index 4a54f823e..36c78c698 100644 --- a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/SealedObjectHandler.kt +++ b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/SealedObjectHandler.kt @@ -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, - 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 } diff --git a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/SimpleObjectHandler.kt b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/SimpleObjectHandler.kt index 25ed0d838..37707b156 100644 --- a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/SimpleObjectHandler.kt +++ b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/SimpleObjectHandler.kt @@ -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, - 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, prop: KProperty<*>, cache: MutableMap, - schemaConfigurator: SchemaConfigurator + schemaConfigurator: SchemaConfigurator, + propEnrichment: PropertyEnrichment? ): JsonSchema { val propClass = prop.returnType.classifier as KClass<*> val types = prop.returnType.arguments.map { @@ -98,28 +112,30 @@ object SimpleObjectHandler { typeMap.filterKeys { k -> k.name == typeSymbol }.values.first() } val constructedType = propClass.createType(types) - return SchemaGenerator.fromTypeToSchema(constructedType, cache, schemaConfigurator).let { - if (it.isOrContainsObjectOrEnumDef()) { - cache[constructedType.getSimpleSlug()] = it - ReferenceDefinition(prop.returnType.getReferenceSlug()) - } else { - it + return SchemaGenerator.fromTypeToSchema(constructedType, cache, schemaConfigurator, propEnrichment?.typeEnrichment) + .let { + if (it.isOrContainsObjectOrEnumDef()) { + cache[constructedType.getSlug(propEnrichment)] = it + ReferenceDefinition(prop.returnType.getReferenceSlug(propEnrichment)) + } else { + it + } } - } } private fun handleGenericProperty( prop: KProperty<*>, typeMap: Map, cache: MutableMap, - 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, - 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,15 @@ 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) + 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) + } } diff --git a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/util/Helpers.kt b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/util/Helpers.kt index f0c0e19eb..cac51befb 100644 --- a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/util/Helpers.kt +++ b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/util/Helpers.kt @@ -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()}" } diff --git a/json-schema/src/test/kotlin/io/bkbn/kompendium/json/schema/SchemaGeneratorTest.kt b/json-schema/src/test/kotlin/io/bkbn/kompendium/json/schema/SchemaGeneratorTest.kt index b7db45220..e45c698d7 100644 --- a/json-schema/src/test/kotlin/io/bkbn/kompendium/json/schema/SchemaGeneratorTest.kt +++ b/json-schema/src/test/kotlin/io/bkbn/kompendium/json/schema/SchemaGeneratorTest.kt @@ -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>("T0012__scalar_map.json") } it("Throws an error when map keys are not strings") { - shouldThrow { SchemaGenerator.fromTypeToSchema>() } + shouldThrow { + SchemaGenerator.fromTypeToSchema( + typeOf>(), + cache = mutableMapOf(), + schemaConfigurator = KotlinXSchemaConfigurator() + ) + } } it("Can generate the schema for a map of objects") { jsonSchemaTest>("T0013__object_map.json") @@ -97,6 +106,36 @@ class SchemaGeneratorTest : DescribeSpec({ jsonSchemaTest?>("T0014__nullable_map.json") } } + describe("Enrichment") { + it("Can attach an enrichment to a simple type") { + jsonSchemaTest( + 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( + 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 jsonSchemaTest(snapshotName: String) { + private inline fun jsonSchemaTest(snapshotName: String, enrichment: TypeEnrichment<*>? = null) { // act - val schema = SchemaGenerator.fromTypeToSchema(schemaConfigurator = KotlinXSchemaConfigurator()) - - // todo add cache assertions!!! + val schema = SchemaGenerator.fromTypeToSchema( + type = typeOf(), + cache = mutableMapOf(), + schemaConfigurator = KotlinXSchemaConfigurator(), + enrichment = enrichment, + ) // assert schema.serialize() shouldEqualJson getFileSnapshot(snapshotName) diff --git a/json-schema/src/test/resources/T0022__enriched_simple_object.json b/json-schema/src/test/resources/T0022__enriched_simple_object.json new file mode 100644 index 000000000..062d1e4c4 --- /dev/null +++ b/json-schema/src/test/resources/T0022__enriched_simple_object.json @@ -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" + ] +} diff --git a/json-schema/src/test/resources/T0023__enriched_nested_reference.json b/json-schema/src/test/resources/T0023__enriched_nested_reference.json new file mode 100644 index 000000000..8a201687e --- /dev/null +++ b/json-schema/src/test/resources/T0023__enriched_nested_reference.json @@ -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" + ] +} diff --git a/oas/build.gradle.kts b/oas/build.gradle.kts index 0b685d83d..e821fbedd 100644 --- a/oas/build.gradle.kts +++ b/oas/build.gradle.kts @@ -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 diff --git a/playground/src/main/kotlin/io/bkbn/kompendium/playground/BasicPlayground.kt b/playground/src/main/kotlin/io/bkbn/kompendium/playground/BasicPlayground.kt index 87ecd6d33..78fa65414 100644 --- a/playground/src/main/kotlin/io/bkbn/kompendium/playground/BasicPlayground.kt +++ b/playground/src/main/kotlin/io/bkbn/kompendium/playground/BasicPlayground.kt @@ -50,7 +50,6 @@ private fun Application.mainModule() { } routing { redoc(pageTitle = "Simple API Docs") - route("/{id}") { idDocumentation() get { diff --git a/playground/src/main/kotlin/io/bkbn/kompendium/playground/EnrichmentPlayground.kt b/playground/src/main/kotlin/io/bkbn/kompendium/playground/EnrichmentPlayground.kt new file mode 100644 index 000000000..e69a756af --- /dev/null +++ b/playground/src/main/kotlin/io/bkbn/kompendium/playground/EnrichmentPlayground.kt @@ -0,0 +1,154 @@ +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 { + 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() + description("Will return whether or not the user is real 😱") + } + + canRespond { + responseType() + 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() + description("Returns user profile information") + } + canRespond { + responseType() + responseCode(HttpStatusCode.NotFound) + description("Indicates that a user with this id does not exist") + } + } + } +} diff --git a/playground/src/main/kotlin/io/bkbn/kompendium/playground/util/Models.kt b/playground/src/main/kotlin/io/bkbn/kompendium/playground/util/Models.kt index f1c42e24e..31727437f 100644 --- a/playground/src/main/kotlin/io/bkbn/kompendium/playground/util/Models.kt +++ b/playground/src/main/kotlin/io/bkbn/kompendium/playground/util/Models.kt @@ -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) diff --git a/settings.gradle.kts b/settings.gradle.kts index c207d48b7..06a4d39d1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,6 +1,7 @@ rootProject.name = "kompendium" include("core") +include("enrichment") include("oas") include("playground") include("locations")