diff --git a/ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt b/ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt index eef4e700590..48a6ddb3665 100644 --- a/ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt +++ b/ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt @@ -17,11 +17,8 @@ import kotlinx.serialization.modules.SerializersModule import kotlin.reflect.KClass import kotlin.reflect.KType import kotlin.reflect.typeOf -import kotlin.time.Duration import kotlin.time.ExperimentalTime -import kotlin.time.Instant import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid /** * Context interface for creating schema from type metadata. @@ -77,11 +74,13 @@ public class KotlinxSerializerJsonSchemaInference( /** * Default instance of KotlinxSerializerJsonSchemaInference using an empty serializers module. */ - public val Default: KotlinxSerializerJsonSchemaInference = + public val Default: KotlinxSerializerJsonSchemaInference get() = KotlinxSerializerJsonSchemaInference(EmptySerializersModule()) } + private val kTypeLookup = mutableMapOf() override fun buildSchema(type: KType): JsonSchema { + includeKType(type) return buildSchemaFromDescriptor( module.serializer(type).descriptor, // parameterized types cannot be referenced from their serial name @@ -90,17 +89,33 @@ public class KotlinxSerializerJsonSchemaInference( ) } + private fun includeKType(type: KType) { + // use toString() because qualifiedName is unavailable in web + val qualifiedName = type.toString().substringBefore('<') + if (qualifiedName in kTypeLookup) return + kTypeLookup[qualifiedName] = type + for (typeArg in type.arguments) { + typeArg.type?.let(::includeKType) + } + } + + private fun SerialDescriptor.isParameterized(): Boolean = + kTypeLookup[nonNullSerialName]?.arguments?.isNotEmpty() == true + @OptIn(ExperimentalSerializationApi::class, InternalAPI::class) internal fun buildSchemaFromDescriptor( descriptor: SerialDescriptor, - includeTitle: Boolean = true, + includeTitle: Boolean = !descriptor.isParameterized(), includeAnnotations: List = emptyList(), visiting: MutableSet, ): JsonSchema { val reflectJsonSchema: KClass<*>.() -> ReferenceOr = { Value( - module.serializer(this, emptyList(), false) - .descriptor.buildJsonSchema(includeTitle, visiting = visiting) + buildSchemaFromDescriptor( + descriptor = module.serializer(this, emptyList(), false).descriptor, + includeTitle = includeTitle, + visiting = visiting, + ) ) } val annotations = includeAnnotations + descriptor.annotations @@ -108,11 +123,11 @@ public class KotlinxSerializerJsonSchemaInference( // For inline descriptors, use the delegate descriptor if (descriptor.isInline) { - return descriptor.getElementDescriptor(0) - .buildJsonSchema( - visiting = visiting, - includeAnnotations = includeAnnotations - ) + return buildSchemaFromDescriptor( + descriptor = descriptor.getElementDescriptor(0), + includeAnnotations = includeAnnotations, + visiting = visiting, + ) } return when (descriptor.kind) { @@ -146,7 +161,8 @@ public class KotlinxSerializerJsonSchemaInference( .nonNullable(elementDescriptor.isNullable) } else { Value( - elementDescriptor.buildJsonSchema( + buildSchemaFromDescriptor( + descriptor = elementDescriptor, includeAnnotations = descriptor.getElementAnnotations(i), visiting = visiting, ) diff --git a/ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/src/io/ktor/openapi/reflect/JsonSchemaInference.jvm.kt b/ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/src/io/ktor/openapi/reflect/JsonSchemaInference.jvm.kt index f50d3e6455f..55fd5d8b960 100644 --- a/ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/src/io/ktor/openapi/reflect/JsonSchemaInference.jvm.kt +++ b/ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/src/io/ktor/openapi/reflect/JsonSchemaInference.jvm.kt @@ -12,6 +12,9 @@ import java.time.OffsetDateTime import kotlin.reflect.KClass import kotlin.reflect.KProperty1 import kotlin.reflect.KType +import kotlin.reflect.KTypeParameter +import kotlin.reflect.KTypeProjection +import kotlin.reflect.full.createType import kotlin.reflect.full.isSubclassOf import kotlin.reflect.full.memberProperties import kotlin.reflect.full.primaryConstructor @@ -145,7 +148,7 @@ public class ReflectionJsonSchemaInference( // Value classes (inline) should be represented as their underlying value if (kClass.isValue) { - kClass.underlyingValueClassTypeOrNull()?.let { underlyingType -> + kClass.underlyingValueClassTypeOrNull(type)?.let { underlyingType -> val unboxedSchema = buildSchemaInternal( underlyingType, visiting, @@ -242,13 +245,14 @@ public class ReflectionJsonSchemaInference( if (adapter.isIgnored(prop)) continue val propertyName = adapter.getName(prop) - val typeName = adapter.getName(prop.returnType) - val propertyIsNullable = adapter.isNullable(prop.returnType) + val resolvedPropertyType = swapTypeArgs(prop.returnType, type) + val typeName = adapter.getName(resolvedPropertyType) + val propertyIsNullable = adapter.isNullable(resolvedPropertyType) properties[propertyName] = if (typeName != null && !visiting.add(typeName)) { ReferenceOr.schema(typeName).nonNullable(propertyIsNullable) } else { - val propSchema = buildSchemaInternal(prop.returnType, visiting, prop.annotations) + val propSchema = buildSchemaInternal(resolvedPropertyType, visiting, prop.annotations) ReferenceOr.Value(propSchema) } @@ -273,6 +277,48 @@ public class ReflectionJsonSchemaInference( } } + private fun KClass<*>.underlyingValueClassTypeOrNull(ownerType: KType): KType? { + val ctorParam = primaryConstructor?.parameters?.singleOrNull() + ?: return null + + val propType = memberProperties.firstOrNull { it.name == ctorParam.name }?.returnType + ?: ctorParam.type + + return swapTypeArgs(propType, ownerType) + } + + private fun swapTypeArgs(propertyType: KType, ownerType: KType): KType { + val ownerClass = ownerType.classifier as? KClass<*> ?: return propertyType + val typeParameters = ownerClass.typeParameters + if (typeParameters.isEmpty() || ownerType.arguments.isEmpty()) return propertyType + + val substitution = typeParameters + .zip(ownerType.arguments) + .mapNotNull { (param, arg) -> arg.type?.let { param to it } } + .toMap() + + if (substitution.isEmpty()) return propertyType + + fun substitute(type: KType): KType { + val classifier = type.classifier + if (classifier is KTypeParameter) { + return substitution[classifier] ?: type + } + + val kClass = classifier as? KClass<*> ?: return type + if (type.arguments.isEmpty()) return type + + val newArgs = type.arguments.map { projection -> + val argType = projection.type ?: return@map projection + KTypeProjection(projection.variance, substitute(argType)) + } + + return kClass.createType(newArgs, type.isMarkedNullable) + } + + return substitute(propertyType) + } + @OptIn( ExperimentalTime::class, ExperimentalUuidApi::class, @@ -361,15 +407,6 @@ public class ReflectionJsonSchemaInference( else -> null } - private fun KClass<*>.underlyingValueClassTypeOrNull(): KType? { - val ctorParam = primaryConstructor?.parameters?.singleOrNull() - ?: return null - - // Prefer the backing property type when available (better chance of having resolved type args) - val propType = memberProperties.firstOrNull { it.name == ctorParam.name }?.returnType - return propType ?: ctorParam.type - } - private fun KClass<*>.starProjectedTypeOrNull(): KType? = try { @Suppress("UNCHECKED_CAST") (this as KClass).starProjectedType diff --git a/ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/test-resources/schema/Response.yaml b/ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/test-resources/schema/Response.yaml new file mode 100644 index 00000000000..080820ff185 --- /dev/null +++ b/ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/test-resources/schema/Response.yaml @@ -0,0 +1,27 @@ +type: object +required: + - data +properties: + data: + type: object + required: + - items + - total + properties: + items: + type: array + items: + type: object + title: io.ktor.openapi.reflect.Country + required: + - name + - code + properties: + name: + type: string + pattern: '[A-Za-z''-,]+' + code: + type: string + pattern: '[A-Z]{3}' + total: + type: integer diff --git a/ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/test/io/ktor/openapi/reflect/AbstractSchemaInferenceTest.kt b/ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/test/io/ktor/openapi/reflect/AbstractSchemaInferenceTest.kt index 2a25887147e..9e8150086ea 100644 --- a/ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/test/io/ktor/openapi/reflect/AbstractSchemaInferenceTest.kt +++ b/ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/test/io/ktor/openapi/reflect/AbstractSchemaInferenceTest.kt @@ -192,6 +192,10 @@ abstract class AbstractSchemaInferenceTest( fun `value classes`() = assertSchemaMatches() + @Test + fun `nested generics`() = + assertSchemaMatches>>() + private inline fun assertSchemaMatches() { val schema = inference.jsonSchema() val expected = readSchemaYaml() @@ -337,3 +341,14 @@ data class RecursiveNode( val name: String, val children: List ) + +@Serializable +data class Response( + val data: T +) + +@Serializable +data class Page( + val items: List, + val total: Int, +)