From 377a60614e0ab35c0c0a15b78008ae82a41d818f Mon Sep 17 00:00:00 2001 From: Ryan Brink <5607577+unredundant@users.noreply.github.com> Date: Thu, 5 Jan 2023 09:44:24 -0500 Subject: [PATCH] feat: constraints (#409) --- CHANGELOG.md | 6 + .../io/bkbn/kompendium/core/KompendiumTest.kt | 31 +++- .../bkbn/kompendium/core/util/Constraints.kt | 160 ++++++++++++++++++ .../resources/T0059__int_constraints.json | 80 +++++++++ .../resources/T0060__double_constraints.json | 76 +++++++++ .../T0061__string_min_max_constraints.json | 74 ++++++++ .../T0062__string_pattern_constraints.json | 73 ++++++++ ...__string_content_encoding_constraints.json | 74 ++++++++ .../resources/T0064__array_constraints.json | 103 +++++++++++ .../kompendium/core/fixtures/TestModels.kt | 3 + docs/SUMMARY.md | 2 + docs/concepts/enrichment.md | 107 ++++++++++++ docs/concepts/index.md | 2 + docs/plugins/notarized_route.md | 86 ---------- .../enrichment/PropertyEnrichment.kt | 29 ++++ gradle.properties | 2 +- .../json/schema/definition/ArrayDefinition.kt | 5 + .../json/schema/definition/TypeDefinition.kt | 25 +++ .../schema/handler/SimpleObjectHandler.kt | 25 ++- .../json/schema/util/Serializers.kt | 43 +++++ .../playground/EnrichmentPlayground.kt | 2 + 21 files changed, 916 insertions(+), 92 deletions(-) create mode 100644 core/src/test/kotlin/io/bkbn/kompendium/core/util/Constraints.kt create mode 100644 core/src/test/resources/T0059__int_constraints.json create mode 100644 core/src/test/resources/T0060__double_constraints.json create mode 100644 core/src/test/resources/T0061__string_min_max_constraints.json create mode 100644 core/src/test/resources/T0062__string_pattern_constraints.json create mode 100644 core/src/test/resources/T0063__string_content_encoding_constraints.json create mode 100644 core/src/test/resources/T0064__array_constraints.json create mode 100644 docs/concepts/enrichment.md create mode 100644 docs/concepts/index.md create mode 100644 json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/util/Serializers.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d36b26d59..5f0ede238 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ ## Released +## [3.11.0] - January 5th, 2023 + +### Added + +- Support for type constraints. + ## [3.10.0] - January 4th, 2023 ### Added 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 613f0cc10..759fbf80d 100644 --- a/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt @@ -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,7 @@ 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 @@ -20,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 @@ -56,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 @@ -318,9 +324,6 @@ class KompendiumTest : DescribeSpec({ 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( @@ -442,4 +445,26 @@ class KompendiumTest : DescribeSpec({ 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() } + } + } }) diff --git a/core/src/test/kotlin/io/bkbn/kompendium/core/util/Constraints.kt b/core/src/test/kotlin/io/bkbn/kompendium/core/util/Constraints.kt new file mode 100644 index 000000000..cb6ad1d5a --- /dev/null +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/util/Constraints.kt @@ -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::content { + minItems = 2 + maxItems = 10 + uniqueItems = true + } + } + ) + responseCode(HttpStatusCode.OK) + } + } + } + } +} diff --git a/core/src/test/resources/T0059__int_constraints.json b/core/src/test/resources/T0059__int_constraints.json new file mode 100644 index 000000000..59707fafc --- /dev/null +++ b/core/src/test/resources/T0059__int_constraints.json @@ -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": [] +} diff --git a/core/src/test/resources/T0060__double_constraints.json b/core/src/test/resources/T0060__double_constraints.json new file mode 100644 index 000000000..ac0ccf050 --- /dev/null +++ b/core/src/test/resources/T0060__double_constraints.json @@ -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": [] +} diff --git a/core/src/test/resources/T0061__string_min_max_constraints.json b/core/src/test/resources/T0061__string_min_max_constraints.json new file mode 100644 index 000000000..92adba69a --- /dev/null +++ b/core/src/test/resources/T0061__string_min_max_constraints.json @@ -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": [] +} diff --git a/core/src/test/resources/T0062__string_pattern_constraints.json b/core/src/test/resources/T0062__string_pattern_constraints.json new file mode 100644 index 000000000..4e4599433 --- /dev/null +++ b/core/src/test/resources/T0062__string_pattern_constraints.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": { + "/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": [] +} diff --git a/core/src/test/resources/T0063__string_content_encoding_constraints.json b/core/src/test/resources/T0063__string_content_encoding_constraints.json new file mode 100644 index 000000000..85a2b47a8 --- /dev/null +++ b/core/src/test/resources/T0063__string_content_encoding_constraints.json @@ -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": [] +} diff --git a/core/src/test/resources/T0064__array_constraints.json b/core/src/test/resources/T0064__array_constraints.json new file mode 100644 index 000000000..229516ebf --- /dev/null +++ b/core/src/test/resources/T0064__array_constraints.json @@ -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": [] +} diff --git a/core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestModels.kt b/core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestModels.kt index e144cac20..00e3c56a8 100644 --- a/core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestModels.kt +++ b/core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestModels.kt @@ -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, diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index c3de0378c..b612b5037 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -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) diff --git a/docs/concepts/enrichment.md b/docs/concepts/enrichment.md new file mode 100644 index 000000000..c4a2ef603 --- /dev/null +++ b/docs/concepts/enrichment.md @@ -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(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 %} + +### 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 + } +} +``` + +## 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 diff --git a/docs/concepts/index.md b/docs/concepts/index.md new file mode 100644 index 000000000..bc8c2af25 --- /dev/null +++ b/docs/concepts/index.md @@ -0,0 +1,2 @@ +Various concepts that are core to Kompendium but not necessarily exclusive +to any given module or plugin diff --git a/docs/plugins/notarized_route.md b/docs/plugins/notarized_route.md index bb4fa15e8..78912ce82 100644 --- a/docs/plugins/notarized_route.md +++ b/docs/plugins/notarized_route.md @@ -204,89 +204,3 @@ 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/src/main/kotlin/io/bkbn/kompendium/enrichment/PropertyEnrichment.kt b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/PropertyEnrichment.kt index 2870f8886..28786e955 100644 --- a/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/PropertyEnrichment.kt +++ b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/PropertyEnrichment.kt @@ -1,7 +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 } diff --git a/gradle.properties b/gradle.properties index 2a68ce163..888a30aad 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ # Kompendium -project.version=3.10.0 +project.version=3.11.0 # Kotlin kotlin.code.style=official # Gradle 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 2427589a8..c7949fc58 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 @@ -7,6 +7,11 @@ data class ArrayDefinition( 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" } 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 db1cd4b20..e25751b45 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 @@ -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 @@ -12,6 +13,30 @@ data class TypeDefinition( @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) 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 37707b156..073e08ddb 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 @@ -169,12 +169,33 @@ object SimpleObjectHandler { 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 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) + 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, + ) } } diff --git a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/util/Serializers.kt b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/util/Serializers.kt new file mode 100644 index 000000000..a9bbe526f --- /dev/null +++ b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/util/Serializers.kt @@ -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 { + 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 { + 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") + } + } + } +} diff --git a/playground/src/main/kotlin/io/bkbn/kompendium/playground/EnrichmentPlayground.kt b/playground/src/main/kotlin/io/bkbn/kompendium/playground/EnrichmentPlayground.kt index e69a756af..fb9021890 100644 --- a/playground/src/main/kotlin/io/bkbn/kompendium/playground/EnrichmentPlayground.kt +++ b/playground/src/main/kotlin/io/bkbn/kompendium/playground/EnrichmentPlayground.kt @@ -70,6 +70,8 @@ private val testEnrichment = TypeEnrichment("testerino") { description = "A good but old field" typeEnrichment = TypeEnrichment("big-tings") { InnerRequest::d { + exclusiveMaximum = 10.0 + exclusiveMinimum = 1.1 description = "THE BIG D" } }