Skip to content

Commit 1f83f21

Browse files
authored
KTOR-9451 Support nested generic types (#5500)
* KTOR-9451 Support nested generic types * fixup! KTOR-9451 Support nested generic types
1 parent 2440990 commit 1f83f21

4 files changed

Lines changed: 117 additions & 20 deletions

File tree

ktor-shared/ktor-openapi-schema/common/src/io/ktor/openapi/JsonSchemaInference.kt

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,10 @@ public class KotlinxSerializerJsonSchemaInference(
7777
public val Default: KotlinxSerializerJsonSchemaInference =
7878
KotlinxSerializerJsonSchemaInference(EmptySerializersModule())
7979
}
80+
private val kTypeLookup = mutableMapOf<String, KType>()
8081

8182
override fun buildSchema(type: KType): JsonSchema {
83+
includeKType(type)
8284
return buildSchemaFromDescriptor(
8385
module.serializer(type).descriptor,
8486
// parameterized types cannot be referenced from their serial name
@@ -87,29 +89,45 @@ public class KotlinxSerializerJsonSchemaInference(
8789
)
8890
}
8991

92+
private fun includeKType(type: KType) {
93+
// use toString() because qualifiedName is unavailable in web
94+
val qualifiedName = type.toString().substringBefore('<')
95+
if (qualifiedName in kTypeLookup) return
96+
kTypeLookup[qualifiedName] = type
97+
for (typeArg in type.arguments) {
98+
typeArg.type?.let(::includeKType)
99+
}
100+
}
101+
102+
private fun SerialDescriptor.isParameterized(): Boolean =
103+
kTypeLookup[nonNullSerialName]?.arguments?.isNotEmpty() == true
104+
90105
@OptIn(ExperimentalSerializationApi::class, InternalAPI::class)
91106
internal fun buildSchemaFromDescriptor(
92107
descriptor: SerialDescriptor,
93-
includeTitle: Boolean = true,
108+
includeTitle: Boolean = !descriptor.isParameterized(),
94109
includeAnnotations: List<Annotation> = emptyList(),
95110
visiting: MutableSet<String>,
96111
): JsonSchema {
97112
val reflectJsonSchema: KClass<*>.() -> ReferenceOr<JsonSchema> = {
98113
Value(
99-
module.serializer(this, emptyList(), false)
100-
.descriptor.buildJsonSchema(includeTitle, visiting = visiting)
114+
buildSchemaFromDescriptor(
115+
descriptor = module.serializer(this, emptyList(), false).descriptor,
116+
includeTitle = includeTitle,
117+
visiting = visiting,
118+
)
101119
)
102120
}
103121
val annotations = includeAnnotations + descriptor.annotations
104122
val isNullable = descriptor.isNullable
105123

106124
// For inline descriptors, use the delegate descriptor
107125
if (descriptor.isInline) {
108-
return descriptor.getElementDescriptor(0)
109-
.buildJsonSchema(
110-
visiting = visiting,
111-
includeAnnotations = includeAnnotations
112-
)
126+
return buildSchemaFromDescriptor(
127+
descriptor = descriptor.getElementDescriptor(0),
128+
includeAnnotations = includeAnnotations,
129+
visiting = visiting,
130+
)
113131
}
114132

115133
return when (descriptor.kind) {

ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/src/io/ktor/openapi/reflect/JsonSchemaInference.jvm.kt

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import java.time.OffsetDateTime
1212
import kotlin.reflect.KClass
1313
import kotlin.reflect.KProperty1
1414
import kotlin.reflect.KType
15+
import kotlin.reflect.KTypeParameter
16+
import kotlin.reflect.KTypeProjection
17+
import kotlin.reflect.full.createType
1518
import kotlin.reflect.full.isSubclassOf
1619
import kotlin.reflect.full.memberProperties
1720
import kotlin.reflect.full.primaryConstructor
@@ -139,7 +142,7 @@ public class ReflectionJsonSchemaInference(
139142

140143
// Value classes (inline) should be represented as their underlying value
141144
if (kClass.isValue) {
142-
kClass.underlyingValueClassTypeOrNull()?.let { underlyingType ->
145+
kClass.underlyingValueClassTypeOrNull(type)?.let { underlyingType ->
143146
val unboxedSchema = buildSchemaInternal(
144147
underlyingType,
145148
visiting,
@@ -236,9 +239,10 @@ public class ReflectionJsonSchemaInference(
236239
if (adapter.isIgnored(prop)) continue
237240

238241
val propertyName = adapter.getName(prop)
239-
val propertyIsNullable = adapter.isNullable(prop.returnType)
242+
val resolvedPropertyType = swapTypeArgs(prop.returnType, type)
243+
val propertyIsNullable = adapter.isNullable(resolvedPropertyType)
240244

241-
properties[propertyName] = buildSchemaOrRef(prop.returnType, visiting, prop.annotations)
245+
properties[propertyName] = buildSchemaOrRef(resolvedPropertyType, visiting, prop.annotations)
242246

243247
// Required: non-nullable properties are required (best effort; default values are not detectable reliably)
244248
if (!propertyIsNullable) {
@@ -288,6 +292,48 @@ public class ReflectionJsonSchemaInference(
288292
}
289293
}
290294

295+
private fun KClass<*>.underlyingValueClassTypeOrNull(ownerType: KType): KType? {
296+
val ctorParam = primaryConstructor?.parameters?.singleOrNull()
297+
?: return null
298+
299+
val propType = memberProperties.firstOrNull { it.name == ctorParam.name }?.returnType
300+
?: ctorParam.type
301+
302+
return swapTypeArgs(propType, ownerType)
303+
}
304+
305+
private fun swapTypeArgs(propertyType: KType, ownerType: KType): KType {
306+
val ownerClass = ownerType.classifier as? KClass<*> ?: return propertyType
307+
val typeParameters = ownerClass.typeParameters
308+
if (typeParameters.isEmpty() || ownerType.arguments.isEmpty()) return propertyType
309+
310+
val substitution = typeParameters
311+
.zip(ownerType.arguments)
312+
.mapNotNull { (param, arg) -> arg.type?.let { param to it } }
313+
.toMap()
314+
315+
if (substitution.isEmpty()) return propertyType
316+
317+
fun substitute(type: KType): KType {
318+
val classifier = type.classifier
319+
if (classifier is KTypeParameter) {
320+
return substitution[classifier] ?: type
321+
}
322+
323+
val kClass = classifier as? KClass<*> ?: return type
324+
if (type.arguments.isEmpty()) return type
325+
326+
val newArgs = type.arguments.map { projection ->
327+
val argType = projection.type ?: return@map projection
328+
KTypeProjection(projection.variance, substitute(argType))
329+
}
330+
331+
return kClass.createType(newArgs, type.isMarkedNullable)
332+
}
333+
334+
return substitute(propertyType)
335+
}
336+
291337
@OptIn(
292338
ExperimentalTime::class,
293339
ExperimentalUuidApi::class,
@@ -376,15 +422,6 @@ public class ReflectionJsonSchemaInference(
376422
else -> null
377423
}
378424

379-
private fun KClass<*>.underlyingValueClassTypeOrNull(): KType? {
380-
val ctorParam = primaryConstructor?.parameters?.singleOrNull()
381-
?: return null
382-
383-
// Prefer the backing property type when available (better chance of having resolved type args)
384-
val propType = memberProperties.firstOrNull { it.name == ctorParam.name }?.returnType
385-
return propType ?: ctorParam.type
386-
}
387-
388425
private fun KClass<*>.starProjectedTypeOrNull(): KType? = try {
389426
@Suppress("UNCHECKED_CAST")
390427
(this as KClass<Any>).starProjectedType
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
type: object
2+
required:
3+
- data
4+
properties:
5+
data:
6+
type: object
7+
required:
8+
- items
9+
- total
10+
properties:
11+
items:
12+
type: array
13+
items:
14+
type: object
15+
title: io.ktor.openapi.reflect.Country
16+
required:
17+
- name
18+
- code
19+
properties:
20+
name:
21+
type: string
22+
pattern: '[A-Za-z''-,]+'
23+
code:
24+
type: string
25+
pattern: '[A-Z]{3}'
26+
total:
27+
type: integer

ktor-shared/ktor-openapi-schema/ktor-openapi-schema-reflect/jvm/test/io/ktor/openapi/reflect/AbstractSchemaInferenceTest.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,10 @@ abstract class AbstractSchemaInferenceTest(
196196
fun `value classes`() =
197197
assertSchemaMatches<Email>()
198198

199+
@Test
200+
fun `nested generics`() =
201+
assertSchemaMatches<Response<Page<Country>>>()
202+
199203
private inline fun <reified T : Any> assertSchemaMatches() {
200204
val schema = inference.jsonSchema<T>()
201205
val expected = readSchemaYaml<T>()
@@ -357,3 +361,14 @@ data class IntLiteral(val value: Int) : Expression
357361

358362
@Serializable
359363
data class StringLiteral(val value: String) : Expression
364+
365+
@Serializable
366+
data class Response<T>(
367+
val data: T
368+
)
369+
370+
@Serializable
371+
data class Page<out E>(
372+
val items: List<E>,
373+
val total: Int,
374+
)

0 commit comments

Comments
 (0)