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 d5f81f8f2..d1e9e6ba0 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 @@ -7,7 +7,7 @@ import kotlin.reflect.typeOf class RequestInfo private constructor( val requestType: KType, - val typeEnrichment: TypeEnrichment<*>?, + val enrichment: TypeEnrichment<*>?, val description: String, val examples: Map?, val mediaTypes: Set, @@ -60,7 +60,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, + enrichment = typeEnrichment, examples = examples, mediaTypes = mediaTypes ?: setOf("application/json"), required = required ?: true 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 a0b78f59e..719fbfcc8 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 @@ -10,7 +10,7 @@ import kotlin.reflect.typeOf class ResponseInfo private constructor( val responseCode: HttpStatusCode, val responseType: KType, - val typeEnrichment: TypeEnrichment<*>?, + val enrichment: TypeEnrichment<*>?, val description: String, val examples: Map?, val mediaTypes: Set, @@ -69,7 +69,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, + enrichment = typeEnrichment, examples = examples, mediaTypes = mediaTypes ?: setOf("application/json"), responseHeaders = responseHeaders 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 ec27b80e8..d6742f3a7 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 @@ -51,36 +51,45 @@ object Helpers { routePath: String, authMethods: List = emptyList() ) { + val type = this.response.responseType + val enrichment = this.response.enrichment SchemaGenerator.fromTypeOrUnit( - type = this.response.responseType, + type = type, cache = spec.components.schemas, schemaConfigurator = schemaConfigurator, - enrichment = this.response.typeEnrichment, + enrichment = enrichment, )?.let { schema -> - spec.components.schemas[this.response.responseType.getSlug(this.response.typeEnrichment)] = schema + val slug = type.getSlug(enrichment) + spec.components.schemas[slug] = schema } errors.forEach { error -> + val errorEnrichment = error.enrichment + val errorType = error.responseType SchemaGenerator.fromTypeOrUnit( - type = error.responseType, + type = errorType, cache = spec.components.schemas, schemaConfigurator = schemaConfigurator, - enrichment = error.typeEnrichment, + enrichment = errorEnrichment, )?.let { schema -> - spec.components.schemas[error.responseType.getSlug(error.typeEnrichment)] = schema + val slug = errorType.getSlug(errorEnrichment) + spec.components.schemas[slug] = schema } } when (this) { is MethodInfoWithRequest -> { this.request?.let { reqInfo -> + val reqEnrichment = reqInfo.enrichment + val reqType = reqInfo.requestType SchemaGenerator.fromTypeOrUnit( - type = reqInfo.requestType, + type = reqType, cache = spec.components.schemas, schemaConfigurator = schemaConfigurator, - enrichment = reqInfo.typeEnrichment, + enrichment = reqEnrichment, )?.let { schema -> - spec.components.schemas[reqInfo.requestType.getSlug(reqInfo.typeEnrichment)] = schema + val slug = reqType.getSlug(reqEnrichment) + spec.components.schemas[slug] = schema } } } @@ -127,7 +136,7 @@ object Helpers { content = reqInfo.requestType.toReferenceContent( examples = reqInfo.examples, mediaTypes = reqInfo.mediaTypes, - enrichment = reqInfo.typeEnrichment + enrichment = reqInfo.enrichment ), required = reqInfo.required ) @@ -142,7 +151,7 @@ object Helpers { content = this.response.responseType.toReferenceContent( examples = this.response.examples, mediaTypes = this.response.mediaTypes, - enrichment = this.response.typeEnrichment + enrichment = this.response.enrichment ) ) ).plus(this.errors.toResponseMap()) @@ -174,7 +183,7 @@ object Helpers { content = error.responseType.toReferenceContent( examples = error.examples, mediaTypes = error.mediaTypes, - enrichment = error.typeEnrichment + enrichment = error.enrichment ) ) } diff --git a/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumAuthenticationTest.kt b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumAuthenticationTest.kt new file mode 100644 index 000000000..940796902 --- /dev/null +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumAuthenticationTest.kt @@ -0,0 +1,167 @@ +package io.bkbn.kompendium.core + +import dev.forst.ktor.apikey.apiKey +import io.bkbn.kompendium.core.fixtures.TestHelpers +import io.bkbn.kompendium.core.util.customAuthConfig +import io.bkbn.kompendium.core.util.customScopesOnSiblingPathOperations +import io.bkbn.kompendium.core.util.defaultAuthConfig +import io.bkbn.kompendium.core.util.multipleAuthStrategies +import io.bkbn.kompendium.oas.component.Components +import io.bkbn.kompendium.oas.security.ApiKeyAuth +import io.bkbn.kompendium.oas.security.BasicAuth +import io.bkbn.kompendium.oas.security.BearerAuth +import io.bkbn.kompendium.oas.security.OAuth +import io.kotest.core.spec.style.DescribeSpec +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.http.HttpMethod +import io.ktor.server.application.install +import io.ktor.server.auth.Authentication +import io.ktor.server.auth.OAuthServerSettings +import io.ktor.server.auth.UserIdPrincipal +import io.ktor.server.auth.basic +import io.ktor.server.auth.jwt.jwt +import io.ktor.server.auth.oauth + +class KompendiumAuthenticationTest : DescribeSpec({ + describe("Authentication") { + it("Can add a default auth config by default") { + TestHelpers.openApiTestAllSerializers( + snapshotName = "T0045__default_auth_config.json", + applicationSetup = { + install(Authentication) { + basic("basic") { + realm = "Ktor Server" + validate { UserIdPrincipal("Placeholder") } + } + } + }, + specOverrides = { + this.copy( + components = Components( + securitySchemes = mutableMapOf( + "basic" to BasicAuth() + ) + ) + ) + } + ) { defaultAuthConfig() } + } + it("Can provide custom auth config with proper scopes") { + TestHelpers.openApiTestAllSerializers( + snapshotName = "T0046__custom_auth_config.json", + applicationSetup = { + install(Authentication) { + oauth("auth-oauth-google") { + urlProvider = { "http://localhost:8080/callback" } + providerLookup = { + OAuthServerSettings.OAuth2ServerSettings( + name = "google", + authorizeUrl = "https://accounts.google.com/o/oauth2/auth", + accessTokenUrl = "https://accounts.google.com/o/oauth2/token", + requestMethod = HttpMethod.Post, + clientId = "DUMMY_VAL", + clientSecret = "DUMMY_VAL", + defaultScopes = listOf("https://www.googleapis.com/auth/userinfo.profile"), + extraTokenParameters = listOf("access_type" to "offline") + ) + } + client = HttpClient(CIO) + } + } + }, + specOverrides = { + this.copy( + components = Components( + securitySchemes = mutableMapOf( + "auth-oauth-google" to OAuth( + flows = OAuth.Flows( + implicit = OAuth.Flows.Implicit( + authorizationUrl = "https://accounts.google.com/o/oauth2/auth", + scopes = mapOf( + "write:pets" to "modify pets in your account", + "read:pets" to "read your pets" + ) + ) + ) + ) + ) + ) + ) + } + ) { customAuthConfig() } + } + it("Can provide multiple authentication strategies") { + TestHelpers.openApiTestAllSerializers( + snapshotName = "T0047__multiple_auth_strategies.json", + applicationSetup = { + install(Authentication) { + apiKey("api-key") { + headerName = "X-API-KEY" + validate { + UserIdPrincipal("Placeholder") + } + } + jwt("jwt") { + realm = "Server" + } + } + }, + specOverrides = { + this.copy( + components = Components( + securitySchemes = mutableMapOf( + "jwt" to BearerAuth("JWT"), + "api-key" to ApiKeyAuth(ApiKeyAuth.ApiKeyLocation.HEADER, "X-API-KEY") + ) + ) + ) + } + ) { multipleAuthStrategies() } + } + it("Can provide different scopes on path operations in the same route") { + TestHelpers.openApiTestAllSerializers( + snapshotName = "T0074__auth_on_specific_path_operation.json", + applicationSetup = { + install(Authentication) { + oauth("auth-oauth-google") { + urlProvider = { "http://localhost:8080/callback" } + providerLookup = { + OAuthServerSettings.OAuth2ServerSettings( + name = "google", + authorizeUrl = "https://accounts.google.com/o/oauth2/auth", + accessTokenUrl = "https://accounts.google.com/o/oauth2/token", + requestMethod = HttpMethod.Post, + clientId = "DUMMY_VAL", + clientSecret = "DUMMY_VAL", + defaultScopes = listOf("https://www.googleapis.com/auth/userinfo.profile"), + extraTokenParameters = listOf("access_type" to "offline") + ) + } + client = HttpClient(CIO) + } + } + }, + specOverrides = { + this.copy( + components = Components( + securitySchemes = mutableMapOf( + "auth-oauth-google" to OAuth( + flows = OAuth.Flows( + implicit = OAuth.Flows.Implicit( + authorizationUrl = "https://accounts.google.com/o/oauth2/auth", + scopes = mapOf( + "write:pets" to "modify pets in your account", + "read:pets" to "read your pets" + ) + ) + ) + ) + ) + ) + ) + } + ) { customScopesOnSiblingPathOperations() } + } + } +}) diff --git a/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumConstraintsTest.kt b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumConstraintsTest.kt new file mode 100644 index 000000000..2c04985ae --- /dev/null +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumConstraintsTest.kt @@ -0,0 +1,35 @@ +package io.bkbn.kompendium.core + +import io.bkbn.kompendium.core.fixtures.TestHelpers +import io.bkbn.kompendium.core.util.arrayConstraints +import io.bkbn.kompendium.core.util.doubleConstraints +import io.bkbn.kompendium.core.util.intConstraints +import io.bkbn.kompendium.core.util.stringConstraints +import io.bkbn.kompendium.core.util.stringContentEncodingConstraints +import io.bkbn.kompendium.core.util.stringPatternConstraints +import io.kotest.core.spec.style.DescribeSpec + +class KompendiumConstraintsTest : DescribeSpec({ + describe("Constraints") { + it("Can apply constraints to an int field") { + TestHelpers.openApiTestAllSerializers("T0059__int_constraints.json") { intConstraints() } + } + it("Can apply constraints to a double field") { + TestHelpers.openApiTestAllSerializers("T0060__double_constraints.json") { doubleConstraints() } + } + it("Can apply a min and max length to a string field") { + TestHelpers.openApiTestAllSerializers("T0061__string_min_max_constraints.json") { stringConstraints() } + } + it("Can apply a pattern to a string field") { + TestHelpers.openApiTestAllSerializers("T0062__string_pattern_constraints.json") { stringPatternConstraints() } + } + it("Can apply a content encoding and media type to a string field") { + TestHelpers.openApiTestAllSerializers("T0063__string_content_encoding_constraints.json") { + stringContentEncodingConstraints() + } + } + it("Can apply constraints to an array field") { + TestHelpers.openApiTestAllSerializers("T0064__array_constraints.json") { arrayConstraints() } + } + } +}) diff --git a/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumDefaultsTest.kt b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumDefaultsTest.kt new file mode 100644 index 000000000..f68261887 --- /dev/null +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumDefaultsTest.kt @@ -0,0 +1,13 @@ +package io.bkbn.kompendium.core + +import io.bkbn.kompendium.core.fixtures.TestHelpers +import io.bkbn.kompendium.core.util.defaultParameter +import io.kotest.core.spec.style.DescribeSpec + +class KompendiumDefaultsTest : DescribeSpec({ + describe("Defaults") { + it("Can generate a default parameter value") { + TestHelpers.openApiTestAllSerializers("T0022__query_with_default_parameter.json") { defaultParameter() } + } + } +}) diff --git a/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumEnrichmentTest.kt b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumEnrichmentTest.kt new file mode 100644 index 000000000..e86f35140 --- /dev/null +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumEnrichmentTest.kt @@ -0,0 +1,35 @@ +package io.bkbn.kompendium.core + +import io.bkbn.kompendium.core.fixtures.TestHelpers +import io.bkbn.kompendium.core.util.enrichedComplexGenericType +import io.bkbn.kompendium.core.util.enrichedGenericResponse +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.enrichedTopLevelCollection +import io.kotest.core.spec.style.DescribeSpec + +class KompendiumEnrichmentTest : DescribeSpec({ + describe("Enrichment") { + it("Can enrich a simple request") { + TestHelpers.openApiTestAllSerializers("T0055__enriched_simple_request.json") { enrichedSimpleRequest() } + } + it("Can enrich a simple response") { + TestHelpers.openApiTestAllSerializers("T0058__enriched_simple_response.json") { enrichedSimpleResponse() } + } + it("Can enrich a nested collection") { + TestHelpers.openApiTestAllSerializers("T0056__enriched_nested_collection.json") { enrichedNestedCollection() } + } + it("Can enrich a complex generic type") { + TestHelpers.openApiTestAllSerializers( + "T0057__enriched_complex_generic_type.json" + ) { enrichedComplexGenericType() } + } + it("Can enrich a generic object") { + TestHelpers.openApiTestAllSerializers("T0067__enriched_generic_object.json") { enrichedGenericResponse() } + } + it("Can enrich a top level list type") { + TestHelpers.openApiTestAllSerializers("T0077__enriched_top_level_list.json") { enrichedTopLevelCollection() } + } + } +}) diff --git a/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumErrorHandlingTest.kt b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumErrorHandlingTest.kt new file mode 100644 index 000000000..0191b3d4e --- /dev/null +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumErrorHandlingTest.kt @@ -0,0 +1,45 @@ +package io.bkbn.kompendium.core + +import io.bkbn.kompendium.core.fixtures.TestHelpers +import io.bkbn.kompendium.core.util.dateTimeString +import io.bkbn.kompendium.core.util.samePathSameMethod +import io.bkbn.kompendium.json.schema.exception.UnknownSchemaException +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.should +import io.kotest.matchers.string.startWith +import io.ktor.server.application.install +import io.ktor.server.auth.Authentication +import io.ktor.server.auth.UserIdPrincipal +import io.ktor.server.auth.basic + +class KompendiumErrorHandlingTest : DescribeSpec({ + describe("Error Handling") { + it("Throws a clear exception when an unidentified type is encountered") { + val exception = shouldThrow { + TestHelpers.openApiTestAllSerializers( + "" + ) { dateTimeString() } + } + exception.message should startWith("An unknown type was encountered: class java.time.Instant") + } + it("Throws an exception when same method for same path has been previously registered") { + val exception = shouldThrow { + TestHelpers.openApiTestAllSerializers( + snapshotName = "", + applicationSetup = { + install(Authentication) { + basic("basic") { + realm = "Ktor Server" + validate { UserIdPrincipal("Placeholder") } + } + } + }, + ) { + samePathSameMethod() + } + } + exception.message should startWith("A route has already been registered for path: /test/{a} and method: GET") + } + } +}) diff --git a/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumExamplesTest.kt b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumExamplesTest.kt new file mode 100644 index 000000000..c42f05f67 --- /dev/null +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumExamplesTest.kt @@ -0,0 +1,27 @@ +package io.bkbn.kompendium.core + +import io.bkbn.kompendium.core.fixtures.TestHelpers +import io.bkbn.kompendium.core.util.exampleParams +import io.bkbn.kompendium.core.util.exampleSummaryAndDescription +import io.bkbn.kompendium.core.util.optionalReqExample +import io.bkbn.kompendium.core.util.reqRespExamples +import io.kotest.core.spec.style.DescribeSpec + +class KompendiumExamplesTest : DescribeSpec({ + describe("Examples") { + it("Can generate example response and request bodies") { + TestHelpers.openApiTestAllSerializers("T0020__example_req_and_resp.json") { reqRespExamples() } + } + it("Can describe example parameters") { + TestHelpers.openApiTestAllSerializers("T0021__example_parameters.json") { exampleParams() } + } + it("Can generate example optional request body") { + TestHelpers.openApiTestAllSerializers("T0069__example_optional_req.json") { optionalReqExample() } + } + it("Can generate example summary and description") { + TestHelpers.openApiTestAllSerializers( + "T0075__example_summary_and_description.json" + ) { exampleSummaryAndDescription() } + } + } +}) diff --git a/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumExceptionsTest.kt b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumExceptionsTest.kt new file mode 100644 index 000000000..5a91d5ff5 --- /dev/null +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumExceptionsTest.kt @@ -0,0 +1,27 @@ +package io.bkbn.kompendium.core + +import io.bkbn.kompendium.core.fixtures.TestHelpers +import io.bkbn.kompendium.core.util.genericException +import io.bkbn.kompendium.core.util.multipleExceptions +import io.bkbn.kompendium.core.util.polymorphicException +import io.bkbn.kompendium.core.util.singleException +import io.kotest.core.spec.style.DescribeSpec + +class KompendiumExceptionsTest : DescribeSpec({ + describe("Exceptions") { + it("Can add an exception status code to a response") { + TestHelpers.openApiTestAllSerializers("T0016__notarized_get_with_exception_response.json") { singleException() } + } + it("Can support multiple response codes") { + TestHelpers.openApiTestAllSerializers("T0017__notarized_get_with_multiple_exception_responses.json") { + multipleExceptions() + } + } + it("Can add a polymorphic exception response") { + TestHelpers.openApiTestAllSerializers("T0018__polymorphic_error_status_codes.json") { polymorphicException() } + } + it("Can add a generic exception response") { + TestHelpers.openApiTestAllSerializers("T0019__generic_exception.json") { genericException() } + } + } +}) diff --git a/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumFreeFormTest.kt b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumFreeFormTest.kt new file mode 100644 index 000000000..b0f45b082 --- /dev/null +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumFreeFormTest.kt @@ -0,0 +1,7 @@ +package io.bkbn.kompendium.core + +import io.kotest.core.spec.style.DescribeSpec + +class KompendiumFreeFormTest : DescribeSpec({ + // todo Assess strategies here +}) diff --git a/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumPolymorphismAndGenericsTest.kt b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumPolymorphismAndGenericsTest.kt new file mode 100644 index 000000000..48d1d6326 --- /dev/null +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumPolymorphismAndGenericsTest.kt @@ -0,0 +1,67 @@ +package io.bkbn.kompendium.core + +import io.bkbn.kompendium.core.fixtures.TestHelpers +import io.bkbn.kompendium.core.util.genericPolymorphicResponse +import io.bkbn.kompendium.core.util.genericPolymorphicResponseMultipleImpls +import io.bkbn.kompendium.core.util.gnarlyGenericResponse +import io.bkbn.kompendium.core.util.nestedGenericCollection +import io.bkbn.kompendium.core.util.nestedGenericMultipleParamsCollection +import io.bkbn.kompendium.core.util.nestedGenericResponse +import io.bkbn.kompendium.core.util.overrideSealedTypeIdentifier +import io.bkbn.kompendium.core.util.polymorphicCollectionResponse +import io.bkbn.kompendium.core.util.polymorphicMapResponse +import io.bkbn.kompendium.core.util.polymorphicResponse +import io.bkbn.kompendium.core.util.simpleGenericResponse +import io.bkbn.kompendium.core.util.subtypeNotCompleteSetOfParentProperties +import io.kotest.core.spec.style.DescribeSpec + +class KompendiumPolymorphismAndGenericsTest : DescribeSpec({ + describe("Polymorphism and Generics") { + it("can generate a polymorphic response type") { + TestHelpers.openApiTestAllSerializers("T0027__polymorphic_response.json") { polymorphicResponse() } + } + it("Can generate a collection with polymorphic response type") { + TestHelpers.openApiTestAllSerializers("T0028__polymorphic_list_response.json") { polymorphicCollectionResponse() } + } + it("Can generate a map with a polymorphic response type") { + TestHelpers.openApiTestAllSerializers("T0029__polymorphic_map_response.json") { polymorphicMapResponse() } + } + it("Can generate a response type with a generic type") { + TestHelpers.openApiTestAllSerializers("T0030__simple_generic_response.json") { simpleGenericResponse() } + } + it("Can generate a response type with a nested generic type") { + TestHelpers.openApiTestAllSerializers("T0031__nested_generic_response.json") { nestedGenericResponse() } + } + it("Can generate a polymorphic response type with generics") { + TestHelpers.openApiTestAllSerializers( + "T0032__polymorphic_response_with_generics.json" + ) { genericPolymorphicResponse() } + } + it("Can handle an absolutely psycho inheritance test") { + TestHelpers.openApiTestAllSerializers("T0033__crazy_polymorphic_example.json") { + genericPolymorphicResponseMultipleImpls() + } + } + it("Can support nested generic collections") { + TestHelpers.openApiTestAllSerializers("T0039__nested_generic_collection.json") { nestedGenericCollection() } + } + it("Can support nested generics with multiple type parameters") { + TestHelpers.openApiTestAllSerializers("T0040__nested_generic_multiple_type_params.json") { + nestedGenericMultipleParamsCollection() + } + } + it("Can handle a really gnarly generic example") { + TestHelpers.openApiTestAllSerializers("T0043__gnarly_generic_example.json") { gnarlyGenericResponse() } + } + it("Can override the type name for a sealed interface implementation") { + TestHelpers.openApiTestAllSerializers("T0070__sealed_interface_type_name_override.json") { + overrideSealedTypeIdentifier() + } + } + it("Can serialize an object where the subtype is not a complete set of parent properties") { + TestHelpers.openApiTestAllSerializers("T0071__subtype_not_complete_set_of_parent_properties.json") { + subtypeNotCompleteSetOfParentProperties() + } + } + } +}) diff --git a/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumRequiredFieldsTest.kt b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumRequiredFieldsTest.kt new file mode 100644 index 000000000..ee2793de8 --- /dev/null +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumRequiredFieldsTest.kt @@ -0,0 +1,25 @@ +package io.bkbn.kompendium.core + +import io.bkbn.kompendium.core.fixtures.TestHelpers +import io.bkbn.kompendium.core.util.defaultField +import io.bkbn.kompendium.core.util.nonRequiredParam +import io.bkbn.kompendium.core.util.nullableField +import io.bkbn.kompendium.core.util.requiredParams +import io.kotest.core.spec.style.DescribeSpec + +class KompendiumRequiredFieldsTest : DescribeSpec({ + describe("Required Fields") { + it("Marks a parameter as required if there is no default and it is not marked nullable") { + TestHelpers.openApiTestAllSerializers("T0023__required_param.json") { requiredParams() } + } + it("Can mark a parameter as not required") { + TestHelpers.openApiTestAllSerializers("T0024__non_required_param.json") { nonRequiredParam() } + } + it("Does not mark a field as required if a default value is provided") { + TestHelpers.openApiTestAllSerializers("T0025__default_field.json") { defaultField() } + } + it("Does not mark a nullable field as required") { + TestHelpers.openApiTestAllSerializers("T0026__nullable_field.json") { nullableField() } + } + } +}) diff --git a/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumRouteParsingTest.kt b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumRouteParsingTest.kt new file mode 100644 index 000000000..666943904 --- /dev/null +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumRouteParsingTest.kt @@ -0,0 +1,29 @@ +package io.bkbn.kompendium.core + +import io.bkbn.kompendium.core.fixtures.TestHelpers +import io.bkbn.kompendium.core.util.nestedUnderRoot +import io.bkbn.kompendium.core.util.paramWrapper +import io.bkbn.kompendium.core.util.rootRoute +import io.bkbn.kompendium.core.util.simplePathParsing +import io.bkbn.kompendium.core.util.trailingSlash +import io.kotest.core.spec.style.DescribeSpec + +class KompendiumRouteParsingTest : DescribeSpec({ + describe("Route Parsing") { + it("Can parse a simple path and store it under the expected route") { + TestHelpers.openApiTestAllSerializers("T0012__path_parser.json") { simplePathParsing() } + } + it("Can notarize the root route") { + TestHelpers.openApiTestAllSerializers("T0013__root_route.json") { rootRoute() } + } + it("Can notarize a route under the root module without appending trailing slash") { + TestHelpers.openApiTestAllSerializers("T0014__nested_under_root.json") { nestedUnderRoot() } + } + it("Can notarize a route with a trailing slash") { + TestHelpers.openApiTestAllSerializers("T0015__trailing_slash.json") { trailingSlash() } + } + it("Can notarize a route with a parameter") { + TestHelpers.openApiTestAllSerializers("T0068__param_wrapper.json") { paramWrapper() } + } + } +}) 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 299e953b4..072f58002 100644 --- a/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt @@ -1,39 +1,13 @@ 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 -import io.bkbn.kompendium.core.util.customScopesOnSiblingPathOperations -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.enrichedGenericResponse -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.exampleSummaryAndDescription import io.bkbn.kompendium.core.util.fieldOutsideConstructor -import io.bkbn.kompendium.core.util.genericException -import io.bkbn.kompendium.core.util.genericPolymorphicResponse -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 -import io.bkbn.kompendium.core.util.nestedGenericMultipleParamsCollection -import io.bkbn.kompendium.core.util.nestedGenericResponse import io.bkbn.kompendium.core.util.nestedTypeName -import io.bkbn.kompendium.core.util.nestedUnderRoot import io.bkbn.kompendium.core.util.nonRequiredParam import io.bkbn.kompendium.core.util.nonRequiredParams import io.bkbn.kompendium.core.util.notarizedDelete @@ -47,68 +21,34 @@ import io.bkbn.kompendium.core.util.nullableEnumField import io.bkbn.kompendium.core.util.nullableField import io.bkbn.kompendium.core.util.nullableNestedObject import io.bkbn.kompendium.core.util.nullableReference -import io.bkbn.kompendium.core.util.optionalReqExample import io.bkbn.kompendium.core.util.overrideMediaTypes -import io.bkbn.kompendium.core.util.overrideSealedTypeIdentifier -import io.bkbn.kompendium.core.util.paramWrapper -import io.bkbn.kompendium.core.util.polymorphicCollectionResponse -import io.bkbn.kompendium.core.util.polymorphicException -import io.bkbn.kompendium.core.util.polymorphicMapResponse -import io.bkbn.kompendium.core.util.polymorphicResponse import io.bkbn.kompendium.core.util.postNoReqBody import io.bkbn.kompendium.core.util.primitives -import io.bkbn.kompendium.core.util.reqRespExamples import io.bkbn.kompendium.core.util.requiredParams import io.bkbn.kompendium.core.util.responseHeaders import io.bkbn.kompendium.core.util.returnsEnumList import io.bkbn.kompendium.core.util.returnsList -import io.bkbn.kompendium.core.util.rootRoute import io.bkbn.kompendium.core.util.samePathDifferentMethodsAndAuth -import io.bkbn.kompendium.core.util.samePathSameMethod -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.subtypeNotCompleteSetOfParentProperties import io.bkbn.kompendium.core.util.topLevelNullable -import io.bkbn.kompendium.core.util.trailingSlash import io.bkbn.kompendium.core.util.unbackedFieldsResponse import io.bkbn.kompendium.core.util.withOperationId -import io.bkbn.kompendium.json.schema.definition.TypeDefinition -import io.bkbn.kompendium.json.schema.exception.UnknownSchemaException import io.bkbn.kompendium.oas.component.Components -import io.bkbn.kompendium.oas.security.ApiKeyAuth import io.bkbn.kompendium.oas.security.BasicAuth -import io.bkbn.kompendium.oas.security.BearerAuth -import io.bkbn.kompendium.oas.security.OAuth import io.bkbn.kompendium.oas.serialization.KompendiumSerializersModule -import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.should -import io.kotest.matchers.string.startWith -import io.ktor.client.HttpClient -import io.ktor.client.engine.cio.CIO -import io.ktor.http.HttpMethod import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.call import io.ktor.server.application.install import io.ktor.server.auth.Authentication -import io.ktor.server.auth.OAuthServerSettings import io.ktor.server.auth.UserIdPrincipal import io.ktor.server.auth.basic -import io.ktor.server.auth.jwt.jwt -import io.ktor.server.auth.oauth import io.ktor.server.response.respondText import io.ktor.server.routing.get import io.ktor.server.routing.route import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.net.URI -import java.time.Instant -import kotlin.reflect.typeOf class KompendiumTest : DescribeSpec({ describe("Notarized Open API Metadata Tests") { @@ -155,56 +95,6 @@ class KompendiumTest : DescribeSpec({ openApiTestAllSerializers("T0066__notarized_get_with_response_headers.json") { responseHeaders() } } } - describe("Route Parsing") { - it("Can parse a simple path and store it under the expected route") { - openApiTestAllSerializers("T0012__path_parser.json") { simplePathParsing() } - } - it("Can notarize the root route") { - openApiTestAllSerializers("T0013__root_route.json") { rootRoute() } - } - it("Can notarize a route under the root module without appending trailing slash") { - openApiTestAllSerializers("T0014__nested_under_root.json") { nestedUnderRoot() } - } - it("Can notarize a route with a trailing slash") { - openApiTestAllSerializers("T0015__trailing_slash.json") { trailingSlash() } - } - it("Can notarize a route with a parameter") { - openApiTestAllSerializers("T0068__param_wrapper.json") { paramWrapper() } - } - } - describe("Exceptions") { - it("Can add an exception status code to a response") { - openApiTestAllSerializers("T0016__notarized_get_with_exception_response.json") { singleException() } - } - it("Can support multiple response codes") { - openApiTestAllSerializers("T0017__notarized_get_with_multiple_exception_responses.json") { multipleExceptions() } - } - it("Can add a polymorphic exception response") { - openApiTestAllSerializers("T0018__polymorphic_error_status_codes.json") { polymorphicException() } - } - it("Can add a generic exception response") { - openApiTestAllSerializers("T0019__generic_exception.json") { genericException() } - } - } - describe("Examples") { - it("Can generate example response and request bodies") { - openApiTestAllSerializers("T0020__example_req_and_resp.json") { reqRespExamples() } - } - it("Can describe example parameters") { - openApiTestAllSerializers("T0021__example_parameters.json") { exampleParams() } - } - it("Can generate example optional request body") { - openApiTestAllSerializers("T0069__example_optional_req.json") { optionalReqExample() } - } - it("Can generate example summary and description") { - openApiTestAllSerializers("T0075__example_summary_and_description.json") { exampleSummaryAndDescription() } - } - } - describe("Defaults") { - it("Can generate a default parameter value") { - openApiTestAllSerializers("T0022__query_with_default_parameter.json") { defaultParameter() } - } - } describe("Required Fields") { it("Marks a parameter as required if there is no default and it is not marked nullable") { openApiTestAllSerializers("T0023__required_param.json") { requiredParams() } @@ -219,377 +109,121 @@ class KompendiumTest : DescribeSpec({ openApiTestAllSerializers("T0026__nullable_field.json") { nullableField() } } } - describe("Polymorphism and Generics") { - it("can generate a polymorphic response type") { - openApiTestAllSerializers("T0027__polymorphic_response.json") { polymorphicResponse() } + describe("Custom Serializable Reader tests") { + it("Can support ignoring fields") { + openApiTestAllSerializers("T0048__ignored_property.json") { ignoredFieldsResponse() } } - it("Can generate a collection with polymorphic response type") { - openApiTestAllSerializers("T0028__polymorphic_list_response.json") { polymorphicCollectionResponse() } + it("Can support un-backed fields") { + openApiTestAllSerializers("T0049__unbacked_property.json") { unbackedFieldsResponse() } } - it("Can generate a map with a polymorphic response type") { - openApiTestAllSerializers("T0029__polymorphic_map_response.json") { polymorphicMapResponse() } + it("Can support custom named fields") { + openApiTestAllSerializers("T0050__custom_named_property.json") { customFieldNameResponse() } } - it("Can generate a response type with a generic type") { - openApiTestAllSerializers("T0030__simple_generic_response.json") { simpleGenericResponse() } + } + describe("Miscellaneous") { + xit("Can generate the necessary ReDoc home page") { + // TODO apiFunctionalityTest(getFileSnapshot("redoc.html"), "/docs") { returnsList() } } - it("Can generate a response type with a nested generic type") { - openApiTestAllSerializers("T0031__nested_generic_response.json") { nestedGenericResponse() } + it("Can add an operation id to a notarized route") { + openApiTestAllSerializers("T0034__notarized_get_with_operation_id.json") { withOperationId() } } - it("Can generate a polymorphic response type with generics") { - openApiTestAllSerializers("T0032__polymorphic_response_with_generics.json") { genericPolymorphicResponse() } + xit("Can add an undeclared field") { + // TODO openApiTestAllSerializers("undeclared_field.json") { undeclaredType() } } - it("Can handle an absolutely psycho inheritance test") { - openApiTestAllSerializers("T0033__crazy_polymorphic_example.json") { genericPolymorphicResponseMultipleImpls() } + it("Can add a custom header parameter with a name override") { + openApiTestAllSerializers("T0035__override_parameter_name.json") { headerParameter() } } - it("Can support nested generic collections") { - openApiTestAllSerializers("T0039__nested_generic_collection.json") { nestedGenericCollection() } + xit("Can override field name") { + // TODO Assess strategies here } - it("Can support nested generics with multiple type parameters") { - openApiTestAllSerializers("T0040__nested_generic_multiple_type_params.json") { - nestedGenericMultipleParamsCollection() - } + it("Can serialize a recursive type") { + openApiTestAllSerializers("T0042__simple_recursive.json") { simpleRecursive() } } - it("Can handle a really gnarly generic example") { - openApiTestAllSerializers("T0043__gnarly_generic_example.json") { gnarlyGenericResponse() } + it("Nullable fields do not lead to doom") { + openApiTestAllSerializers("T0036__nullable_fields.json") { nullableNestedObject() } } - it("Can override the type name for a sealed interface implementation") { - openApiTestAllSerializers("T0070__sealed_interface_type_name_override.json") { overrideSealedTypeIdentifier() } + it("Can have a nullable enum as a member field") { + openApiTestAllSerializers("T0037__nullable_enum_field.json") { nullableEnumField() } } - it("Can serialize an object where the subtype is not a complete set of parent properties") { - openApiTestAllSerializers("T0071__subtype_not_complete_set_of_parent_properties.json") { - subtypeNotCompleteSetOfParentProperties() - } + it("Can have a list of enums as a field") { + openApiTestAllSerializers("T0076__list_of_enums.json") { returnsEnumList() } } - describe("Custom Serializable Reader tests") { - it("Can support ignoring fields") { - openApiTestAllSerializers("T0048__ignored_property.json") { ignoredFieldsResponse() } - } - it("Can support un-backed fields") { - openApiTestAllSerializers("T0049__unbacked_property.json") { unbackedFieldsResponse() } - } - it("Can support custom named fields") { - openApiTestAllSerializers("T0050__custom_named_property.json") { customFieldNameResponse() } - } + it("Can have a nullable reference without impacting base type") { + openApiTestAllSerializers("T0041__nullable_reference.json") { nullableReference() } } - describe("Miscellaneous") { - xit("Can generate the necessary ReDoc home page") { - // TODO apiFunctionalityTest(getFileSnapshot("redoc.html"), "/docs") { returnsList() } - } - it("Can add an operation id to a notarized route") { - openApiTestAllSerializers("T0034__notarized_get_with_operation_id.json") { withOperationId() } - } - xit("Can add an undeclared field") { - // TODO openApiTestAllSerializers("undeclared_field.json") { undeclaredType() } - } - it("Can add a custom header parameter with a name override") { - openApiTestAllSerializers("T0035__override_parameter_name.json") { headerParameter() } - } - xit("Can override field name") { - // TODO Assess strategies here - } - it("Can serialize a recursive type") { - openApiTestAllSerializers("T0042__simple_recursive.json") { simpleRecursive() } - } - it("Nullable fields do not lead to doom") { - openApiTestAllSerializers("T0036__nullable_fields.json") { nullableNestedObject() } - } - it("Can have a nullable enum as a member field") { - openApiTestAllSerializers("T0037__nullable_enum_field.json") { nullableEnumField() } - } - it("Can have a list of enums as a field") { - openApiTestAllSerializers("T0076__list_of_enums.json") { returnsEnumList() } - } - it("Can have a nullable reference without impacting base type") { - openApiTestAllSerializers("T0041__nullable_reference.json") { nullableReference() } - } - it("Can handle nested type names") { - openApiTestAllSerializers("T0044__nested_type_name.json") { nestedTypeName() } - } - it("Can handle top level nullable types") { - openApiTestAllSerializers("T0051__top_level_nullable.json") { topLevelNullable() } - } - it("Can handle multiple registrations for different methods with the same path and different auth") { - openApiTestAllSerializers( - "T0053__same_path_different_methods_and_auth.json", - applicationSetup = { - install(Authentication) { - basic("basic") { - realm = "Ktor Server" - validate { UserIdPrincipal("Placeholder") } - } + it("Can handle nested type names") { + openApiTestAllSerializers("T0044__nested_type_name.json") { nestedTypeName() } + } + it("Can handle top level nullable types") { + openApiTestAllSerializers("T0051__top_level_nullable.json") { topLevelNullable() } + } + it("Can handle multiple registrations for different methods with the same path and different auth") { + openApiTestAllSerializers( + "T0053__same_path_different_methods_and_auth.json", + applicationSetup = { + install(Authentication) { + basic("basic") { + realm = "Ktor Server" + validate { UserIdPrincipal("Placeholder") } } - }, - specOverrides = { - this.copy( - components = Components( - securitySchemes = mutableMapOf( - "basic" to BasicAuth() - ) + } + }, + specOverrides = { + this.copy( + components = Components( + securitySchemes = mutableMapOf( + "basic" to BasicAuth() ) ) - } - ) { samePathDifferentMethodsAndAuth() } - } - it("Can generate paths without application root-path") { - openApiTestAllSerializers( - "T0054__app_with_rootpath.json", - applicationEnvironmentBuilder = { - rootPath = "/example" - }, - specOverrides = { - copy( - servers = servers.map { it.copy(url = URI("${it.url}/example")) }.toMutableList() - ) - } - ) { notarizedGet() } - } - it("Can apply a custom serialization strategy to the openapi document") { - val customJsonEncoder = Json { - serializersModule = KompendiumSerializersModule.module - encodeDefaults = true - explicitNulls = false + ) } - openApiTestAllSerializers( - snapshotName = "T0072__custom_serialization_strategy.json", - notarizedApplicationConfigOverrides = { - specRoute = { spec, routing -> - routing { - route("/openapi.json") { - get { - call.response.headers.append("Content-Type", "application/json") - call.respondText { customJsonEncoder.encodeToString(spec) } - } - } - } - } - }, - contentNegotiation = { - json( - Json { - encodeDefaults = true - explicitNulls = true - } - ) - } - ) { notarizedGet() } - } - it("Can serialize a data class with a field outside of the constructor") { - openApiTestAllSerializers("T0073__data_class_with_field_outside_constructor.json") { fieldOutsideConstructor() } - } + ) { samePathDifferentMethodsAndAuth() } } - describe("Error Handling") { - it("Throws a clear exception when an unidentified type is encountered") { - val exception = shouldThrow { openApiTestAllSerializers("") { dateTimeString() } } - exception.message should startWith("An unknown type was encountered: class java.time.Instant") - } - it("Throws an exception when same method for same path has been previously registered") { - val exception = shouldThrow { - openApiTestAllSerializers( - snapshotName = "", - applicationSetup = { - install(Authentication) { - basic("basic") { - realm = "Ktor Server" - validate { UserIdPrincipal("Placeholder") } - } - } - }, - ) { - samePathSameMethod() - } + it("Can generate paths without application root-path") { + openApiTestAllSerializers( + "T0054__app_with_rootpath.json", + applicationEnvironmentBuilder = { + rootPath = "/example" + }, + specOverrides = { + copy( + servers = servers.map { it.copy(url = URI("${it.url}/example")) }.toMutableList() + ) } - exception.message should startWith("A route has already been registered for path: /test/{a} and method: GET") - } + ) { notarizedGet() } } - describe("Formats") { - it("Can set a format for a simple type schema") { - openApiTestAllSerializers( - snapshotName = "T0038__formatted_date_time_string.json", - customTypes = mapOf(typeOf() to TypeDefinition(type = "string", format = "date")) - ) { dateTimeString() } + it("Can apply a custom serialization strategy to the openapi document") { + val customJsonEncoder = Json { + serializersModule = KompendiumSerializersModule.module + encodeDefaults = true + explicitNulls = false } - } - describe("Free Form") { - // todo Assess strategies here - } - describe("Authentication") { - it("Can add a default auth config by default") { - openApiTestAllSerializers( - snapshotName = "T0045__default_auth_config.json", - applicationSetup = { - install(Authentication) { - basic("basic") { - realm = "Ktor Server" - validate { UserIdPrincipal("Placeholder") } - } - } - }, - specOverrides = { - this.copy( - components = Components( - securitySchemes = mutableMapOf( - "basic" to BasicAuth() - ) - ) - ) - } - ) { defaultAuthConfig() } - } - it("Can provide custom auth config with proper scopes") { - openApiTestAllSerializers( - snapshotName = "T0046__custom_auth_config.json", - applicationSetup = { - install(Authentication) { - oauth("auth-oauth-google") { - urlProvider = { "http://localhost:8080/callback" } - providerLookup = { - OAuthServerSettings.OAuth2ServerSettings( - name = "google", - authorizeUrl = "https://accounts.google.com/o/oauth2/auth", - accessTokenUrl = "https://accounts.google.com/o/oauth2/token", - requestMethod = HttpMethod.Post, - clientId = "DUMMY_VAL", - clientSecret = "DUMMY_VAL", - defaultScopes = listOf("https://www.googleapis.com/auth/userinfo.profile"), - extraTokenParameters = listOf("access_type" to "offline") - ) - } - client = HttpClient(CIO) - } - } - }, - specOverrides = { - this.copy( - components = Components( - securitySchemes = mutableMapOf( - "auth-oauth-google" to OAuth( - flows = OAuth.Flows( - implicit = OAuth.Flows.Implicit( - authorizationUrl = "https://accounts.google.com/o/oauth2/auth", - scopes = mapOf( - "write:pets" to "modify pets in your account", - "read:pets" to "read your pets" - ) - ) - ) - ) - ) - ) - ) - } - ) { customAuthConfig() } - } - it("Can provide multiple authentication strategies") { - openApiTestAllSerializers( - snapshotName = "T0047__multiple_auth_strategies.json", - applicationSetup = { - install(Authentication) { - apiKey("api-key") { - headerName = "X-API-KEY" - validate { - UserIdPrincipal("Placeholder") + openApiTestAllSerializers( + snapshotName = "T0072__custom_serialization_strategy.json", + notarizedApplicationConfigOverrides = { + specRoute = { spec, routing -> + routing { + route("/openapi.json") { + get { + call.response.headers.append("Content-Type", "application/json") + call.respondText { customJsonEncoder.encodeToString(spec) } } } - jwt("jwt") { - realm = "Server" - } } - }, - specOverrides = { - this.copy( - components = Components( - securitySchemes = mutableMapOf( - "jwt" to BearerAuth("JWT"), - "api-key" to ApiKeyAuth(ApiKeyAuth.ApiKeyLocation.HEADER, "X-API-KEY") - ) - ) - ) } - ) { multipleAuthStrategies() } - } - it("Can provide different scopes on path operations in the same route") { - openApiTestAllSerializers( - snapshotName = "T0074__auth_on_specific_path_operation.json", - applicationSetup = { - install(Authentication) { - oauth("auth-oauth-google") { - urlProvider = { "http://localhost:8080/callback" } - providerLookup = { - OAuthServerSettings.OAuth2ServerSettings( - name = "google", - authorizeUrl = "https://accounts.google.com/o/oauth2/auth", - accessTokenUrl = "https://accounts.google.com/o/oauth2/token", - requestMethod = HttpMethod.Post, - clientId = "DUMMY_VAL", - clientSecret = "DUMMY_VAL", - defaultScopes = listOf("https://www.googleapis.com/auth/userinfo.profile"), - extraTokenParameters = listOf("access_type" to "offline") - ) - } - client = HttpClient(CIO) - } + }, + contentNegotiation = { + json( + Json { + encodeDefaults = true + explicitNulls = true } - }, - specOverrides = { - this.copy( - components = Components( - securitySchemes = mutableMapOf( - "auth-oauth-google" to OAuth( - flows = OAuth.Flows( - implicit = OAuth.Flows.Implicit( - authorizationUrl = "https://accounts.google.com/o/oauth2/auth", - scopes = mapOf( - "write:pets" to "modify pets in your account", - "read:pets" to "read your pets" - ) - ) - ) - ) - ) - ) - ) - } - ) { customScopesOnSiblingPathOperations() } - } - } - 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() } - } - it("Can enrich a generic object") { - openApiTestAllSerializers("T0067__enriched_generic_object.json") { enrichedGenericResponse() } - } - } - 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() } - } + ) { notarizedGet() } + } + it("Can serialize a data class with a field outside of the constructor") { + openApiTestAllSerializers("T0073__data_class_with_field_outside_constructor.json") { fieldOutsideConstructor() } } } }) diff --git a/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTypeFormatTest.kt b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTypeFormatTest.kt new file mode 100644 index 000000000..43fee2722 --- /dev/null +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTypeFormatTest.kt @@ -0,0 +1,19 @@ +package io.bkbn.kompendium.core + +import io.bkbn.kompendium.core.fixtures.TestHelpers +import io.bkbn.kompendium.core.util.dateTimeString +import io.bkbn.kompendium.json.schema.definition.TypeDefinition +import io.kotest.core.spec.style.DescribeSpec +import java.time.Instant +import kotlin.reflect.typeOf + +class KompendiumTypeFormatTest : DescribeSpec({ + describe("Formats") { + it("Can set a format for a simple type schema") { + TestHelpers.openApiTestAllSerializers( + snapshotName = "T0038__formatted_date_time_string.json", + customTypes = mapOf(typeOf() to TypeDefinition(type = "string", format = "date")) + ) { dateTimeString() } + } + } +}) 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 index cb6ad1d5a..fd6ebf6d4 100644 --- a/core/src/test/kotlin/io/bkbn/kompendium/core/util/Constraints.kt +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/util/Constraints.kt @@ -7,7 +7,10 @@ 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.bkbn.kompendium.enrichment.CollectionEnrichment +import io.bkbn.kompendium.enrichment.NumberEnrichment +import io.bkbn.kompendium.enrichment.ObjectEnrichment +import io.bkbn.kompendium.enrichment.StringEnrichment import io.ktor.http.HttpStatusCode import io.ktor.server.application.install import io.ktor.server.routing.Routing @@ -23,15 +26,17 @@ fun Routing.intConstraints() { responseCode(HttpStatusCode.OK) description("An int") responseType( - enrichment = TypeEnrichment("example") { + enrichment = ObjectEnrichment("example") { TestCreatedResponse::id { - minimum = 2 - maximum = 100 - multipleOf = 2 + NumberEnrichment("blah-blah-blah") { + minimum = 2 + maximum = 100 + multipleOf = 2 + } } + responseCode(HttpStatusCode.OK) } ) - responseCode(HttpStatusCode.OK) } } } @@ -48,11 +53,13 @@ fun Routing.doubleConstraints() { responseCode(HttpStatusCode.OK) description("A double") responseType( - enrichment = TypeEnrichment("example") { + enrichment = ObjectEnrichment("example") { DoubleResponse::payload { - minimum = 2.0 - maximum = 100.0 - multipleOf = 2.0 + NumberEnrichment("blah-blah-blah") { + minimum = 2.0 + maximum = 100.0 + multipleOf = 2.0 + } } } ) @@ -73,10 +80,12 @@ fun Routing.stringConstraints() { responseCode(HttpStatusCode.OK) description("A string") responseType( - enrichment = TypeEnrichment("example") { + enrichment = ObjectEnrichment("example") { TestNested::nesty { - maxLength = 10 - minLength = 2 + StringEnrichment("blah") { + maxLength = 10 + minLength = 2 + } } } ) @@ -97,9 +106,11 @@ fun Routing.stringPatternConstraints() { responseCode(HttpStatusCode.OK) description("A string") responseType( - enrichment = TypeEnrichment("example") { + enrichment = ObjectEnrichment("example") { TestNested::nesty { - pattern = "[a-z]+" + StringEnrichment("blah") { + pattern = "[a-z]+" + } } } ) @@ -120,10 +131,12 @@ fun Routing.stringContentEncodingConstraints() { responseCode(HttpStatusCode.OK) description("A string") responseType( - enrichment = TypeEnrichment("example") { + enrichment = ObjectEnrichment("example") { TestNested::nesty { - contentEncoding = "base64" - contentMediaType = "image/png" + StringEnrichment("blah") { + contentEncoding = "base64" + contentMediaType = "image/png" + } } } ) @@ -144,11 +157,13 @@ fun Routing.arrayConstraints() { responseCode(HttpStatusCode.OK) description("An array") responseType( - enrichment = TypeEnrichment("example") { + enrichment = ObjectEnrichment("example") { Page::content { - minItems = 2 - maxItems = 10 - uniqueItems = true + CollectionEnrichment("blah") { + minItems = 2 + maxItems = 10 + uniqueItems = true + } } } ) 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 index 546eb506a..eaf129fba 100644 --- a/core/src/test/kotlin/io/bkbn/kompendium/core/util/Enrichment.kt +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/util/Enrichment.kt @@ -1,16 +1,20 @@ package io.bkbn.kompendium.core.util import io.bkbn.kompendium.core.fixtures.ComplexRequest +import io.bkbn.kompendium.core.fixtures.GenericObject 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.fixtures.GenericObject 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.bkbn.kompendium.enrichment.CollectionEnrichment +import io.bkbn.kompendium.enrichment.MapEnrichment +import io.bkbn.kompendium.enrichment.NumberEnrichment +import io.bkbn.kompendium.enrichment.ObjectEnrichment +import io.bkbn.kompendium.enrichment.StringEnrichment import io.ktor.http.HttpStatusCode import io.ktor.server.application.install import io.ktor.server.routing.Routing @@ -24,9 +28,11 @@ fun Routing.enrichedSimpleResponse() { description(TestModules.defaultPathDescription) response { responseType( - enrichment = TypeEnrichment("simple") { + enrichment = ObjectEnrichment("simple") { TestResponse::c { - description = "A simple description" + StringEnrichment("blah-blah-blah") { + description = "A simple description" + } } } ) @@ -47,12 +53,16 @@ fun Routing.enrichedSimpleRequest() { description(TestModules.defaultPathDescription) request { requestType( - enrichment = TypeEnrichment("simple") { + enrichment = ObjectEnrichment("simple") { TestSimpleRequest::a { - description = "A simple description" + StringEnrichment("blah-blah-blah") { + description = "A simple description" + } } TestSimpleRequest::b { - deprecated = true + NumberEnrichment("blah-blah-blah") { + deprecated = true + } } } ) @@ -77,12 +87,52 @@ fun Routing.enrichedNestedCollection() { description(TestModules.defaultPathDescription) request { requestType( - enrichment = TypeEnrichment("simple") { + enrichment = ObjectEnrichment("simple") { ComplexRequest::tables { - description = "A nested item" - typeEnrichment = TypeEnrichment("nested") { - NestedComplexItem::name { - description = "A nested description" + CollectionEnrichment("blah-blah") { + description = "A nested description" + itemEnrichment = ObjectEnrichment("nested") { + NestedComplexItem::name { + StringEnrichment("beleheh") { + description = "A nested description" + } + } + } + } + } + } + ) + description("A test request") + } + response { + responseCode(HttpStatusCode.Created) + responseType() + description(TestModules.defaultResponseDescription) + } + } + } + } +} + +fun Routing.enrichedTopLevelCollection() { + route("/example") { + install(NotarizedRoute()) { + parameters = TestModules.defaultParams + post = PostInfo.builder { + summary(TestModules.defaultPathSummary) + description(TestModules.defaultPathDescription) + request { + requestType( + enrichment = CollectionEnrichment>("blah-blah") { + itemEnrichment = ObjectEnrichment("simple") { + TestSimpleRequest::a { + StringEnrichment("blah-blah-blah") { + description = "A simple description" + } + } + TestSimpleRequest::b { + NumberEnrichment("blah-blah-blah") { + deprecated = true } } } @@ -109,15 +159,21 @@ fun Routing.enrichedComplexGenericType() { description(TestModules.defaultPathDescription) request { requestType( - enrichment = TypeEnrichment("simple") { + enrichment = ObjectEnrichment("simple") { MultiNestedGenerics::content { - description = "Getting pretty crazy" - typeEnrichment = TypeEnrichment("nested") { - ComplexRequest::tables { - description = "A nested item" - typeEnrichment = TypeEnrichment("nested") { - NestedComplexItem::name { + MapEnrichment("blah") { + description = "A nested description" + valueEnrichment = ObjectEnrichment("nested") { + ComplexRequest::tables { + CollectionEnrichment("blah-blah") { description = "A nested description" + itemEnrichment = ObjectEnrichment("nested") { + NestedComplexItem::name { + StringEnrichment("beleheh") { + description = "A nested description" + } + } + } } } } @@ -145,15 +201,20 @@ fun Routing.enrichedGenericResponse() { description(TestModules.defaultPathDescription) response { responseType( - enrichment = TypeEnrichment("generic") { + enrichment = ObjectEnrichment("generic") { + description = "another description" GenericObject::data { - description = "A simple description" - typeEnrichment = TypeEnrichment("simple") { + ObjectEnrichment("simple") { + description = "also a description" TestSimpleRequest::a { - description = "A simple description" + StringEnrichment("blah-blah-blah") { + description = "A simple description" + } } TestSimpleRequest::b { - deprecated = true + NumberEnrichment("blah-blah-blah") { + deprecated = true + } } } } diff --git a/core/src/test/resources/T0056__enriched_nested_collection.json b/core/src/test/resources/T0056__enriched_nested_collection.json index afe86a8da..4079e4449 100644 --- a/core/src/test/resources/T0056__enriched_nested_collection.json +++ b/core/src/test/resources/T0056__enriched_nested_collection.json @@ -111,9 +111,9 @@ }, "tables": { "items": { - "$ref": "#/components/schemas/NestedComplexItem-nested" + "$ref": "#/components/schemas/NestedComplexItem-blah-blah" }, - "description": "A nested item", + "description": "A nested description", "type": "array" } }, @@ -158,6 +158,25 @@ "ONE", "TWO" ] + }, + "NestedComplexItem-blah-blah": { + "type": "object", + "properties": { + "alias": { + "additionalProperties": { + "$ref": "#/components/schemas/CrazyItem" + }, + "type": "object" + }, + "name": { + "type": "string", + "description": "A nested description" + } + }, + "required": [ + "alias", + "name" + ] } }, "securitySchemes": {} diff --git a/core/src/test/resources/T0057__enriched_complex_generic_type.json b/core/src/test/resources/T0057__enriched_complex_generic_type.json index 04369c030..bd81ddfdc 100644 --- a/core/src/test/resources/T0057__enriched_complex_generic_type.json +++ b/core/src/test/resources/T0057__enriched_complex_generic_type.json @@ -105,9 +105,9 @@ "properties": { "content": { "additionalProperties": { - "$ref": "#/components/schemas/ComplexRequest-nested" + "$ref": "#/components/schemas/ComplexRequest-blah" }, - "description": "Getting pretty crazy", + "description": "A nested description", "type": "object" } }, @@ -126,9 +126,9 @@ }, "tables": { "items": { - "$ref": "#/components/schemas/NestedComplexItem-nested" + "$ref": "#/components/schemas/NestedComplexItem-blah-blah" }, - "description": "A nested item", + "description": "A nested description", "type": "array" } }, @@ -173,6 +173,48 @@ "ONE", "TWO" ] + }, + "NestedComplexItem-blah-blah": { + "type": "object", + "properties": { + "alias": { + "additionalProperties": { + "$ref": "#/components/schemas/CrazyItem" + }, + "type": "object" + }, + "name": { + "type": "string", + "description": "A nested description" + } + }, + "required": [ + "alias", + "name" + ] + }, + "ComplexRequest-blah": { + "type": "object", + "properties": { + "amazingField": { + "type": "string" + }, + "org": { + "type": "string" + }, + "tables": { + "items": { + "$ref": "#/components/schemas/NestedComplexItem-blah-blah" + }, + "description": "A nested description", + "type": "array" + } + }, + "required": [ + "amazingField", + "org", + "tables" + ] } }, "securitySchemes": {} diff --git a/core/src/test/resources/T0067__enriched_generic_object.json b/core/src/test/resources/T0067__enriched_generic_object.json index 50abcb5bb..afec89260 100644 --- a/core/src/test/resources/T0067__enriched_generic_object.json +++ b/core/src/test/resources/T0067__enriched_generic_object.json @@ -58,7 +58,7 @@ "properties": { "data": { "$ref": "#/components/schemas/TestSimpleRequest-simple", - "description": "A simple description" + "description": "also a description" } }, "required": [ diff --git a/core/src/test/resources/T0077__enriched_top_level_list.json b/core/src/test/resources/T0077__enriched_top_level_list.json new file mode 100644 index 000000000..a003d112c --- /dev/null +++ b/core/src/test/resources/T0077__enriched_top_level_list.json @@ -0,0 +1,150 @@ +{ + "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/List-TestSimpleRequest-blah-blah" + } + } + }, + "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" + ] + }, + "TestSimpleRequest-blah-blah": { + "type": "object", + "properties": { + "a": { + "type": "string", + "description": "A simple description" + }, + "b": { + "type": "number", + "format": "int32", + "deprecated": true + } + }, + "required": [ + "a", + "b" + ] + }, + "List-TestSimpleRequest-blah-blah": { + "items": { + "$ref": "#/components/schemas/TestSimpleRequest-blah-blah" + }, + "type": "array" + } + }, + "securitySchemes": {} + }, + "security": [], + "tags": [] +} diff --git a/detekt.yml b/detekt.yml index 9fda1b066..68a59abcb 100644 --- a/detekt.yml +++ b/detekt.yml @@ -5,8 +5,8 @@ complexity: active: true functionThreshold: 10 constructorThreshold: 15 - ComplexMethod: - threshold: 20 + CyclomaticComplexMethod: + threshold: 15 style: MaxLineLength: excludes: ['**/test/**/*'] diff --git a/docs/concepts/enrichment.md b/docs/concepts/enrichment.md index 932b40f79..bc28212a9 100644 --- a/docs/concepts/enrichment.md +++ b/docs/concepts/enrichment.md @@ -1,10 +1,10 @@ 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 `enrichment` parameter of the relevant `requestType` or `responseType`. +`ObjectEnrichment` object and passing it to the `enrichment` parameter of the relevant `requestType` or `responseType`. ```kotlin data class SimpleData(val a: String, val b: Int? = null) -val myEnrichment = TypeEnrichment(id = "simple-enrichment") { +val myEnrichment = ObjectEnrichment(id = "simple-enrichment") { SimpleData::a { description = "This will update the field description" } @@ -56,7 +56,7 @@ and apply it inside a parent data class using the `typeEnrichment` property. 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") { +val childEnrichment = ObjectEnrichment(id = "child-enrichment") { ChildData::c { description = "This will update the field description of field c on child data" } @@ -65,7 +65,7 @@ val childEnrichment = TypeEnrichment(id = "child-enrichment") { } } -val parentEnrichment = TypeEnrichment(id = "parent-enrichment") { +val parentEnrichment = ObjectEnrichment(id = "parent-enrichment") { ParentData::a { description = "This will update the field description" } diff --git a/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/BooleanEnrichment.kt b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/BooleanEnrichment.kt new file mode 100644 index 000000000..054879700 --- /dev/null +++ b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/BooleanEnrichment.kt @@ -0,0 +1,16 @@ +package io.bkbn.kompendium.enrichment + +class BooleanEnrichment(override val id: String) : Enrichment { + override var deprecated: Boolean? = null + override var description: String? = null + + companion object { + inline operator fun invoke( + id: String, + init: BooleanEnrichment.() -> Unit + ): BooleanEnrichment { + val builder = BooleanEnrichment(id) + return builder.apply(init) + } + } +} diff --git a/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/CollectionEnrichment.kt b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/CollectionEnrichment.kt new file mode 100644 index 000000000..b49ab8f84 --- /dev/null +++ b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/CollectionEnrichment.kt @@ -0,0 +1,24 @@ +package io.bkbn.kompendium.enrichment + +class CollectionEnrichment(override val id: String) : TypeEnrichment { + + override var deprecated: Boolean? = null + override var description: String? = null + + var maxItems: Int? = null + var minItems: Int? = null + var uniqueItems: Boolean? = null + // TODO How to handle contains, minContains, maxContains? + + var itemEnrichment: TypeEnrichment<*>? = null + + companion object { + inline operator fun invoke( + id: String, + init: CollectionEnrichment.() -> Unit + ): CollectionEnrichment { + val builder = CollectionEnrichment(id) + return builder.apply(init) + } + } +} diff --git a/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/Enrichment.kt b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/Enrichment.kt index a8f1aa2bc..e4eafc0e3 100644 --- a/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/Enrichment.kt +++ b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/Enrichment.kt @@ -1,3 +1,7 @@ package io.bkbn.kompendium.enrichment -sealed interface Enrichment +sealed interface Enrichment { + val id: String + var deprecated: Boolean? + var description: String? +} diff --git a/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/MapEnrichment.kt b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/MapEnrichment.kt new file mode 100644 index 000000000..8cdbbe0ba --- /dev/null +++ b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/MapEnrichment.kt @@ -0,0 +1,23 @@ +package io.bkbn.kompendium.enrichment + +class MapEnrichment(override val id: String) : TypeEnrichment { + + override var deprecated: Boolean? = null + override var description: String? = null + + var maxProperties: Int? = null + var minProperties: Int? = null + + lateinit var keyEnrichment: StringEnrichment + lateinit var valueEnrichment: TypeEnrichment<*> + + companion object { + inline operator fun invoke( + id: String, + init: MapEnrichment.() -> Unit + ): MapEnrichment { + val builder = MapEnrichment(id) + return builder.apply(init) + } + } +} diff --git a/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/NumberEnrichment.kt b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/NumberEnrichment.kt new file mode 100644 index 000000000..edf9e6124 --- /dev/null +++ b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/NumberEnrichment.kt @@ -0,0 +1,19 @@ +package io.bkbn.kompendium.enrichment +class NumberEnrichment(override val id: String) : Enrichment { + + override var deprecated: Boolean? = null + override var description: String? = null + + var multipleOf: Number? = null + var maximum: Number? = null + var exclusiveMaximum: Number? = null + var minimum: Number? = null + var exclusiveMinimum: Number? = null + + companion object { + inline operator fun invoke(id: String, init: NumberEnrichment.() -> Unit): NumberEnrichment { + val builder = NumberEnrichment(id) + return builder.apply(init) + } + } +} diff --git a/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/ObjectEnrichment.kt b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/ObjectEnrichment.kt new file mode 100644 index 000000000..d0e9283c5 --- /dev/null +++ b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/ObjectEnrichment.kt @@ -0,0 +1,27 @@ +package io.bkbn.kompendium.enrichment + +import kotlin.reflect.KProperty1 + +class ObjectEnrichment(override val id: String) : TypeEnrichment { + + override var deprecated: Boolean? = null + override var description: String? = null + + private val _propertyEnrichments: MutableMap, Enrichment> = mutableMapOf() + + val propertyEnrichment: Map, Enrichment> + get() = _propertyEnrichments.toMap() + + operator fun KProperty1.invoke(init: () -> Enrichment) { + require(!_propertyEnrichments.containsKey(this)) { "${this.name} has already been registered" } + val enrichment = init.invoke() + _propertyEnrichments[this] = enrichment + } + + companion object { + inline operator fun invoke(id: String, init: ObjectEnrichment.() -> Unit): ObjectEnrichment { + val builder = ObjectEnrichment(id) + return builder.apply(init) + } + } +} diff --git a/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/PropertyEnrichment.kt b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/PropertyEnrichment.kt deleted file mode 100644 index 28786e955..000000000 --- a/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/PropertyEnrichment.kt +++ /dev/null @@ -1,36 +0,0 @@ -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/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/StringEnrichment.kt b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/StringEnrichment.kt new file mode 100644 index 000000000..11e866762 --- /dev/null +++ b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/StringEnrichment.kt @@ -0,0 +1,20 @@ +package io.bkbn.kompendium.enrichment + +class StringEnrichment(override val id: String) : Enrichment { + override var deprecated: Boolean? = null + override var description: String? = null + + 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? + + companion object { + inline operator fun invoke(id: String, init: StringEnrichment.() -> Unit): StringEnrichment { + val builder = StringEnrichment(id) + return builder.apply(init) + } + } +} diff --git a/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/TypeEnrichment.kt b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/TypeEnrichment.kt index 4b4dbed23..00ca67d79 100644 --- a/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/TypeEnrichment.kt +++ b/enrichment/src/main/kotlin/io/bkbn/kompendium/enrichment/TypeEnrichment.kt @@ -1,25 +1,3 @@ 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) - } - } -} +sealed interface TypeEnrichment : Enrichment diff --git a/gradle.properties b/gradle.properties index 17560328f..1d4ac3143 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,8 +1,10 @@ # Kompendium project.version=4.0.0-alpha + # Kotlin kotlin.code.style=official -kotlin.experimental.tryK2=true +#kotlin.experimental.tryK2=true + # Gradle org.gradle.vfs.watch=true org.gradle.vfs.verbose=true diff --git a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/KotlinXSchemaConfigurator.kt b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/KotlinXSchemaConfigurator.kt index e03510848..407f239f9 100644 --- a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/KotlinXSchemaConfigurator.kt +++ b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/KotlinXSchemaConfigurator.kt @@ -26,7 +26,7 @@ class KotlinXSchemaConfigurator : SchemaConfigurator { .filterIsInstance() .firstOrNull()?.value ?: property.name - override fun sealedTypeEnrichment( + override fun sealedObjectEnrichment( implementationType: KType, implementationSchema: JsonSchema, ): JsonSchema { diff --git a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/SchemaConfigurator.kt b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/SchemaConfigurator.kt index c6c72bc4f..f8f46d47a 100644 --- a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/SchemaConfigurator.kt +++ b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/SchemaConfigurator.kt @@ -9,7 +9,7 @@ interface SchemaConfigurator { fun serializableMemberProperties(clazz: KClass<*>): Collection> fun serializableName(property: KProperty1): String - fun sealedTypeEnrichment( + fun sealedObjectEnrichment( implementationType: KType, implementationSchema: JsonSchema ): JsonSchema 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 c11912025..1dc211346 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,9 @@ package io.bkbn.kompendium.json.schema +import io.bkbn.kompendium.enrichment.CollectionEnrichment +import io.bkbn.kompendium.enrichment.Enrichment +import io.bkbn.kompendium.enrichment.MapEnrichment +import io.bkbn.kompendium.enrichment.ObjectEnrichment import io.bkbn.kompendium.enrichment.TypeEnrichment import io.bkbn.kompendium.json.schema.definition.JsonSchema import io.bkbn.kompendium.json.schema.definition.NullableDefinition @@ -23,7 +27,7 @@ object SchemaGenerator { type: KType, cache: MutableMap, schemaConfigurator: SchemaConfigurator, - enrichment: TypeEnrichment<*>? = null + enrichment: Enrichment? = null ): JsonSchema { val slug = type.getSlug(enrichment) @@ -47,18 +51,7 @@ object SchemaGenerator { String::class -> checkForNull(type, TypeDefinition.STRING) Boolean::class -> checkForNull(type, TypeDefinition.BOOLEAN) UUID::class -> checkForNull(type, TypeDefinition.UUID) - else -> when { - 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, enrichment) - } else { - SimpleObjectHandler.handle(type, clazz, cache, schemaConfigurator, enrichment) - } - } - } + else -> complexTypeToSchema(clazz, type, cache, schemaConfigurator, enrichment as? TypeEnrichment<*>?) } } @@ -77,4 +70,59 @@ object SchemaGenerator { true -> OneOfDefinition(NullableDefinition(), schema) false -> schema } + + private fun complexTypeToSchema( + clazz: KClass<*>, + type: KType, + cache: MutableMap, + schemaConfigurator: SchemaConfigurator, + enrichment: Enrichment? = null + ): JsonSchema = when { + clazz.isSubclassOf(Enum::class) -> EnumHandler.handle(type, clazz, cache) + clazz.isSubclassOf(Collection::class) -> handleCollection(type, cache, schemaConfigurator, enrichment) + clazz.isSubclassOf(Map::class) -> handleMap(type, cache, schemaConfigurator, enrichment) + else -> handleObject(type, clazz, cache, schemaConfigurator, enrichment) + } + + private fun handleCollection( + type: KType, + cache: MutableMap, + schemaConfigurator: SchemaConfigurator, + enrichment: Enrichment? + ) = when (enrichment) { + is CollectionEnrichment<*> -> CollectionHandler.handle(type, cache, schemaConfigurator, enrichment) + null -> CollectionHandler.handle(type, cache, schemaConfigurator, null) + else -> error("Incorrect enrichment type for enrichment id: ${enrichment.id}") + } + + private fun handleMap( + type: KType, + cache: MutableMap, + schemaConfigurator: SchemaConfigurator, + enrichment: Enrichment? + ) = when (enrichment) { + is MapEnrichment<*> -> MapHandler.handle(type, cache, schemaConfigurator, enrichment) + null -> MapHandler.handle(type, cache, schemaConfigurator, null) + else -> error("Incorrect enrichment type for enrichment id: ${enrichment.id}") + } + + private fun handleObject( + type: KType, + clazz: KClass<*>, + cache: MutableMap, + schemaConfigurator: SchemaConfigurator, + enrichment: Enrichment? + ) = when (clazz.isSealed) { + true -> when (enrichment) { + is ObjectEnrichment<*> -> SealedObjectHandler.handle(type, clazz, cache, schemaConfigurator, enrichment) + null -> SealedObjectHandler.handle(type, clazz, cache, schemaConfigurator, null) + else -> error("Incorrect enrichment type for enrichment id: ${enrichment.id}") + } + + false -> when (enrichment) { + is ObjectEnrichment<*> -> SimpleObjectHandler.handle(type, clazz, cache, schemaConfigurator, enrichment) + null -> SimpleObjectHandler.handle(type, clazz, cache, schemaConfigurator, null) + else -> error("Incorrect enrichment type for enrichment id: ${enrichment.id}") + } + } } 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 02199afb2..07adcc7b8 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 @@ -5,6 +5,8 @@ import kotlinx.serialization.Serializable @Serializable data class MapDefinition( val additionalProperties: JsonSchema, + val maxProperties: Int? = null, + val minProperties: Int? = 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/handler/CollectionHandler.kt b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/CollectionHandler.kt index 3599483d8..23de3f1c9 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,6 +1,6 @@ package io.bkbn.kompendium.json.schema.handler -import io.bkbn.kompendium.enrichment.TypeEnrichment +import io.bkbn.kompendium.enrichment.CollectionEnrichment import io.bkbn.kompendium.json.schema.SchemaConfigurator import io.bkbn.kompendium.json.schema.SchemaGenerator import io.bkbn.kompendium.json.schema.definition.ArrayDefinition @@ -19,18 +19,23 @@ object CollectionHandler { type: KType, cache: MutableMap, schemaConfigurator: SchemaConfigurator, - enrichment: TypeEnrichment<*>? = null + enrichment: CollectionEnrichment<*>? = null ): JsonSchema { + require(enrichment is CollectionEnrichment<*> || enrichment == null) { + "Enrichment for collection must be either null or a CollectionEnrichment" + } + 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, enrichment).let { - if ((it is TypeDefinition && it.type == "object") || it is EnumDefinition) { - cache[collectionType.getSlug(enrichment)] = it - ReferenceDefinition(collectionType.getReferenceSlug(enrichment)) - } else { - it + val typeSchema = + SchemaGenerator.fromTypeToSchema(collectionType, cache, schemaConfigurator, enrichment?.itemEnrichment).let { + if ((it is TypeDefinition && it.type == "object") || it is EnumDefinition) { + cache[collectionType.getSlug(enrichment)] = it + ReferenceDefinition(collectionType.getReferenceSlug(enrichment)) + } else { + it + } } - } val definition = ArrayDefinition(typeSchema) return when (type.isMarkedNullable) { true -> OneOfDefinition(NullableDefinition(), definition) diff --git a/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/EnrichmentHandler.kt b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/EnrichmentHandler.kt new file mode 100644 index 000000000..c60ff7e40 --- /dev/null +++ b/json-schema/src/main/kotlin/io/bkbn/kompendium/json/schema/handler/EnrichmentHandler.kt @@ -0,0 +1,94 @@ +package io.bkbn.kompendium.json.schema.handler + +import io.bkbn.kompendium.enrichment.CollectionEnrichment +import io.bkbn.kompendium.enrichment.Enrichment +import io.bkbn.kompendium.enrichment.MapEnrichment +import io.bkbn.kompendium.enrichment.NumberEnrichment +import io.bkbn.kompendium.enrichment.ObjectEnrichment +import io.bkbn.kompendium.enrichment.StringEnrichment +import io.bkbn.kompendium.json.schema.definition.ArrayDefinition +import io.bkbn.kompendium.json.schema.definition.JsonSchema +import io.bkbn.kompendium.json.schema.definition.MapDefinition +import io.bkbn.kompendium.json.schema.definition.ReferenceDefinition +import io.bkbn.kompendium.json.schema.definition.TypeDefinition + +object EnrichmentHandler { + + fun Enrichment.applyToSchema(schema: JsonSchema): JsonSchema = when (this) { + is NumberEnrichment -> applyToSchema(schema) + is StringEnrichment -> applyToSchema(schema) + is CollectionEnrichment<*> -> applyToSchema(schema) + is MapEnrichment<*> -> applyToSchema(schema) + is ObjectEnrichment<*> -> applyToSchema(schema) + else -> error("Incorrect enrichment type for enrichment id: ${this.id}") + } + + private fun ObjectEnrichment<*>.applyToSchema(schema: JsonSchema): JsonSchema = when (schema) { + is TypeDefinition -> schema.copy(deprecated = deprecated, description = description) + is ReferenceDefinition -> schema.copy(deprecated = deprecated, description = description) + else -> error("Incorrect enrichment type for enrichment id: ${this.id}") + } + + private fun MapEnrichment<*>.applyToSchema(schema: JsonSchema): JsonSchema = when (schema) { + is MapDefinition -> schema.copyMapEnrichment(this) + else -> error("Incorrect enrichment type for enrichment id: ${this.id}") + } + + private fun CollectionEnrichment<*>.applyToSchema(schema: JsonSchema): JsonSchema = when (schema) { + is ArrayDefinition -> schema.copyArrayEnrichment(this) + else -> error("Incorrect enrichment type for enrichment id: ${this.id}") + } + + private fun NumberEnrichment.applyToSchema(schema: JsonSchema): JsonSchema = when (schema) { + is TypeDefinition -> schema.copyNumberEnrichment(this) + else -> error("Incorrect enrichment type for enrichment id: ${this.id}") + } + + private fun StringEnrichment.applyToSchema(schema: JsonSchema): JsonSchema = when (schema) { + is TypeDefinition -> schema.copyStringEnrichment(this) + else -> error("Incorrect enrichment type for enrichment id: ${this.id}") + } + + private fun TypeDefinition.copyNumberEnrichment( + enrichment: NumberEnrichment + ): TypeDefinition = copy( + deprecated = enrichment.deprecated, + description = enrichment.description, + multipleOf = enrichment.multipleOf, + maximum = enrichment.maximum, + exclusiveMaximum = enrichment.exclusiveMaximum, + minimum = enrichment.minimum, + exclusiveMinimum = enrichment.exclusiveMinimum, + ) + + private fun TypeDefinition.copyStringEnrichment( + enrichment: StringEnrichment + ): TypeDefinition = copy( + deprecated = enrichment.deprecated, + description = enrichment.description, + maxLength = enrichment.maxLength, + minLength = enrichment.minLength, + pattern = enrichment.pattern, + contentEncoding = enrichment.contentEncoding, + contentMediaType = enrichment.contentMediaType, + ) + + private fun ArrayDefinition.copyArrayEnrichment( + enrichment: CollectionEnrichment<*> + ): ArrayDefinition = copy( + deprecated = enrichment.deprecated, + description = enrichment.description, + minItems = enrichment.minItems, + maxItems = enrichment.maxItems, + uniqueItems = enrichment.uniqueItems, + ) + + private fun MapDefinition.copyMapEnrichment( + enrichment: MapEnrichment<*> + ): MapDefinition = copy( + deprecated = enrichment.deprecated, + description = enrichment.description, + minProperties = enrichment.minProperties, + maxProperties = enrichment.maxProperties, + ) +} 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 dac1238b5..a508bb1b1 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,6 +1,5 @@ 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 @@ -14,9 +13,8 @@ object EnumHandler { type: KType, clazz: KClass<*>, cache: MutableMap, - enrichment: TypeEnrichment<*>? = null ): JsonSchema { - cache[type.getSlug(enrichment)] = ReferenceDefinition(type.getReferenceSlug(enrichment)) + cache[type.getSlug()] = ReferenceDefinition(type.getReferenceSlug()) val options = clazz.java.enumConstants.map { it.toString() }.toSet() return EnumDefinition(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 d9fab0a36..33e6c4366 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,6 +1,6 @@ package io.bkbn.kompendium.json.schema.handler -import io.bkbn.kompendium.enrichment.TypeEnrichment +import io.bkbn.kompendium.enrichment.MapEnrichment import io.bkbn.kompendium.json.schema.SchemaConfigurator import io.bkbn.kompendium.json.schema.SchemaGenerator import io.bkbn.kompendium.json.schema.definition.JsonSchema @@ -20,20 +20,24 @@ object MapHandler { type: KType, cache: MutableMap, schemaConfigurator: SchemaConfigurator, - enrichment: TypeEnrichment<*>? = null + enrichment: MapEnrichment<*>? = null ): JsonSchema { + require(enrichment is MapEnrichment<*> || enrichment == null) { + "Enrichment for map must be either null or a MapEnrichment" + } 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, enrichment).let { - if (it is TypeDefinition && it.type == "object") { - cache[valueType.getSlug(enrichment)] = it - ReferenceDefinition(valueType.getReferenceSlug(enrichment)) - } else { - it + val valueSchema = + SchemaGenerator.fromTypeToSchema(valueType, cache, schemaConfigurator, enrichment?.valueEnrichment).let { + if (it is TypeDefinition && it.type == "object") { + cache[valueType.getSlug(enrichment)] = it + ReferenceDefinition(valueType.getReferenceSlug(enrichment)) + } else { + it + } } - } val definition = MapDefinition(valueSchema) return when (type.isMarkedNullable) { true -> OneOfDefinition(NullableDefinition(), definition) 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 5bb9bd820..24fbb859a 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,6 +1,6 @@ package io.bkbn.kompendium.json.schema.handler -import io.bkbn.kompendium.enrichment.TypeEnrichment +import io.bkbn.kompendium.enrichment.ObjectEnrichment import io.bkbn.kompendium.json.schema.SchemaConfigurator import io.bkbn.kompendium.json.schema.SchemaGenerator import io.bkbn.kompendium.json.schema.definition.AnyOfDefinition @@ -20,14 +20,14 @@ object SealedObjectHandler { clazz: KClass<*>, cache: MutableMap, schemaConfigurator: SchemaConfigurator, - enrichment: TypeEnrichment<*>? = null, + enrichment: ObjectEnrichment<*>? = null, ): JsonSchema { val subclasses = clazz.sealedSubclasses .map { it.createType(type.arguments) } .map { t -> SchemaGenerator.fromTypeToSchema(t, cache, schemaConfigurator, enrichment) .let { - schemaConfigurator.sealedTypeEnrichment(t, it) + schemaConfigurator.sealedObjectEnrichment(t, it) }.let { js -> if (js is TypeDefinition && js.type == "object") { val slug = t.getSlug(enrichment) 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 a28bd20fd..2a895b2b8 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,19 +1,17 @@ package io.bkbn.kompendium.json.schema.handler -import io.bkbn.kompendium.enrichment.PropertyEnrichment -import io.bkbn.kompendium.enrichment.TypeEnrichment +import io.bkbn.kompendium.enrichment.Enrichment +import io.bkbn.kompendium.enrichment.ObjectEnrichment 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.handler.EnrichmentHandler.applyToSchema import io.bkbn.kompendium.json.schema.util.Helpers.getReferenceSlug import io.bkbn.kompendium.json.schema.util.Helpers.getSlug import kotlin.reflect.KClass @@ -32,28 +30,31 @@ object SimpleObjectHandler { clazz: KClass<*>, cache: MutableMap, schemaConfigurator: SchemaConfigurator, - enrichment: TypeEnrichment<*>?, + enrichment: ObjectEnrichment<*>?, ): JsonSchema { - cache[type.getSlug(enrichment)] = ReferenceDefinition(type.getReferenceSlug(enrichment)) + require(enrichment is ObjectEnrichment<*> || enrichment == null) { + "Enrichment for object must either be of type ObjectEnrichment or null" + } + + val slug = type.getSlug(enrichment) + val referenceSlug = type.getReferenceSlug(enrichment) + cache[slug] = ReferenceDefinition(referenceSlug) 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 propEnrichment = enrichment?.propertyEnrichment?.get(prop) val schema = when (prop.needsToInjectGenerics(typeMap)) { - true -> handleNestedGenerics(typeMap, prop, cache, schemaConfigurator, propTypeEnrichment) + true -> handleNestedGenerics(typeMap, prop, cache, schemaConfigurator, propEnrichment) false -> when (typeMap.containsKey(prop.returnType.classifier)) { - true -> handleGenericProperty(prop, typeMap, cache, schemaConfigurator, propTypeEnrichment) - false -> handleProperty(prop, cache, schemaConfigurator, propTypeEnrichment?.typeEnrichment) + true -> handleGenericProperty(prop, typeMap, cache, schemaConfigurator, propEnrichment) + false -> handleProperty(prop, cache, schemaConfigurator, propEnrichment) } } - val enrichedSchema = propTypeEnrichment?.applyToSchema(schema) ?: schema + val enrichedSchema = propEnrichment?.applyToSchema(schema) ?: schema val nullCheckSchema = when (prop.returnType.isMarkedNullable && !enrichedSchema.isNullable()) { true -> OneOfDefinition(NullableDefinition(), enrichedSchema) @@ -84,11 +85,14 @@ object SimpleObjectHandler { .map { schemaConfigurator.serializableName(it) } .toSet() - return TypeDefinition( + val definition = TypeDefinition( type = "object", properties = props, required = required ) + + cache[slug] = definition + return definition } private fun KProperty<*>.needsToInjectGenerics( @@ -103,7 +107,7 @@ object SimpleObjectHandler { prop: KProperty<*>, cache: MutableMap, schemaConfigurator: SchemaConfigurator, - propEnrichment: PropertyEnrichment? + propEnrichment: Enrichment? ): JsonSchema { val propClass = prop.returnType.classifier as KClass<*> val types = prop.returnType.arguments.map { @@ -111,7 +115,7 @@ object SimpleObjectHandler { typeMap.filterKeys { k -> k.name == typeSymbol }.values.first() } val constructedType = propClass.createType(types) - return SchemaGenerator.fromTypeToSchema(constructedType, cache, schemaConfigurator, propEnrichment?.typeEnrichment) + return SchemaGenerator.fromTypeToSchema(constructedType, cache, schemaConfigurator, propEnrichment) .let { if (it.isOrContainsObjectOrEnumDef()) { cache[constructedType.getSlug(propEnrichment)] = it @@ -127,14 +131,14 @@ object SimpleObjectHandler { typeMap: Map, cache: MutableMap, schemaConfigurator: SchemaConfigurator, - propEnrichment: PropertyEnrichment? + propEnrichment: Enrichment? ): 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, propEnrichment?.typeEnrichment).let { + return SchemaGenerator.fromTypeToSchema(type, cache, schemaConfigurator, propEnrichment).let { if (it.isOrContainsObjectOrEnumDef()) { - cache[type.getSlug(propEnrichment?.typeEnrichment)] = it - ReferenceDefinition(type.getReferenceSlug(propEnrichment?.typeEnrichment)) + cache[type.getSlug(propEnrichment)] = it + ReferenceDefinition(type.getReferenceSlug(propEnrichment)) } else { it } @@ -145,7 +149,7 @@ object SimpleObjectHandler { prop: KProperty<*>, cache: MutableMap, schemaConfigurator: SchemaConfigurator, - propEnrichment: TypeEnrichment<*>? + propEnrichment: Enrichment? ): JsonSchema = SchemaGenerator.fromTypeToSchema(prop.returnType, cache, schemaConfigurator, propEnrichment).let { if (it.isOrContainsObjectOrEnumDef()) { @@ -165,37 +169,4 @@ object SimpleObjectHandler { } private fun JsonSchema.isNullable(): Boolean = this is OneOfDefinition && this.oneOf.any { it is NullableDefinition } - - private fun PropertyEnrichment.applyToSchema(schema: JsonSchema): JsonSchema = when (schema) { - is AnyOfDefinition -> schema.copy(deprecated = deprecated, description = description) - is ArrayDefinition -> schema.copy( - deprecated = deprecated, - description = description, - minItems = minItems, - maxItems = maxItems, - uniqueItems = uniqueItems, - ) - - is EnumDefinition -> schema.copy(deprecated = deprecated, description = description) - is MapDefinition -> schema.copy(deprecated = deprecated, description = description) - is NullableDefinition -> schema.copy(deprecated = deprecated, description = description) - is OneOfDefinition -> schema.copy(deprecated = deprecated, description = description) - is ReferenceDefinition -> schema.copy(deprecated = deprecated, description = description) - is TypeDefinition -> schema.copy( - deprecated = deprecated, - description = description, - multipleOf = multipleOf, - maximum = maximum, - exclusiveMaximum = exclusiveMaximum, - minimum = minimum, - exclusiveMinimum = exclusiveMinimum, - maxLength = maxLength, - minLength = minLength, - pattern = pattern, - contentEncoding = contentEncoding, - contentMediaType = contentMediaType, - maxProperties = maxProperties, - minProperties = minProperties, - ) - } } 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 cf041bdd2..d121b067e 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,8 +1,6 @@ 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 @@ -11,9 +9,8 @@ object Helpers { 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") - else -> getSimpleSlug() + null -> getSimpleSlug() + else -> getEnrichedSlug(enrichment) } fun KType.getSimpleSlug(): String = when { @@ -21,12 +18,11 @@ object Helpers { else -> (classifier as KClass<*>).kompendiumSlug() ?: error("Could not determine simple name for $this") } - private fun KType.getEnrichedSlug(enrichment: TypeEnrichment<*>) = getSimpleSlug() + "-${enrichment.id}" + private fun KType.getEnrichedSlug(enrichment: Enrichment) = 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") - else -> getSimpleReferenceSlug() + null -> getSimpleReferenceSlug() + else -> getSimpleReferenceSlug() + "-${enrichment.id}" } private fun KType.getSimpleReferenceSlug() = when { 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 9315cb089..d16d65c53 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 @@ -13,7 +13,10 @@ import io.bkbn.kompendium.core.fixtures.TransientObject import io.bkbn.kompendium.core.fixtures.UnbackedObject import io.bkbn.kompendium.core.fixtures.GenericObject import io.bkbn.kompendium.core.fixtures.TestHelpers.getFileSnapshot -import io.bkbn.kompendium.enrichment.TypeEnrichment +import io.bkbn.kompendium.enrichment.CollectionEnrichment +import io.bkbn.kompendium.enrichment.NumberEnrichment +import io.bkbn.kompendium.enrichment.ObjectEnrichment +import io.bkbn.kompendium.enrichment.StringEnrichment import io.bkbn.kompendium.json.schema.definition.JsonSchema import io.kotest.assertions.json.shouldEqualJson import io.kotest.assertions.throwables.shouldThrow @@ -114,12 +117,16 @@ class SchemaGeneratorTest : DescribeSpec({ it("Can attach an enrichment to a simple type") { jsonSchemaTest( snapshotName = "T0022__enriched_simple_object.json", - enrichment = TypeEnrichment("simple") { + enrichment = ObjectEnrichment("simple") { TestSimpleRequest::a { - description = "This is a simple description" + StringEnrichment("blah") { + description = "This is a simple description" + } } TestSimpleRequest::b { - deprecated = true + NumberEnrichment("bla") { + deprecated = true + } } } ) @@ -127,12 +134,16 @@ class SchemaGeneratorTest : DescribeSpec({ it("Can properly assign a reference to a nested enrichment") { jsonSchemaTest( snapshotName = "T0023__enriched_nested_reference.json", - enrichment = TypeEnrichment("example") { + enrichment = ObjectEnrichment("example") { ComplexRequest::tables { - description = "Collection of important items" - typeEnrichment = TypeEnrichment("table") { - NestedComplexItem::name { - description = "The name of the table" + CollectionEnrichment>("tables") { + description = "Collection of important items" + itemEnrichment = ObjectEnrichment("table") { + NestedComplexItem::name { + StringEnrichment("name") { + description = "The name of the table" + } + } } } } @@ -142,15 +153,19 @@ class SchemaGeneratorTest : DescribeSpec({ it("Can properly assign a reference to a generic object") { jsonSchemaTest>( snapshotName = "T0025__enrichment_generic_object.json", - enrichment = TypeEnrichment("generic") { + enrichment = ObjectEnrichment("generic") { GenericObject::data { - description = "This is a generic param" - typeEnrichment = TypeEnrichment("simple") { + ObjectEnrichment("blob") { + description = "This is a generic object" TestSimpleRequest::a { - description = "This is a simple description" + StringEnrichment("blah") { + description = "This is a simple description" + } } TestSimpleRequest::b { - deprecated = true + NumberEnrichment("bla") { + deprecated = true + } } } } @@ -168,7 +183,7 @@ class SchemaGeneratorTest : DescribeSpec({ private fun JsonSchema.serialize() = json.encodeToString(JsonSchema.serializer(), this) - private inline fun jsonSchemaTest(snapshotName: String, enrichment: TypeEnrichment<*>? = null) { + private inline fun jsonSchemaTest(snapshotName: String, enrichment: ObjectEnrichment<*>? = null) { // act val schema = SchemaGenerator.fromTypeToSchema( type = typeOf(), diff --git a/json-schema/src/test/resources/T0023__enriched_nested_reference.json b/json-schema/src/test/resources/T0023__enriched_nested_reference.json index 8a201687e..e2d668ab1 100644 --- a/json-schema/src/test/resources/T0023__enriched_nested_reference.json +++ b/json-schema/src/test/resources/T0023__enriched_nested_reference.json @@ -9,7 +9,7 @@ }, "tables": { "items": { - "$ref": "#/components/schemas/NestedComplexItem-table" + "$ref": "#/components/schemas/NestedComplexItem-tables" }, "description": "Collection of important items", "type": "array" diff --git a/json-schema/src/test/resources/T0025__enrichment_generic_object.json b/json-schema/src/test/resources/T0025__enrichment_generic_object.json index ddad55fae..59208feb9 100644 --- a/json-schema/src/test/resources/T0025__enrichment_generic_object.json +++ b/json-schema/src/test/resources/T0025__enrichment_generic_object.json @@ -2,8 +2,8 @@ "type": "object", "properties": { "data": { - "description": "This is a generic param", - "$ref": "#/components/schemas/TestSimpleRequest-simple" + "$ref": "#/components/schemas/TestSimpleRequest-blob", + "description": "This is a generic object" } }, "required": [ 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 98b10cf02..50c8d2347 100644 --- a/playground/src/main/kotlin/io/bkbn/kompendium/playground/EnrichmentPlayground.kt +++ b/playground/src/main/kotlin/io/bkbn/kompendium/playground/EnrichmentPlayground.kt @@ -6,7 +6,10 @@ 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.core.routes.swagger -import io.bkbn.kompendium.enrichment.TypeEnrichment +import io.bkbn.kompendium.enrichment.BooleanEnrichment +import io.bkbn.kompendium.enrichment.NumberEnrichment +import io.bkbn.kompendium.enrichment.ObjectEnrichment +import io.bkbn.kompendium.enrichment.StringEnrichment import io.bkbn.kompendium.json.schema.KotlinXSchemaConfigurator import io.bkbn.kompendium.json.schema.definition.TypeDefinition import io.bkbn.kompendium.oas.payload.Parameter @@ -60,29 +63,37 @@ private fun Application.mainModule() { } } -private val testEnrichment = TypeEnrichment("testerino") { +private val testEnrichment = ObjectEnrichment("testerino") { ExampleRequest::thingA { - description = "This is a thing" + StringEnrichment("thingA") { + description = "This is a thing" + } } ExampleRequest::thingB { - description = "This is another thing" + NumberEnrichment("thingB") { + description = "This is another thing" + } } ExampleRequest::thingC { - deprecated = true - description = "A good but old field" - typeEnrichment = TypeEnrichment("big-tings") { + ObjectEnrichment("thingC") { + deprecated = true + description = "A good but old field" InnerRequest::d { - exclusiveMaximum = 10.0 - exclusiveMinimum = 1.1 - description = "THE BIG D" + NumberEnrichment("blahblah") { + exclusiveMinimum = 1.1 + exclusiveMaximum = 10.0 + description = "THE BIG D" + } } } } } -private val testResponseEnrichment = TypeEnrichment("testerino") { +private val testResponseEnrichment = ObjectEnrichment("testerino") { ExampleResponse::isReal { - description = "Is this thing real or not?" + BooleanEnrichment("blah") { + description = "Is this thing real or not?" + } } }