diff --git a/gradle.properties b/gradle.properties index 86328173..2aaa940e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,7 +20,7 @@ ksp.useKSP2=true detekt.use.worker.api = true group=me.kpavlov.kt.schema -version=0.8.2-SNAPSHOT +version=0.8.4-SNAPSHOT versionSuffix=SNAPSHOT diff --git a/ksp-integration-tests/src/main/kotlin/me/kpavlov/kt/schema/integration/type/Trip.kt b/ksp-integration-tests/src/main/kotlin/me/kpavlov/kt/schema/integration/type/Trip.kt new file mode 100644 index 00000000..8ca3b101 --- /dev/null +++ b/ksp-integration-tests/src/main/kotlin/me/kpavlov/kt/schema/integration/type/Trip.kt @@ -0,0 +1,24 @@ +package me.kpavlov.kt.schema.integration.type + +import com.fasterxml.jackson.annotation.JacksonAnnotation +import me.kpavlov.kt.schema.Description +import me.kpavlov.kt.schema.Schema + +// Minimal inline value class to test flattening: no companion factory, no extra members. +@JvmInline +value class Age( + val value: Int, +) + +@Description("Distance in km") +@JvmInline +value class Distance( + val value: Double, +) + +@Schema +data class Trip( + @Description("Traveler's age") + val travelerAge: Age, + val distance: Distance, +) diff --git a/ksp-integration-tests/src/test/kotlin/me/kpavlov/kt/schema/integration/type/TripSchemaTest.kt b/ksp-integration-tests/src/test/kotlin/me/kpavlov/kt/schema/integration/type/TripSchemaTest.kt new file mode 100644 index 00000000..f6042f65 --- /dev/null +++ b/ksp-integration-tests/src/test/kotlin/me/kpavlov/kt/schema/integration/type/TripSchemaTest.kt @@ -0,0 +1,36 @@ +package me.kpavlov.kt.schema.integration.type + +import io.kotest.assertions.json.shouldEqualJson +import kotlin.test.Test + +/** + * Tests for Trip schema generation - inline value class flattening. + */ +class TripSchemaTest { + @Test + fun `flattens inline value class properties to their wrapped primitive type`() { + val schema = Trip::class.jsonSchemaString + + // language=json + schema shouldEqualJson + $$""" + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "me.kpavlov.kt.schema.integration.type.Trip", + "type": "object", + "properties": { + "travelerAge": { + "type": "integer", + "description": "Traveler's age" + }, + "distance": { + "type": "number", + "description": "Distance in km" + } + }, + "required": ["travelerAge", "distance"], + "additionalProperties": false + } + """.trimIndent() + } +} diff --git a/kt-schema-generator-core/src/jvmMain/kotlin/me/kpavlov/kt/schema/generator/reflect/ReflectionIntrospectionContext.kt b/kt-schema-generator-core/src/jvmMain/kotlin/me/kpavlov/kt/schema/generator/reflect/ReflectionIntrospectionContext.kt index adb1175e..759358c5 100644 --- a/kt-schema-generator-core/src/jvmMain/kotlin/me/kpavlov/kt/schema/generator/reflect/ReflectionIntrospectionContext.kt +++ b/kt-schema-generator-core/src/jvmMain/kotlin/me/kpavlov/kt/schema/generator/reflect/ReflectionIntrospectionContext.kt @@ -45,6 +45,7 @@ internal class ReflectionIntrospectionContext : BaseIntrospectionContext( * - Nullability from descriptor.isNullable * - Primitives (inlined) * - Collections (List, Map) (inlined) + * - Inline value classes (flattened to their wrapped element's type) * - Enums (referenced via TypeId) * - Objects/Classes (referenced via TypeId) * - Polymorphic types (referenced via TypeId) @@ -82,6 +83,11 @@ internal class ReflectionIntrospectionContext : BaseIntrospectionContext( return ref } + // @JvmInline value classes serialize as their wrapped value, not as an object with one + // property, so flatten them to the wrapped type's schema (same idea as the kotlinx.serialization + // front end's descriptor.isInline handling, just reached via reflection here). + if (klass.isValue) return flattenInlineValueClass(type) + // Handle different kinds return when { isListLike(klass) -> handleListType(type) @@ -211,6 +217,49 @@ internal class ReflectionIntrospectionContext : BaseIntrospectionContext( return ref } + /** + * Flattens an inline value class to the schema of its single wrapped property. + * + * On the JVM, a `@JvmInline value class` is erased to its wrapped value at the call site + * (e.g. a `Double`), never boxed as `{"value": 14.5}`, so the schema must follow suit. + * + * A class-level `@Description` on the value class is carried over onto the flattened + * primitive node, since there is no wrapper object left to attach it to. + * + * Falls back to [handleObjectType] when the wrapped property can't be determined, or for a + * value class that (transitively) wraps a collection of itself — flattening would otherwise + * recurse without end. + */ + private fun flattenInlineValueClass(type: KType): TypeRef { + val klass = type.klass + val wrappedType = findPrimaryConstructor(klass)?.parameters?.singleOrNull()?.type + if (wrappedType == null || type in visitingTypes) return handleObjectType(type) + + val nullable = type.effectiveNullable() + visitingTypes += type + val wrappedRef = + try { + toRef(wrappedType) + } finally { + visitingTypes -= type + } + + val classDescription = extractDescription(klass.java.annotations.toList()) + val resultRef = + if (classDescription != null && wrappedRef is TypeRef.Inline && wrappedRef.node is PrimitiveNode) { + TypeRef.Inline( + (wrappedRef.node as PrimitiveNode).copy(description = classDescription), + wrappedRef.nullable, + ) + } else { + wrappedRef + } + + val ref = if (nullable && !resultRef.nullable) resultRef.withNullable(true) else resultRef + if (!nullable) typeRefCache[type] = ref + return ref + } + /** * Handles enum types by creating an EnumNode and adding it to discovered nodes. */ diff --git a/kt-schema-generator-core/src/jvmTest/kotlin/me/kpavlov/kt/schema/generator/reflect/ReflectionIntrospectorTest.kt b/kt-schema-generator-core/src/jvmTest/kotlin/me/kpavlov/kt/schema/generator/reflect/ReflectionIntrospectorTest.kt index ce846db0..0bb8511e 100644 --- a/kt-schema-generator-core/src/jvmTest/kotlin/me/kpavlov/kt/schema/generator/reflect/ReflectionIntrospectorTest.kt +++ b/kt-schema-generator-core/src/jvmTest/kotlin/me/kpavlov/kt/schema/generator/reflect/ReflectionIntrospectorTest.kt @@ -54,20 +54,35 @@ class ReflectionIntrospectorTest { @Suppress("unused") sealed interface Vehicle { sealed interface Motorized : Vehicle { - data class Car(val doors: Int ) : Motorized - data class Truck(val payload: Double ) : Motorized + data class Car( + val doors: Int, + ) : Motorized + + data class Truck( + val payload: Double, + ) : Motorized } - data class Bicycle(val gears: Int ) : Vehicle + data class Bicycle( + val gears: Int, + ) : Vehicle } @Suppress("unused") sealed interface Event { - data class Click(val x: Int, val y: Int ) : Event - data class PageView(val url: String ) : Event + data class Click( + val x: Int, + val y: Int, + ) : Event + + data class PageView( + val url: String, + ) : Event @SchemaIgnore - data class Internal(val trace: String ) : Event + data class Internal( + val trace: String, + ) : Event } data class WithAny( @@ -82,6 +97,32 @@ class ReflectionIntrospectorTest { val mapping: Map<*, *>, ) + @JvmInline + value class Age( + val value: Int, + ) + + @Description("Distance in meters") + @JvmInline + value class DescribedDistance( + val value: Double, + ) + + data class WithInlineValueClass( + val age: Age, + val nullableAge: Age?, + val distance: DescribedDistance, + ) + + @JvmInline + value class RecursiveWrapper( + val items: List, + ) + + data class WithRecursiveInlineValueClass( + val wrapper: RecursiveWrapper, + ) + private val introspector = ReflectionClassIntrospector @Test @@ -323,4 +364,44 @@ class ReflectionIntrospectorTest { } } } + + @Test + fun `flattens inline value class to its wrapped primitive, carrying nullability and class description`() { + val graph = introspector.introspect(WithInlineValueClass::class) + + val root = graph.root.shouldBeInstanceOf() + val node = graph.nodes[root.id].shouldBeInstanceOf() + val props = node.properties.associateBy { it.name } + + // Age(Int) flattens to a bare INT primitive — no {"value": ...} wrapper. + props.getValue("age").type.shouldBeInstanceOf { inline -> + inline.node.shouldBeInstanceOf { prim -> + prim.kind shouldBe PrimitiveKind.INT + } + inline.nullable shouldBe false + } + + // Age? propagates nullability onto the flattened primitive. + props.getValue("nullableAge").type.shouldBeInstanceOf { inline -> + inline.nullable shouldBe true + } + + // A class-level @Description on the value class lands on the flattened primitive. + props.getValue("distance").type.shouldBeInstanceOf { inline -> + inline.node.shouldBeInstanceOf { prim -> + prim.kind shouldBe PrimitiveKind.DOUBLE + prim.description shouldBe "Distance in meters" + } + } + + // Neither Age nor DescribedDistance should appear as a named node in the graph. + graph.nodes.keys.none { it.value.endsWith(".Age") || it.value.endsWith(".DescribedDistance") } shouldBe true + } + + @Test + fun `inline value class wrapping a collection of itself falls back to a structural object instead of deadloop`() { + val graph = introspector.introspect(WithRecursiveInlineValueClass::class) + + graph.root.shouldBeInstanceOf() + } } diff --git a/kt-schema-ksp/src/main/kotlin/me/kpavlov/kt/schema/ksp/ir/KspIntrospectionContext.kt b/kt-schema-ksp/src/main/kotlin/me/kpavlov/kt/schema/ksp/ir/KspIntrospectionContext.kt index c0f73d4b..33198d6d 100644 --- a/kt-schema-ksp/src/main/kotlin/me/kpavlov/kt/schema/ksp/ir/KspIntrospectionContext.kt +++ b/kt-schema-ksp/src/main/kotlin/me/kpavlov/kt/schema/ksp/ir/KspIntrospectionContext.kt @@ -38,9 +38,10 @@ import me.kpavlov.kt.schema.generator.core.ir.withNullable * 4. Opaque JSON types (kotlinx.serialization.json and the rest of the Jackson databind node * hierarchy) → [AnyNode] → empty schema `{}` * 5. Generic type parameters and unknowns -> kotlin.Any via [handleAnyFallback] - * 6. Sealed class hierarchies -> PolymorphicNode via [handleSealedClass] - * 7. Enum classes -> EnumNode via [handleEnum] - * 8. Regular objects/classes -> ObjectNode via [handleObjectOrClass] + * 6. Inline value classes -> flattened to their wrapped element's type via [resolveInlineValueClassOrNull] + * 7. Sealed class hierarchies -> PolymorphicNode via [handleSealedClass] + * 8. Enum classes -> EnumNode via [handleEnum] + * 9. Regular objects/classes -> ObjectNode via [handleObjectOrClass] */ @OptIn(InternalSchemaGeneratorApi::class) @Suppress("TooManyFunctions") @@ -69,6 +70,7 @@ internal class KspIntrospectionContext : BaseIntrospectionContext() { ?: resolvePrimitiveTypeKindOrNull(type) ?: resolveOpaqueTypeOrNull(type) ?: handleAnyFallback(type) + ?: resolveInlineValueClassOrNull(type, nullable) ?: handleSealedClass(type, nullable) ?: handleEnum(type, nullable) ?: handleObjectOrClass(type, nullable), @@ -192,6 +194,56 @@ internal class KspIntrospectionContext : BaseIntrospectionContext() { return TypeRef.Inline(AnyNode(), nullable) } + /** + * Handles inline value classes (`@JvmInline value class Wrapper(val inner: T)`, surfaced by + * KSP as [Modifier.VALUE]) by delegating to the wrapped element's type. + * + * Inline value classes serialize as their inner value (e.g. `14.5` instead of + * `{"value": 14.5}`), so the schema must reflect the inner type. + * + * If the value class has a class-level `@Description` (or KDoc), it is propagated to the + * flattened primitive node so it still appears in the generated schema. + * + * Returns null (falling through to [handleObjectOrClass]) when [type] isn't a value class, + * its wrapped type can't be determined, or it (transitively) wraps itself — flattening that + * would recurse forever. + * + * @param type The KSType to check + * @param nullable Whether the type reference should be nullable + * @return The flattened TypeRef, or null if this isn't a flattenable inline value class + */ + @Suppress("ReturnCount") + private fun resolveInlineValueClassOrNull( + type: KSType, + nullable: Boolean, + ): TypeRef? { + val decl = type.declaration as? KSClassDeclaration ?: return null + if (Modifier.VALUE !in decl.modifiers) return null + val wrappedParam = decl.primaryConstructor?.parameters?.singleOrNull() ?: return null + if (type in visitingTypes) return null + + visitingTypes += type + val wrappedRef = + try { + toRef(wrappedParam.type.resolve()) + } finally { + visitingTypes -= type + } + + val classDescription = extractDescription(decl) { decl.descriptionFromKdoc() } + val resultRef = + if (classDescription != null && wrappedRef is TypeRef.Inline && wrappedRef.node is PrimitiveNode) { + TypeRef.Inline( + (wrappedRef.node as PrimitiveNode).copy(description = classDescription), + wrappedRef.nullable, + ) + } else { + wrappedRef + } + + return if (nullable && !resultRef.nullable) resultRef.withNullable(true) else resultRef + } + /** * Handles sealed class hierarchies by generating a PolymorphicNode. *