Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<String, KType>()

override fun buildSchema(type: KType): JsonSchema {
includeKType(type)
return buildSchemaFromDescriptor(
module.serializer(type).descriptor,
// parameterized types cannot be referenced from their serial name
Expand All @@ -90,29 +89,45 @@ 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<Annotation> = emptyList(),
visiting: MutableSet<String>,
): JsonSchema {
val reflectJsonSchema: KClass<*>.() -> ReferenceOr<JsonSchema> = {
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
val isNullable = descriptor.isNullable

// 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) {
Expand Down Expand Up @@ -146,7 +161,8 @@ public class KotlinxSerializerJsonSchemaInference(
.nonNullable(elementDescriptor.isNullable)
} else {
Value(
elementDescriptor.buildJsonSchema(
buildSchemaFromDescriptor(
descriptor = elementDescriptor,
includeAnnotations = descriptor.getElementAnnotations(i),
visiting = visiting,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}

Expand All @@ -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,
Expand Down Expand Up @@ -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<Any>).starProjectedType
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ abstract class AbstractSchemaInferenceTest(
fun `value classes`() =
assertSchemaMatches<Email>()

@Test
fun `nested generics`() =
assertSchemaMatches<Response<Page<Country>>>()

private inline fun <reified T : Any> assertSchemaMatches() {
val schema = inference.jsonSchema<T>()
val expected = readSchemaYaml<T>()
Expand Down Expand Up @@ -337,3 +341,14 @@ data class RecursiveNode(
val name: String,
val children: List<RecursiveNode>
)

@Serializable
data class Response<T>(
val data: T
)

@Serializable
data class Page<out E>(
val items: List<E>,
val total: Int,
)