diff --git a/CHANGELOG.md b/CHANGELOG.md index 14a6dd6d1..9908c26d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Changed - Can now put redoc route behind authentication +- Fixed issue where type erasure was breaking nested generics ### Remove 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 f88bc4c5e..67de2b46d 100644 --- a/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt @@ -16,6 +16,8 @@ import io.bkbn.kompendium.core.util.TestModules.genericPolymorphicResponse import io.bkbn.kompendium.core.util.TestModules.genericPolymorphicResponseMultipleImpls import io.bkbn.kompendium.core.util.TestModules.headerParameter import io.bkbn.kompendium.core.util.TestModules.multipleExceptions +import io.bkbn.kompendium.core.util.TestModules.nestedGenericCollection +import io.bkbn.kompendium.core.util.TestModules.nestedGenericMultipleParamsCollection import io.bkbn.kompendium.core.util.TestModules.nestedGenericResponse import io.bkbn.kompendium.core.util.TestModules.nonRequiredParam import io.bkbn.kompendium.core.util.TestModules.polymorphicException @@ -157,6 +159,12 @@ class KompendiumTest : DescribeSpec({ it("Can handle an absolutely psycho inheritance test") { openApiTestAllSerializers("T0033__crazy_polymorphic_example.json") { genericPolymorphicResponseMultipleImpls() } } + it("Can support nested generic collections") { + openApiTestAllSerializers("T0039__nested_generic_collection.json") { nestedGenericCollection() } + } + it("Can support nested generics with multiple type parameters") { + openApiTestAllSerializers("T0040__nested_generic_multiple_type_params.json") { nestedGenericMultipleParamsCollection() } + } } describe("Miscellaneous") { xit("Can generate the necessary ReDoc home page") { diff --git a/core/src/test/kotlin/io/bkbn/kompendium/core/util/TestModules.kt b/core/src/test/kotlin/io/bkbn/kompendium/core/util/TestModules.kt index 586187c22..e6c44f41b 100644 --- a/core/src/test/kotlin/io/bkbn/kompendium/core/util/TestModules.kt +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/util/TestModules.kt @@ -8,8 +8,10 @@ import io.bkbn.kompendium.core.fixtures.ExceptionResponse import io.bkbn.kompendium.core.fixtures.Flibbity import io.bkbn.kompendium.core.fixtures.FlibbityGibbit import io.bkbn.kompendium.core.fixtures.Gibbity +import io.bkbn.kompendium.core.fixtures.MultiNestedGenerics import io.bkbn.kompendium.core.fixtures.NullableEnum import io.bkbn.kompendium.core.fixtures.NullableField +import io.bkbn.kompendium.core.fixtures.Page import io.bkbn.kompendium.core.fixtures.ProfileUpdateRequest import io.bkbn.kompendium.core.fixtures.TestCreatedResponse import io.bkbn.kompendium.core.fixtures.TestNested @@ -567,6 +569,10 @@ object TestModules { fun Routing.genericPolymorphicResponseMultipleImpls() = basicGetGenerator>() + fun Routing.nestedGenericCollection() = basicGetGenerator>() + + fun Routing.nestedGenericMultipleParamsCollection() = basicGetGenerator>() + fun Routing.withOperationId() = basicGetGenerator(operationId = "getThisDude") fun Routing.nullableNestedObject() = basicGetGenerator() @@ -575,14 +581,16 @@ object TestModules { fun Routing.dateTimeString() = basicGetGenerator() - fun Routing.headerParameter() = basicGetGenerator( params = listOf( - Parameter( - name = "X-User-Email", - `in` = Parameter.Location.header, - schema = TypeDefinition.STRING, - required = true + fun Routing.headerParameter() = basicGetGenerator( + params = listOf( + Parameter( + name = "X-User-Email", + `in` = Parameter.Location.header, + schema = TypeDefinition.STRING, + required = true + ) ) - )) + ) fun Routing.simpleRecursive() = basicGetGenerator() diff --git a/core/src/test/resources/T0039__nested_generic_collection.json b/core/src/test/resources/T0039__nested_generic_collection.json new file mode 100644 index 000000000..da3ff6e12 --- /dev/null +++ b/core/src/test/resources/T0039__nested_generic_collection.json @@ -0,0 +1,101 @@ +{ + "openapi": "3.1.0", + "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", + "info": { + "title": "Test API", + "version": "1.33.7", + "description": "An amazing, fully-ish 😉 generated API spec", + "termsOfService": "https://example.com", + "contact": { + "name": "Homer Simpson", + "url": "https://gph.is/1NPUDiM", + "email": "chunkylover53@aol.com" + }, + "license": { + "name": "MIT", + "url": "https://github.com/bkbnio/kompendium/blob/main/LICENSE" + } + }, + "servers": [ + { + "url": "https://myawesomeapi.com", + "description": "Production instance of my API" + }, + { + "url": "https://staging.myawesomeapi.com", + "description": "Where the fun stuff happens" + } + ], + "paths": { + "/": { + "get": { + "tags": [], + "summary": "Great Summary!", + "description": "testing more", + "parameters": [], + "responses": { + "200": { + "description": "A Successful Endeavor", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Page-Int" + } + } + } + } + }, + "deprecated": false + }, + "parameters": [] + } + }, + "webhooks": {}, + "components": { + "schemas": { + "Page-Int": { + "type": "object", + "properties": { + "content": { + "items": { + "type": "number", + "format": "int32" + }, + "type": "array" + }, + "number": { + "type": "number", + "format": "int32" + }, + "numberOfElements": { + "type": "number", + "format": "int32" + }, + "size": { + "type": "number", + "format": "int32" + }, + "totalElements": { + "type": "number", + "format": "int64" + }, + "totalPages": { + "type": "number", + "format": "int32" + } + }, + "required": [ + "content", + "number", + "numberOfElements", + "size", + "totalElements", + "totalPages" + ] + } + }, + "securitySchemes": {} + }, + "security": [], + "tags": [] +} diff --git a/core/src/test/resources/T0040__nested_generic_multiple_type_params.json b/core/src/test/resources/T0040__nested_generic_multiple_type_params.json new file mode 100644 index 000000000..ab4a72f56 --- /dev/null +++ b/core/src/test/resources/T0040__nested_generic_multiple_type_params.json @@ -0,0 +1,129 @@ +{ + "openapi": "3.1.0", + "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", + "info": { + "title": "Test API", + "version": "1.33.7", + "description": "An amazing, fully-ish 😉 generated API spec", + "termsOfService": "https://example.com", + "contact": { + "name": "Homer Simpson", + "url": "https://gph.is/1NPUDiM", + "email": "chunkylover53@aol.com" + }, + "license": { + "name": "MIT", + "url": "https://github.com/bkbnio/kompendium/blob/main/LICENSE" + } + }, + "servers": [ + { + "url": "https://myawesomeapi.com", + "description": "Production instance of my API" + }, + { + "url": "https://staging.myawesomeapi.com", + "description": "Where the fun stuff happens" + } + ], + "paths": { + "/": { + "get": { + "tags": [], + "summary": "Great Summary!", + "description": "testing more", + "parameters": [], + "responses": { + "200": { + "description": "A Successful Endeavor", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MultiNestedGenerics-String-ComplexRequest" + } + } + } + } + }, + "deprecated": false + }, + "parameters": [] + } + }, + "webhooks": {}, + "components": { + "schemas": { + "CrazyItem": { + "type": "object", + "properties": { + "enumeration": { + "enum": [ + "ONE", + "TWO" + ] + } + }, + "required": [ + "enumeration" + ] + }, + "NestedComplexItem": { + "type": "object", + "properties": { + "alias": { + "additionalProperties": { + "$ref": "#/components/schemas/CrazyItem" + }, + "type": "object" + }, + "name": { + "type": "string" + } + }, + "required": [ + "alias", + "name" + ] + }, + "ComplexRequest": { + "type": "object", + "properties": { + "amazingField": { + "type": "string" + }, + "org": { + "type": "string" + }, + "tables": { + "items": { + "$ref": "#/components/schemas/NestedComplexItem" + }, + "type": "array" + } + }, + "required": [ + "amazingField", + "org", + "tables" + ] + }, + "MultiNestedGenerics-String-ComplexRequest": { + "type": "object", + "properties": { + "content": { + "additionalProperties": { + "$ref": "#/components/schemas/ComplexRequest" + }, + "type": "object" + } + }, + "required": [ + "content" + ] + } + }, + "securitySchemes": {} + }, + "security": [], + "tags": [] +} diff --git a/core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestModels.kt b/core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestModels.kt index 754bc7c85..4e8ce5d1d 100644 --- a/core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestModels.kt +++ b/core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestModels.kt @@ -118,3 +118,16 @@ public data class ProfileMetadataUpdateRequest( public val isPrivate: Boolean?, public val otherThing: String? ) + +data class Page( + val content: List, + val totalElements: Long, + val totalPages: Int, + val numberOfElements: Int, + val number: Int, + val size: Int +) + +data class MultiNestedGenerics( + val content: Map +) 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 a3c11b928..64f9fc287 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 @@ -13,6 +13,7 @@ import kotlin.reflect.KProperty import kotlin.reflect.KType import kotlin.reflect.KTypeParameter import kotlin.reflect.KTypeProjection +import kotlin.reflect.full.createType import kotlin.reflect.full.memberProperties import kotlin.reflect.full.primaryConstructor @@ -22,9 +23,12 @@ object SimpleObjectHandler { // cache[type.getSimpleSlug()] = ReferenceDefinition("RECURSION_PLACEHOLDER") val typeMap = clazz.typeParameters.zip(type.arguments).toMap() val props = clazz.memberProperties.associate { prop -> - val schema = when (typeMap.containsKey(prop.returnType.classifier)) { - true -> handleGenericProperty(prop, typeMap, cache) - false -> handleProperty(prop, cache) + val schema = when (prop.needsToInjectGenerics(typeMap)) { + true -> handleNestedGenerics(typeMap, prop, cache) + false -> when (typeMap.containsKey(prop.returnType.classifier)) { + true -> handleGenericProperty(prop, typeMap, cache) + false -> handleProperty(prop, cache) + } } prop.name to schema @@ -48,6 +52,34 @@ object SimpleObjectHandler { } } + private fun KProperty<*>.needsToInjectGenerics( + typeMap: Map + ): Boolean { + val typeSymbols = returnType.arguments.map { it.type.toString() } + return typeMap.any { (k, _) -> typeSymbols.contains(k.name) } + } + + private fun handleNestedGenerics( + typeMap: Map, + prop: KProperty<*>, + cache: MutableMap + ): JsonSchema { + val propClass = prop.returnType.classifier as KClass<*> + val types = prop.returnType.arguments.map { + val typeSymbol = it.type.toString() + typeMap.filterKeys { k -> k.name == typeSymbol }.values.first() + } + val constructedType = propClass.createType(types) + return SchemaGenerator.fromTypeToSchema(constructedType, cache).let { + if (it is TypeDefinition && it.type == "object") { + cache[constructedType.getSimpleSlug()] = it + ReferenceDefinition(prop.returnType.getReferenceSlug()) + } else { + it + } + } + } + private fun handleGenericProperty( prop: KProperty<*>, typeMap: Map,