From a72f6ea7cd0f0d5a7e48f69926c2ce7a787a3bcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Tue, 24 Mar 2026 11:30:31 +0100 Subject: [PATCH 1/6] feat(13-01): restructure polymorphic generation to nested sealed hierarchies - ModelGenerator produces sealed class with nested data class subtypes for oneOf/anyOf-with-discriminator - Sealed interface pattern preserved only for anyOf-without-discriminator (JsonContentPolymorphicSerializer) - TypeMapping.toTypeName accepts classNameLookup for nested ClassName resolution - SerializersModuleGenerator uses parentClass.nestedClass(variant) for qualified references - One FileSpec per hierarchy, variant schemas filtered from separate file generation - All polymorphic tests updated for sealed class assertions, superclass checks, nested type verification Co-Authored-By: Claude Opus 4.6 (1M context) --- .../justworks/core/gen/ModelGenerator.kt | 255 ++++++++++++++---- .../core/gen/SerializersModuleGenerator.kt | 2 +- .../justworks/core/gen/TypeMapping.kt | 8 +- .../core/gen/ModelGeneratorPolymorphicTest.kt | 227 ++++++++++++---- 4 files changed, 383 insertions(+), 109 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt index f7d3bf4..a434085 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt @@ -27,17 +27,28 @@ import kotlin.time.Instant /** * Generates KotlinPoet [FileSpec] instances from an [ApiSpec]. * - * Produces one file per [SchemaModel] (data class, sealed interface, or allOf composed class) + * Produces one file per [SchemaModel] (data class, sealed class hierarchy, or allOf composed class) * and one file per [EnumModel] (enum class), all annotated with kotlinx.serialization annotations. */ class ModelGenerator(private val modelPackage: String, private val nameRegistry: NameRegistry) { - fun generate(spec: ApiSpec): List = context( - buildHierarchyInfo(spec.schemas), - ) { - val schemaFiles = spec.schemas.flatMap { generateSchemaFiles(it) } + fun generate(spec: ApiSpec): List { + val hierarchy = buildHierarchyInfo(spec.schemas) + return context(hierarchy) { generateFiles(spec) } + } + + context(hierarchy: HierarchyInfo) + private fun generateFiles(spec: ApiSpec): List { + val variantNames = hierarchy.sealedHierarchies.values + .flatten() + .toSet() + val classNameLookup = buildClassNameLookup() + + val schemaFiles = spec.schemas + .filter { it.name !in variantNames || it.name in hierarchy.anyOfWithoutDiscriminatorVariants } + .flatMap { generateSchemaFiles(it, classNameLookup) } val inlineSchemaFiles = collectAllInlineSchemas(spec).map { - if (it.isNested) generateNestedInlineClass(it) else generateDataClass(it) + if (it.isNested) generateNestedInlineClass(it, classNameLookup) else generateDataClass(it, classNameLookup) } val enumFiles = spec.enums.map(::generateEnumClass) @@ -46,7 +57,7 @@ class ModelGenerator(private val modelPackage: String, private val nameRegistry: val uuidSerializerFile = if (spec.usesUuid()) generateUuidSerializer() else null - schemaFiles + inlineSchemaFiles + enumFiles + listOfNotNull(serializersModuleFile, uuidSerializerFile) + return schemaFiles + inlineSchemaFiles + enumFiles + listOfNotNull(serializersModuleFile, uuidSerializerFile) } data class HierarchyInfo( @@ -54,7 +65,32 @@ class ModelGenerator(private val modelPackage: String, private val nameRegistry: val variantParents: Map>, val anyOfWithoutDiscriminator: Set, val schemas: List, - ) + ) { + /** Variant names that belong to anyOf-without-discriminator hierarchies (still use interface pattern). */ + val anyOfWithoutDiscriminatorVariants: Set by lazy { + sealedHierarchies + .filterKeys { it in anyOfWithoutDiscriminator } + .values + .flatten() + .toSet() + } + } + + companion object { + /** + * Builds a classNameLookup map from an ApiSpec for use by other generators (e.g. ClientGenerator). + * Maps variant schema names to their nested ClassName (e.g. "Circle" -> Shape.Circle). + */ + fun buildClassNameLookup(spec: ApiSpec, modelPackage: String): Map { + val generator = ModelGenerator(modelPackage, NameRegistry()) + return generator.buildClassNameLookupFromSpec(spec) + } + } + + private fun buildClassNameLookupFromSpec(spec: ApiSpec): Map { + val hierarchy = buildHierarchyInfo(spec.schemas) + return context(hierarchy) { buildClassNameLookup() } + } private fun buildHierarchyInfo(schemas: List): HierarchyInfo { fun SchemaModel.variants() = oneOf ?: anyOf ?: emptyList() @@ -89,6 +125,24 @@ class ModelGenerator(private val modelPackage: String, private val nameRegistry: return HierarchyInfo(sealedHierarchies, variantParents, anyOfWithoutDiscriminator, schemas) } + context(hierarchy: HierarchyInfo) + private fun buildClassNameLookup(): Map { + val lookup = mutableMapOf() + + for ((parent, variants) in hierarchy.sealedHierarchies) { + // Skip anyOf-without-discriminator — those still use flat names + if (parent in hierarchy.anyOfWithoutDiscriminator) continue + + val parentClass = ClassName(modelPackage, parent) + lookup[parent] = parentClass + for (variant in variants) { + lookup[variant] = parentClass.nestedClass(variant) + } + } + + return lookup + } + private fun collectAllInlineSchemas(spec: ApiSpec): List { val endpointRefs = spec.endpoints.flatMap { endpoint -> val requestRef = endpoint.requestBody?.schema @@ -117,52 +171,44 @@ class ModelGenerator(private val modelPackage: String, private val nameRegistry: } context(hierarchy: HierarchyInfo) - private fun generateSchemaFiles(schema: SchemaModel): List = when { - !schema.anyOf.isNullOrEmpty() || !schema.oneOf.isNullOrEmpty() -> { - if (schema.name in hierarchy.anyOfWithoutDiscriminator) { - listOf(generateSealedInterface(schema), generatePolymorphicSerializer(schema)) - } else { - listOf(generateSealedInterface(schema)) + private fun generateSchemaFiles(schema: SchemaModel, classNameLookup: Map): List = + when { + !schema.anyOf.isNullOrEmpty() || !schema.oneOf.isNullOrEmpty() -> { + if (schema.name in hierarchy.anyOfWithoutDiscriminator) { + listOf( + generateSealedInterface(schema), + generatePolymorphicSerializer(schema, classNameLookup), + ) + } else { + listOf(generateSealedHierarchy(schema, classNameLookup)) + } } - } - schema.isPrimitiveOnly -> { - val targetType = schema.underlyingType - ?.let { TypeMapping.toTypeName(it, modelPackage) } - ?: STRING - listOf(generateTypeAlias(schema, targetType)) - } + schema.isPrimitiveOnly -> { + val targetType = schema.underlyingType + ?.let { TypeMapping.toTypeName(it, modelPackage, classNameLookup) } + ?: STRING + listOf(generateTypeAlias(schema, targetType)) + } - else -> { - listOf(generateDataClass(schema)) + else -> { + listOf(generateDataClass(schema, classNameLookup)) + } } - } /** - * Generates a sealed interface for a oneOf/anyOf schema. - * - anyOf without discriminator: @Serializable(with = XxxSerializer::class) - * - oneOf or anyOf with discriminator: plain @Serializable + @JsonClassDiscriminator + * Generates a sealed class with nested data class subtypes for oneOf or anyOf-with-discriminator schemas. */ context(hierarchy: HierarchyInfo) - private fun generateSealedInterface(schema: SchemaModel): FileSpec { + private fun generateSealedHierarchy(schema: SchemaModel, classNameLookup: Map): FileSpec { val className = ClassName(modelPackage, schema.name) + val schemasById = hierarchy.schemas.associateBy { it.name } - val typeSpec = TypeSpec.interfaceBuilder(className).addModifiers(KModifier.SEALED) - - if (schema.name in hierarchy.anyOfWithoutDiscriminator) { - val serializerClassName = ClassName(modelPackage, "${schema.name}Serializer") - typeSpec.addAnnotation( - AnnotationSpec - .builder(SERIALIZABLE) - .addMember("with = %T::class", serializerClassName) - .build(), - ) - } else { - typeSpec.addAnnotation(SERIALIZABLE) - } + val parentBuilder = TypeSpec.classBuilder(className).addModifiers(KModifier.SEALED) + parentBuilder.addAnnotation(SERIALIZABLE) if (schema.discriminator != null) { - typeSpec.addAnnotation( + parentBuilder.addAnnotation( AnnotationSpec .builder(JSON_CLASS_DISCRIMINATOR) .addMember("%S", schema.discriminator.propertyName) @@ -171,10 +217,19 @@ class ModelGenerator(private val modelPackage: String, private val nameRegistry: } if (schema.description != null) { - typeSpec.addKdoc("%L", schema.description) + parentBuilder.addKdoc("%L", schema.description) } - val fileBuilder = FileSpec.builder(className).addType(typeSpec.build()) + // Generate nested subtypes + val variants = hierarchy.sealedHierarchies[schema.name].orEmpty() + for (variantName in variants) { + val variantSchema = schemasById[variantName] + val serialName = resolveSerialName(schema, variantName) + val nestedType = buildNestedVariant(variantSchema, variantName, className, serialName, classNameLookup) + parentBuilder.addType(nestedType) + } + + val fileBuilder = FileSpec.builder(className).addType(parentBuilder.build()) if (schema.discriminator != null) { fileBuilder.addAnnotation( @@ -188,11 +243,96 @@ class ModelGenerator(private val modelPackage: String, private val nameRegistry: return fileBuilder.build() } + /** + * Builds a nested data class TypeSpec for a variant inside a sealed class hierarchy. + */ + private fun buildNestedVariant( + variantSchema: SchemaModel?, + variantName: String, + parentClassName: ClassName, + serialName: String, + classNameLookup: Map, + ): TypeSpec { + val variantClassName = parentClassName.nestedClass(variantName) + val builder = TypeSpec.classBuilder(variantClassName).addModifiers(KModifier.DATA) + builder.superclass(parentClassName) + builder.addSuperclassConstructorParameter("") + builder.addAnnotation(SERIALIZABLE) + builder.addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", serialName).build()) + + if (variantSchema != null) { + val sortedProps = variantSchema.properties.sortedBy { prop -> + when { + prop.name in variantSchema.requiredProperties && prop.defaultValue == null -> 1 + prop.defaultValue != null -> 2 + else -> 3 + } + } + + val constructorBuilder = FunSpec.constructorBuilder() + val propertySpecs = sortedProps.map { prop -> + val type = TypeMapping + .toTypeName(prop.type, modelPackage, classNameLookup) + .copy(nullable = prop.nullable) + val kotlinName = prop.name.toCamelCase() + + val paramBuilder = ParameterSpec.builder(kotlinName, type) + when { + prop.nullable -> paramBuilder.defaultValue(CodeBlock.of("null")) + prop.defaultValue != null -> paramBuilder.defaultValue(formatDefaultValue(prop)) + } + constructorBuilder.addParameter(paramBuilder.build()) + + PropertySpec + .builder(kotlinName, type) + .initializer(kotlinName) + .addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", prop.name).build()) + .build() + } + builder.primaryConstructor(constructorBuilder.build()) + builder.addProperties(propertySpecs) + + if (variantSchema.description != null) { + builder.addKdoc("%L", variantSchema.description) + } + } else { + // Empty variant with no properties — still need a constructor for data class + builder.primaryConstructor(FunSpec.constructorBuilder().build()) + } + + return builder.build() + } + + /** + * Generates a sealed interface for anyOf without discriminator schemas. + * Only used for the JsonContentPolymorphicSerializer pattern. + */ + context(hierarchy: HierarchyInfo) + private fun generateSealedInterface(schema: SchemaModel): FileSpec { + val className = ClassName(modelPackage, schema.name) + + val typeSpec = TypeSpec.interfaceBuilder(className).addModifiers(KModifier.SEALED) + + val serializerClassName = ClassName(modelPackage, "${schema.name}Serializer") + typeSpec.addAnnotation( + AnnotationSpec + .builder(SERIALIZABLE) + .addMember("with = %T::class", serializerClassName) + .build(), + ) + + if (schema.description != null) { + typeSpec.addKdoc("%L", schema.description) + } + + return FileSpec.builder(className).addType(typeSpec.build()).build() + } + /** * Generates a JsonContentPolymorphicSerializer object for an anyOf schema without discriminator. */ context(hierarchy: HierarchyInfo) - private fun generatePolymorphicSerializer(schema: SchemaModel): FileSpec { + private fun generatePolymorphicSerializer(schema: SchemaModel, classNameLookup: Map,): FileSpec { val sealedClassName = ClassName(modelPackage, schema.name) val serializerClassName = ClassName(modelPackage, "${schema.name}Serializer") @@ -218,7 +358,7 @@ class ModelGenerator(private val modelPackage: String, private val nameRegistry: fields.firstOrNull { allFields[it] == 1 } } - val selectDeserializerBody = buildSelectDeserializerBody(schema.name, uniqueFieldsPerVariant) + val selectDeserializerBody = buildSelectDeserializerBody(schema.name, uniqueFieldsPerVariant, classNameLookup) val deserializationStrategy = ClassName("kotlinx.serialization", "DeserializationStrategy") .parameterizedBy(WildcardTypeName.producerOf(sealedClassName)) @@ -250,17 +390,19 @@ class ModelGenerator(private val modelPackage: String, private val nameRegistry: private fun buildSelectDeserializerBody( parentName: String, uniqueFieldsPerVariant: Map, + classNameLookup: Map, ): CodeBlock { val builder = CodeBlock.builder() builder.beginControlFlow("return when") val notUnique = uniqueFieldsPerVariant.mapNotNull { (variantName, uniqueField) -> if (uniqueField != null) { + val variantClass = classNameLookup[variantName] ?: ClassName(modelPackage, variantName) builder.addStatement( - "%S·in·element.%M -> %T.serializer()", + "%S\u00b7in\u00b7element.%M -> %T.serializer()", uniqueField, JSON_OBJECT_EXT, - ClassName(modelPackage, variantName), + variantClass, ) null } else { @@ -288,12 +430,19 @@ class ModelGenerator(private val modelPackage: String, private val nameRegistry: /** * Generates a data class FileSpec, with superinterfaces and @SerialName resolved from hierarchy. + * Used for: standalone schemas, allOf composed classes, and anyOf-without-discriminator variants. */ context(hierarchy: HierarchyInfo) - private fun generateDataClass(schema: SchemaModel): FileSpec { + private fun generateDataClass(schema: SchemaModel, classNameLookup: Map): FileSpec { val className = ClassName(modelPackage, schema.name) - val parentEntries = hierarchy.variantParents[schema.name].orEmpty() + // Only apply superinterfaces for anyOf-without-discriminator variants + val parentEntries = hierarchy.variantParents[schema.name] + .orEmpty() + .filterKeys { parentClass -> + val parentName = parentClass.simpleName + parentName in hierarchy.anyOfWithoutDiscriminator + } val serialName = parentEntries.values.firstOrNull() val superinterfaces = parentEntries.keys @@ -307,7 +456,7 @@ class ModelGenerator(private val modelPackage: String, private val nameRegistry: val constructorBuilder = FunSpec.constructorBuilder() val propertySpecs = sortedProps.map { prop -> - val type = TypeMapping.toTypeName(prop.type, modelPackage).copy(nullable = prop.nullable) + val type = TypeMapping.toTypeName(prop.type, modelPackage, classNameLookup).copy(nullable = prop.nullable) val kotlinName = prop.name.toCamelCase() val paramBuilder = ParameterSpec.builder(kotlinName, type) @@ -474,8 +623,8 @@ class ModelGenerator(private val modelPackage: String, private val nameRegistry: } context(_: HierarchyInfo) - private fun generateNestedInlineClass(schema: SchemaModel): FileSpec = - generateDataClass(schema.copy(name = schema.name.toInlinedName())) + private fun generateNestedInlineClass(schema: SchemaModel, classNameLookup: Map): FileSpec = + generateDataClass(schema.copy(name = schema.name.toInlinedName()), classNameLookup) private val SchemaModel.isPrimitiveOnly: Boolean get() = properties.isEmpty() && allOf == null && oneOf == null && anyOf == null diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt index 255807c..3d6bcd0 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/SerializersModuleGenerator.kt @@ -37,7 +37,7 @@ class SerializersModuleGenerator(private val modelPackage: String) { val parentClass = ClassName(modelPackage, parent) code.beginControlFlow("%M(%T::class)", POLYMORPHIC_FUN, parentClass) for (variant in variants) { - val variantClass = ClassName(modelPackage, variant) + val variantClass = parentClass.nestedClass(variant) code.addStatement("%M(%T::class)", SUBCLASS_FUN, variantClass) } code.endControlFlow() diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt index 8bb3cc2..47e9b48 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt @@ -19,7 +19,11 @@ import com.squareup.kotlinpoet.TypeName * Maps [TypeRef] sealed variants to KotlinPoet [TypeName] instances. */ object TypeMapping { - fun toTypeName(typeRef: TypeRef, modelPackage: String): TypeName = when (typeRef) { + fun toTypeName( + typeRef: TypeRef, + modelPackage: String, + classNameLookup: Map = emptyMap(), + ): TypeName = when (typeRef) { is TypeRef.Primitive -> { when (typeRef.type) { PrimitiveType.STRING -> STRING @@ -44,7 +48,7 @@ object TypeMapping { } is TypeRef.Reference -> { - ClassName(modelPackage, typeRef.schemaName) + classNameLookup[typeRef.schemaName] ?: ClassName(modelPackage, typeRef.schemaName) } is TypeRef.Inline -> { diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt index a1134d6..b37d55b 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/ModelGeneratorPolymorphicTest.kt @@ -7,6 +7,7 @@ import com.avsystem.justworks.core.model.PrimitiveType import com.avsystem.justworks.core.model.PropertyModel import com.avsystem.justworks.core.model.SchemaModel import com.avsystem.justworks.core.model.TypeRef +import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.TypeSpec import kotlin.test.Test @@ -18,7 +19,7 @@ class ModelGeneratorPolymorphicTest { private val modelPackage = "com.example.model" private val generator = ModelGenerator(modelPackage, NameRegistry()) - private fun spec(schemas: List = emptyList(), enums: List = emptyList(),) = ApiSpec( + private fun spec(schemas: List = emptyList(), enums: List = emptyList()) = ApiSpec( title = "Test", version = "1.0", endpoints = emptyList(), @@ -45,9 +46,20 @@ class ModelGeneratorPolymorphicTest { discriminator = discriminator, ) - private fun findType(files: List, name: String,): TypeSpec { + /** + * Recursively searches for a TypeSpec by name in files and nested types. + */ + private fun findType(files: List, name: String): TypeSpec { + fun searchIn(types: List): TypeSpec? { + for (type in types) { + if (type.name == name) return type + val nested = searchIn(type.typeSpecs) + if (nested != null) return nested + } + return null + } for (file in files) { - val found = file.members.filterIsInstance().find { it.name == name } + val found = searchIn(file.members.filterIsInstance()) if (found != null) return found } throw AssertionError("TypeSpec '$name' not found in generated files") @@ -63,10 +75,10 @@ class ModelGeneratorPolymorphicTest { throw AssertionError("FileSpec containing '$typeName' not found") } - // -- POLY-01: Sealed interface from oneOf -- + // -- POLY-01: Sealed class from oneOf (nested subtypes) -- @Test - fun `oneOf schema generates sealed interface with SEALED modifier`() { + fun `oneOf schema generates sealed class with SEALED modifier`() { val shapeSchema = schema( name = "Shape", @@ -89,7 +101,64 @@ class ModelGeneratorPolymorphicTest { val shapeType = findType(files, "Shape") assertTrue(KModifier.SEALED in shapeType.modifiers, "Expected SEALED modifier on Shape") - assertEquals(TypeSpec.Kind.INTERFACE, shapeType.kind, "Expected INTERFACE kind") + assertEquals(TypeSpec.Kind.CLASS, shapeType.kind, "Expected CLASS kind (not INTERFACE)") + } + + @Test + fun `oneOf subtypes are nested inside parent sealed class`() { + val shapeSchema = + schema( + name = "Shape", + oneOf = listOf(TypeRef.Reference("Circle"), TypeRef.Reference("Square")), + ) + val circleSchema = + schema( + name = "Circle", + properties = listOf(PropertyModel("radius", TypeRef.Primitive(PrimitiveType.DOUBLE), null, false)), + requiredProperties = setOf("radius"), + ) + val squareSchema = + schema( + name = "Square", + properties = listOf(PropertyModel("sideLength", TypeRef.Primitive(PrimitiveType.DOUBLE), null, false)), + requiredProperties = setOf("sideLength"), + ) + + val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema, squareSchema))) + val shapeType = findType(files, "Shape") + + val nestedNames = shapeType.typeSpecs.map { it.name } + assertTrue("Circle" in nestedNames, "Circle should be nested inside Shape. Nested: $nestedNames") + assertTrue("Square" in nestedNames, "Square should be nested inside Shape. Nested: $nestedNames") + } + + @Test + fun `oneOf hierarchy produces single file`() { + val shapeSchema = + schema( + name = "Shape", + oneOf = listOf(TypeRef.Reference("Circle"), TypeRef.Reference("Square")), + ) + val circleSchema = + schema( + name = "Circle", + properties = listOf(PropertyModel("radius", TypeRef.Primitive(PrimitiveType.DOUBLE), null, false)), + requiredProperties = setOf("radius"), + ) + val squareSchema = + schema( + name = "Square", + properties = listOf(PropertyModel("sideLength", TypeRef.Primitive(PrimitiveType.DOUBLE), null, false)), + requiredProperties = setOf("sideLength"), + ) + + val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema, squareSchema))) + + // No separate Circle.kt or Square.kt files + val fileNames = files.map { it.name } + assertTrue("Circle" !in fileNames, "Circle should NOT have separate file. Files: $fileNames") + assertTrue("Square" !in fileNames, "Square should NOT have separate file. Files: $fileNames") + assertTrue("Shape" in fileNames, "Shape file should exist. Files: $fileNames") } @Test @@ -106,13 +175,13 @@ class ModelGeneratorPolymorphicTest { val shapeType = findType(files, "Shape") val annotations = shapeType.annotations.map { it.typeName.toString() } - assertTrue("kotlinx.serialization.Serializable" in annotations, "Expected @Serializable on sealed interface") + assertTrue("kotlinx.serialization.Serializable" in annotations, "Expected @Serializable on sealed class") } - // -- POLY-02: Variant data classes implement sealed interface -- + // -- POLY-02: Variant subtypes extend sealed class -- @Test - fun `variant data class implements sealed interface`() { + fun `variant data class extends sealed class via superclass`() { val shapeSchema = schema( name = "Shape", @@ -128,10 +197,11 @@ class ModelGeneratorPolymorphicTest { val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema))) val circleType = findType(files, "Circle") - val superinterfaces = circleType.superinterfaces.keys.map { it.toString() } + // Should use superclass (not superinterfaces) since parent is sealed class + val superclass = circleType.superclass.toString() assertTrue( - "$modelPackage.Shape" in superinterfaces, - "Circle should implement Shape. Superinterfaces: $superinterfaces", + "$modelPackage.Shape" in superclass, + "Circle should extend Shape as superclass. Superclass: $superclass", ) } @@ -163,6 +233,30 @@ class ModelGeneratorPolymorphicTest { ) } + @Test + fun `variant data class has Serializable annotation`() { + val shapeSchema = + schema( + name = "Shape", + oneOf = listOf(TypeRef.Reference("Circle")), + ) + val circleSchema = + schema( + name = "Circle", + properties = listOf(PropertyModel("radius", TypeRef.Primitive(PrimitiveType.DOUBLE), null, false)), + requiredProperties = setOf("radius"), + ) + + val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema))) + val circleType = findType(files, "Circle") + + val annotations = circleType.annotations.map { it.typeName.toString() } + assertTrue( + "kotlinx.serialization.Serializable" in annotations, + "Nested variant should have @Serializable. Annotations: $annotations", + ) + } + // -- POLY-03: Discriminator -- @Test @@ -262,8 +356,6 @@ class ModelGeneratorPolymorphicTest { @Test fun `allOf schema produces data class with merged properties`() { - // SpecParser merges allOf properties before ModelGenerator sees them. - // So ExtendedDog already has all properties (from Dog + inline) in its SchemaModel. val dogSchema = schema( name = "Dog", @@ -291,7 +383,6 @@ class ModelGeneratorPolymorphicTest { val extendedDogType = findType(files, "ExtendedDog") val constructor = assertNotNull(extendedDogType.primaryConstructor, "Expected primary constructor") - // Should have all merged properties: name, breed from Dog + tricks from inline val paramNames = constructor.parameters.map { it.name } assertTrue("name" in paramNames, "Expected 'name' from Dog. Params: $paramNames") assertTrue("breed" in paramNames, "Expected 'breed' from Dog. Params: $paramNames") @@ -338,8 +429,7 @@ class ModelGeneratorPolymorphicTest { // -- POLY-07: oneOf with wrapper objects -- @Test - fun `oneOf with wrapper objects generates sealed interface with JsonClassDiscriminator`() { - // Create wrapper schema like AWS CloudControl's NetworkMeshDevice + fun `oneOf with wrapper objects generates sealed class with JsonClassDiscriminator`() { val extenderPropsSchema = schema( name = "ExtenderDeviceProperties", @@ -353,8 +443,6 @@ class ModelGeneratorPolymorphicTest { requiredProperties = setOf("macAddress"), ) - // Parent schema with oneOf pointing to wrapper variants - // Note: This test verifies the SpecParser has already unwrapped, so we pass the unwrapped form val networkMeshSchema = schema( name = "NetworkMeshDevice", @@ -379,8 +467,8 @@ class ModelGeneratorPolymorphicTest { ) val networkMeshType = findType(files, "NetworkMeshDevice") - // Verify sealed interface with discriminator assertTrue(KModifier.SEALED in networkMeshType.modifiers) + assertEquals(TypeSpec.Kind.CLASS, networkMeshType.kind, "Expected CLASS kind for discriminated oneOf") val discriminatorAnnotation = networkMeshType.annotations.find { it.typeName.toString() == "kotlinx.serialization.json.JsonClassDiscriminator" @@ -390,8 +478,7 @@ class ModelGeneratorPolymorphicTest { } @Test - fun `oneOf with wrapper objects generates correct SerialName on variants`() { - // Same setup as above + fun `oneOf with wrapper objects generates correct SerialName on nested variants`() { val extenderPropsSchema = schema( name = "ExtenderDeviceProperties", @@ -413,7 +500,6 @@ class ModelGeneratorPolymorphicTest { val files = generator.generate(spec(schemas = listOf(networkMeshSchema, extenderPropsSchema))) val extenderType = findType(files, "ExtenderDeviceProperties") - // Verify @SerialName uses wrapper property name val serialNameAnnotation = extenderType.annotations.find { it.typeName.toString() == "kotlinx.serialization.SerialName" @@ -425,7 +511,7 @@ class ModelGeneratorPolymorphicTest { ) } - // -- POLY-08: anyOf without discriminator -> JsonContentPolymorphicSerializer -- + // -- POLY-08: anyOf without discriminator -> JsonContentPolymorphicSerializer (UNCHANGED) -- @Test fun `anyOf without discriminator generates sealed interface with Serializable(with) annotation`() { @@ -447,6 +533,9 @@ class ModelGeneratorPolymorphicTest { val files = generator.generate(spec(schemas = listOf(unionSchema, creditCardSchema, bankTransferSchema))) val paymentType = findType(files, "Payment") + // anyOf without discriminator still uses sealed interface + assertEquals(TypeSpec.Kind.INTERFACE, paymentType.kind, "anyOf without discriminator should remain INTERFACE") + val serializableAnnotation = paymentType.annotations.find { it.typeName.toString() == "kotlinx.serialization.Serializable" } @@ -524,7 +613,6 @@ class ModelGeneratorPolymorphicTest { name = "Payment", anyOf = listOf(TypeRef.Reference("TypeA"), TypeRef.Reference("TypeB")), ) - // Both variants share the same field "amount" - no unique fields val typeASchema = schema( name = "TypeA", properties = listOf(PropertyModel("amount", TypeRef.Primitive(PrimitiveType.DOUBLE), null, false)), @@ -576,8 +664,7 @@ class ModelGeneratorPolymorphicTest { } @Test - fun `anyOf with discriminator NOT affected by JsonContentPolymorphicSerializer path`() { - // Ensure the discriminator-present anyOf still uses the old SerializersModule path + fun `anyOf with discriminator generates sealed class with nested subtypes`() { val shapeSchema = schema( name = "Shape", anyOf = listOf(TypeRef.Reference("Circle"), TypeRef.Reference("Square")), @@ -592,6 +679,9 @@ class ModelGeneratorPolymorphicTest { val files = generator.generate(spec(schemas = listOf(shapeSchema, circleSchema, squareSchema))) val shapeType = findType(files, "Shape") + // Should be sealed class (not interface) for anyOf with discriminator + assertEquals(TypeSpec.Kind.CLASS, shapeType.kind, "Discriminated anyOf should be sealed CLASS") + // Should have plain @Serializable, NOT @Serializable(with = ...) val serializableAnnotation = shapeType.annotations.find { it.typeName.toString() == "kotlinx.serialization.Serializable" @@ -602,6 +692,11 @@ class ModelGeneratorPolymorphicTest { "Discriminated anyOf should use plain @Serializable, not @Serializable(with = ...). Members: ${serializableAnnotation.members}", ) + // Subtypes should be nested + val nestedNames = shapeType.typeSpecs.map { it.name } + assertTrue("Circle" in nestedNames, "Circle should be nested inside Shape. Nested: $nestedNames") + assertTrue("Square" in nestedNames, "Square should be nested inside Shape. Nested: $nestedNames") + // ShapeSerializer should NOT be generated val serializerTypes = files.flatMap { it.members.filterIsInstance() } val shapeSerializerType = serializerTypes.find { it.name == "ShapeSerializer" } @@ -615,7 +710,7 @@ class ModelGeneratorPolymorphicTest { // -- CEM-01: boolean discriminator names (KotlinPoet handles escaping) -- @Test - fun `boolean discriminator names produce valid data classes`() { + fun `boolean discriminator names produce valid nested data classes`() { val deviceStatusSchema = schema( name = "DeviceStatus", oneOf = listOf( @@ -655,21 +750,21 @@ class ModelGeneratorPolymorphicTest { val falseType = findType(files, "false") assertTrue(KModifier.DATA in falseType.modifiers, "'false' should be data class") - // Both implement DeviceStatus sealed interface - val trueSuperinterfaces = trueType.superinterfaces.keys.map { it.toString() } + // Both should extend DeviceStatus sealed class as superclass + val trueSuperclass = trueType.superclass.toString() assertTrue( - "$modelPackage.DeviceStatus" in trueSuperinterfaces, - "'true' should implement DeviceStatus. Superinterfaces: $trueSuperinterfaces", + "$modelPackage.DeviceStatus" in trueSuperclass, + "'true' should extend DeviceStatus. Superclass: $trueSuperclass", ) - val falseSuperinterfaces = falseType.superinterfaces.keys.map { it.toString() } + val falseSuperclass = falseType.superclass.toString() assertTrue( - "$modelPackage.DeviceStatus" in falseSuperinterfaces, - "'false' should implement DeviceStatus. Superinterfaces: $falseSuperinterfaces", + "$modelPackage.DeviceStatus" in falseSuperclass, + "'false' should extend DeviceStatus. Superclass: $falseSuperclass", ) } @Test - fun `all oneOf variant schemas generate data classes even with many subtypes`() { + fun `all oneOf variant schemas generate nested data classes even with many subtypes`() { val variantNames = listOf( "ExtenderDevice", "EthernetDevice", @@ -702,21 +797,19 @@ class ModelGeneratorPolymorphicTest { spec(schemas = listOf(networkMeshSchema) + variantSchemas), ) - // All 6 variants generated + // Parent should be sealed class + val networkMeshType = findType(files, "NetworkMeshDevice") + assertEquals(TypeSpec.Kind.CLASS, networkMeshType.kind, "Expected sealed CLASS") + + // All 6 variants nested inside parent + val nestedNames = networkMeshType.typeSpecs.map { it.name } for (name in variantNames) { - val variantType = findType(files, name) - assertTrue( - KModifier.DATA in variantType.modifiers, - "$name should be a data class", - ) - val superinterfaces = variantType.superinterfaces.keys.map { it.toString() } - assertTrue( - "$modelPackage.NetworkMeshDevice" in superinterfaces, - "$name should implement NetworkMeshDevice. Superinterfaces: $superinterfaces", - ) + assertTrue(name in nestedNames, "$name should be nested inside NetworkMeshDevice. Nested: $nestedNames") + val variantType = networkMeshType.typeSpecs.find { it.name == name }!! + assertTrue(KModifier.DATA in variantType.modifiers, "$name should be a data class") } - // SerializersModule contains all variants + // SerializersModule contains all variants with nested references val serializersModuleFile = files.find { it.name == "SerializersModule" } assertNotNull(serializersModuleFile, "SerializersModule file should be generated") val moduleCode = serializersModuleFile.toString() @@ -729,7 +822,7 @@ class ModelGeneratorPolymorphicTest { } @Test - fun `SerializersModule includes boolean variant names`() { + fun `SerializersModule includes boolean variant names with nested references`() { val deviceStatusSchema = schema( name = "DeviceStatus", oneOf = listOf( @@ -779,7 +872,7 @@ class ModelGeneratorPolymorphicTest { // -- POLY-06: allOf with sealed parent -- @Test - fun `allOf referencing oneOf parent adds superinterface`() { + fun `allOf referencing oneOf parent - variant is nested in parent`() { val petSchema = schema( name = "Pet", @@ -799,10 +892,38 @@ class ModelGeneratorPolymorphicTest { val files = generator.generate(spec(schemas = listOf(petSchema, dogSchema))) val dogType = findType(files, "Dog") - val superinterfaces = dogType.superinterfaces.keys.map { it.toString() } + // Dog should extend Pet (sealed class) as superclass + val superclass = dogType.superclass.toString() assertTrue( - "$modelPackage.Pet" in superinterfaces, - "Dog should have Pet as superinterface. Superinterfaces: $superinterfaces", + "$modelPackage.Pet" in superclass, + "Dog should have Pet as superclass. Superclass: $superclass", + ) + } + + // -- TypeMapping with classNameLookup -- + + @Test + fun `TypeMapping resolves variant to nested ClassName with lookup`() { + val lookup = mapOf( + "Circle" to ClassName(modelPackage, "Shape").nestedClass("Circle"), + "Square" to ClassName(modelPackage, "Shape").nestedClass("Square"), + ) + + val result = TypeMapping.toTypeName(TypeRef.Reference("Circle"), modelPackage, lookup) + assertEquals( + ClassName(modelPackage, "Shape", "Circle"), + result, + "Should resolve Circle to Shape.Circle with lookup", + ) + } + + @Test + fun `TypeMapping falls back to flat ClassName without lookup`() { + val result = TypeMapping.toTypeName(TypeRef.Reference("Circle"), modelPackage) + assertEquals( + ClassName(modelPackage, "Circle"), + result, + "Should resolve Circle to flat ClassName without lookup", ) } } From cbf68047fa99c7a6e32230d1a0b0bd45d6f9e846 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Tue, 24 Mar 2026 11:31:52 +0100 Subject: [PATCH 2/6] feat(13-01): wire classNameLookup through ClientGenerator and CodeGenerator - ClientGenerator accepts classNameLookup for nested ClassName resolution in endpoint signatures - CodeGenerator builds classNameLookup via ModelGenerator companion and passes to ClientGenerator - All three TypeMapping.toTypeName call sites in ClientGenerator updated - Full test suite passes Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/avsystem/justworks/core/gen/ClientGenerator.kt | 7 ++++--- .../com/avsystem/justworks/core/gen/CodeGenerator.kt | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt index 0c3707b..a0c96eb 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/ClientGenerator.kt @@ -35,6 +35,7 @@ class ClientGenerator( private val apiPackage: String, private val modelPackage: String, private val nameRegistry: NameRegistry, + private val classNameLookup: Map = emptyMap(), ) { fun generate(spec: ApiSpec, hasPolymorphicTypes: Boolean = false): List { val grouped = spec.endpoints.groupBy { it.tags.firstOrNull() ?: DEFAULT_TAG } @@ -100,7 +101,7 @@ class ClientGenerator( val params = endpoint.parameters.groupBy { it.location } val pathParams = params[ParameterLocation.PATH].orEmpty().map { param -> - ParameterSpec(param.name.toCamelCase(), TypeMapping.toTypeName(param.schema, modelPackage)) + ParameterSpec(param.name.toCamelCase(), TypeMapping.toTypeName(param.schema, modelPackage, classNameLookup)) } val queryParams = params[ParameterLocation.QUERY].orEmpty().map { param -> @@ -129,7 +130,7 @@ class ClientGenerator( name: String, required: Boolean, ): ParameterSpec { - val baseType = TypeMapping.toTypeName(typeRef, modelPackage) + val baseType = TypeMapping.toTypeName(typeRef, modelPackage, classNameLookup) val builder = ParameterSpec.builder(name.toCamelCase(), baseType.copy(nullable = !required)) if (!required) builder.defaultValue("null") @@ -207,7 +208,7 @@ class ClientGenerator( .asSequence() .filter { it.key.startsWith("2") } .firstNotNullOfOrNull { it.value.schema } - ?.let { successResponse -> TypeMapping.toTypeName(successResponse, modelPackage) } + ?.let { successResponse -> TypeMapping.toTypeName(successResponse, modelPackage, classNameLookup) } ?: UNIT /** diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt index 6464710..492df21 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/gen/CodeGenerator.kt @@ -26,8 +26,10 @@ object CodeGenerator { modelFiles.forEach { it.writeTo(outputDir) } val hasPolymorphicTypes = modelFiles.any { it.name == SerializersModuleGenerator.FILE_NAME } + val classNameLookup = ModelGenerator.buildClassNameLookup(spec, modelPackage) - val clientFiles = ClientGenerator(apiPackage, modelPackage, apiRegistry).generate(spec, hasPolymorphicTypes) + val clientFiles = ClientGenerator(apiPackage, modelPackage, apiRegistry, classNameLookup) + .generate(spec, hasPolymorphicTypes) clientFiles.forEach { it.writeTo(outputDir) } return Result(modelFiles.size, clientFiles.size) From 8682701b01c19960235f3cccda20f700ca9628be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 25 Mar 2026 11:01:19 +0100 Subject: [PATCH 3/6] refactor(core): migrate issue handling to Arrow IorRaise with typed warnings Replace SpecValidator.ValidationIssue and string-based error lists with typed Issue.Error/Issue.Warning and Arrow's IorRaise for non-short-circuiting warning accumulation. ParseResult now carries List and a single Issue.Error on failure. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/avsystem/justworks/core/Issue.kt | 25 +++++++ .../justworks/core/parser/SpecParser.kt | 70 ++++++++++++------- .../justworks/core/parser/SpecValidator.kt | 69 +++++++----------- .../justworks/core/gen/IntegrationTest.kt | 2 +- .../justworks/core/parser/SpecParserTest.kt | 23 +++--- .../core/parser/SpecParserTestBase.kt | 2 +- .../core/parser/SpecValidatorTest.kt | 36 ++++++---- .../justworks/gradle/JustworksGenerateTask.kt | 9 +-- 8 files changed, 132 insertions(+), 104 deletions(-) create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt b/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt new file mode 100644 index 0000000..afeef42 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt @@ -0,0 +1,25 @@ +@file:OptIn(ExperimentalContracts::class, ExperimentalRaiseAccumulateApi::class) + +package com.avsystem.justworks.core + +import arrow.core.Nel +import arrow.core.nonEmptyListOf +import arrow.core.raise.ExperimentalRaiseAccumulateApi +import arrow.core.raise.IorRaise +import arrow.core.raise.RaiseAccumulate +import kotlin.contracts.ExperimentalContracts + +object Issue { + data class Error(val message: String) + + @JvmInline + value class Warning(val message: String) +} + +typealias Warnings = IorRaise> + +context(warnings: Warnings) +fun warn(message: String): Nothing? { + warnings.accumulate(nonEmptyListOf(Issue.Warning(message))) + return null +} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt index 38ecf01..795396b 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -1,13 +1,17 @@ package com.avsystem.justworks.core.parser -import arrow.core.compareTo import arrow.core.fold -import arrow.core.merge -import arrow.core.raise.context.Raise +import arrow.core.getOrElse +import arrow.core.left +import arrow.core.raise.ExperimentalRaiseAccumulateApi +import arrow.core.raise.Raise +import arrow.core.raise.context.either import arrow.core.raise.context.ensure import arrow.core.raise.context.ensureNotNull -import arrow.core.raise.either +import arrow.core.raise.iorNel import arrow.core.raise.nullable +import com.avsystem.justworks.core.Issue +import com.avsystem.justworks.core.Warnings import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.Discriminator import com.avsystem.justworks.core.model.Endpoint @@ -22,6 +26,7 @@ import com.avsystem.justworks.core.model.RequestBody import com.avsystem.justworks.core.model.Response import com.avsystem.justworks.core.model.SchemaModel import com.avsystem.justworks.core.model.TypeRef +import com.avsystem.justworks.core.warn import io.swagger.parser.OpenAPIParser import io.swagger.v3.oas.models.OpenAPI import io.swagger.v3.oas.models.PathItem @@ -38,17 +43,20 @@ import io.swagger.v3.oas.models.parameters.Parameter as SwaggerParameter * ```kotlin * when (val result = SpecParser.parse(file)) { * is ParseResult.Success -> result.apiSpec - * is ParseResult.Failure -> handleErrors(result.errors) + * is ParseResult.Failure -> handleErrors(result.error) * } * ``` * * Both [Success] and [Failure] may carry [warnings] about non-fatal issues * encountered during parsing or validation. */ + sealed interface ParseResult { - data class Success(val apiSpec: ApiSpec, val warnings: List = emptyList()) : ParseResult + val warnings: List + + data class Success(val apiSpec: ApiSpec, override val warnings: List) : ParseResult - data class Failure(val errors: List, val warnings: List = emptyList()) : ParseResult + data class Failure(val error: Issue.Error, override val warnings: List) : ParseResult } object SpecParser { @@ -66,36 +74,43 @@ object SpecParser { * @return [ParseResult.Success] with the parsed model and any warnings, or * [ParseResult.Failure] with a non-empty list of error messages */ - fun parse(specFile: File): ParseResult = either { + @OptIn(ExperimentalRaiseAccumulateApi::class) + fun parse(specFile: File): ParseResult { val parseOptions = ParseOptions().apply { isResolve = true isResolveFully = true isResolveCombinators = false } - val swaggerResult = OpenAPIParser().readLocation(specFile.absolutePath, null, parseOptions) - val openApi = swaggerResult.openAPI - val swaggerMessages = swaggerResult.messages.orEmpty() + val result = iorNel { + either { + val swaggerResult = OpenAPIParser().readLocation(specFile.absolutePath, null, parseOptions) - ensureNotNull(openApi) { - ParseResult.Failure(swaggerMessages.ifEmpty { listOf("Failed to parse spec: ${specFile.name}") }) - } + swaggerResult?.messages?.forEach { warn(it) } - val validationIssues = SpecValidator.validate(openApi) - val (errors, warnings) = validationIssues.partition { it is SpecValidator.ValidationIssue.Error } - val allWarnings = warnings.map { it.message } + swaggerMessages + val openApi = swaggerResult?.openAPI - ensure(errors.isEmpty()) { - ParseResult.Failure(errors.map { it.message }, allWarnings) + ensureNotNull(openApi) { + Issue.Error("Failed to parse spec: ${specFile.name}") + } + + SpecValidator.validate(openApi) + openApi.toApiSpec() + } } + val warnings = result.leftOrNull().orEmpty() + val either = result.getOrElse { Issue.Error("Failed to parse spec: ${specFile.name}").left() } - ParseResult.Success(openApi.toApiSpec(), warnings = allWarnings) - }.merge() + return either.fold( + ifLeft = { ParseResult.Failure(it, warnings) }, + ifRight = { ParseResult.Success(it, warnings) }, + ) + } private typealias ComponentSchemaIdentity = IdentityHashMap, String> private typealias ComponentSchemas = MutableMap> - context(_: Raise) + context(_: Raise, _: Warnings) private fun OpenAPI.toApiSpec(): ApiSpec { val allSchemas = components?.schemas.orEmpty() @@ -207,9 +222,9 @@ object SpecParser { description = description, ) - // --- Schema extraction --- +// --- Schema extraction --- - context(_: Raise, _: ComponentSchemaIdentity, _: ComponentSchemas) + context(_: Raise, _: ComponentSchemaIdentity, _: ComponentSchemas) private fun extractSchemaModel(name: String, schema: Schema<*>): SchemaModel { val allOf = schema.allOf?.mapNotNull { it.resolveName() } @@ -219,7 +234,7 @@ object SpecParser { val anyOf = schema.anyOf?.mapNotNull { it.resolveName() } ensure(oneOf.isNullOrEmpty() || anyOf.isNullOrEmpty()) { - ParseResult.Failure(listOf("Schema '$name' has both oneOf and anyOf. Use one combinator only.")) + Issue.Error("Schema '$name' has both oneOf and anyOf. Use one combinator only.") } val (properties, requiredProps) = @@ -268,9 +283,9 @@ object SpecParser { values = schema.enum.map { it.toString() }, ) - // --- allOf property merging --- +// --- allOf property merging --- - context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) + context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun extractAllOfProperties(parentName: String, schema: Schema<*>): Pair, Set> { val topRequired = schema.required.orEmpty().toSet() val contextCreator: (String) -> String? = { propName -> "$parentName.${propName.toPascalCase()}" } @@ -419,6 +434,7 @@ object SpecParser { split("-", "_", ".").joinToString("") { part -> part.replaceFirstChar { it.uppercase() } } private const val JSON_CONTENT_TYPE = "application/json" + private const val SCHEMA_PREFIX = "#/components/schemas/" private val STRING_FORMAT_MAP = mapOf( diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt index 8ccc15d..271ca93 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt @@ -1,61 +1,40 @@ package com.avsystem.justworks.core.parser -import arrow.core.raise.ExperimentalRaiseAccumulateApi -import arrow.core.raise.context.accumulate -import arrow.core.raise.context.ensureNotNullOrAccumulate -import arrow.core.raise.context.ensureOrAccumulate -import arrow.core.raise.fold +import com.avsystem.justworks.core.Warnings +import com.avsystem.justworks.core.warn import io.swagger.v3.oas.models.OpenAPI object SpecValidator { - sealed class ValidationIssue { - abstract val message: String - - class Error(override val message: String) : ValidationIssue() - - class Warning(override val message: String) : ValidationIssue() - } - /** * Validates a parsed OpenAPI model for required fields and unsupported constructs. * - * Collects all issues without short-circuiting (using Arrow [accumulate]) so that - * callers receive the full list of problems in a single call. - * - * Returned issues are either [ValidationIssue.Error] (spec is unusable) or - * [ValidationIssue.Warning] (spec can be processed but some features will be ignored). + * Accumulates all warnings without short-circuiting so that callers receive the + * full list of problems in a single call. * * @param openApi the parsed OpenAPI model from Swagger Parser - * @return list of [ValidationIssue]; empty when the spec is fully valid */ - @OptIn(ExperimentalRaiseAccumulateApi::class) - fun validate(openApi: OpenAPI): List = fold( - { - accumulate { - ensureNotNullOrAccumulate(openApi.info) { - ValidationIssue.Error("Spec is missing required 'info' section") - } + context(_: Warnings) + fun validate(openApi: OpenAPI) { + if (openApi.info == null) { + warn("Spec is missing required 'info' section") + } - ensureOrAccumulate(!openApi.paths.isNullOrEmpty()) { - ValidationIssue.Warning("Spec has no paths defined") - } - // Detect unsupported constructs for v1 - openApi.paths?.values?.forEach { pathItem -> - pathItem.readOperationsMap()?.values?.forEach { operation -> - ensureOrAccumulate(operation.callbacks.isNullOrEmpty()) { - ValidationIssue.Warning("Callbacks are not supported in v1 and will be ignored") - } - } - } + if (openApi.paths.isNullOrEmpty()) { + warn("Spec has no paths defined") + } - openApi.components?.links?.let { links -> - ensureOrAccumulate(links.isEmpty()) { - ValidationIssue.Warning("Links are not supported in v1 and will be ignored") - } + openApi.paths?.values?.forEach { pathItem -> + pathItem.readOperationsMap()?.values?.forEach { operation -> + if (!operation.callbacks.isNullOrEmpty()) { + warn("Callbacks are not supported in v1 and will be ignored") } } - }, - { it }, - { emptyList() }, - ) + } + + openApi.components?.links?.let { links -> + if (links.isNotEmpty()) { + warn("Links are not supported in v1 and will be ignored") + } + } + } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt index 556509e..cbf97de 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/gen/IntegrationTest.kt @@ -31,7 +31,7 @@ class IntegrationTest { val specFile = File(specUrl.toURI()) return when (val result = SpecParser.parse(specFile)) { is ParseResult.Success -> result - is ParseResult.Failure -> fail("Failed to parse $resourcePath: ${result.errors}") + is ParseResult.Failure -> fail("Failed to parse $resourcePath: ${result.error}") } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt index 8b66c06..7e5c01d 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt @@ -1,5 +1,6 @@ package com.avsystem.justworks.core.parser +import com.avsystem.justworks.core.Issue import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.EnumBackingType import com.avsystem.justworks.core.model.HttpMethod @@ -30,7 +31,7 @@ class SpecParserTest : SpecParserTestBase() { private fun parseSpecErrors(file: File): List { val result = SpecParser.parse(file) check(result is ParseResult.Failure) { "Expected failure" } - return result.errors + return result.warnings.map { it.message } + result.error.message } // -- SPEC-01: OpenAPI 3.0 parsing -- @@ -220,22 +221,22 @@ class SpecParserTest : SpecParserTestBase() { assertEquals(ParameterLocation.QUERY, limitParam.location) } - // -- SPEC-03: Error reporting -- + // -- SPEC-03: Warning reporting -- @Test - fun `parse invalid spec returns Failure`() { + fun `parse spec with missing info produces warnings`() { val result = SpecParser.parse(loadResource("invalid-spec.yaml")) - assertIs(result) + assertIs(result) + assertTrue(result.warnings.isNotEmpty(), "Spec with missing info should produce warnings") } @Test - fun `parse invalid spec has descriptive error messages`() { - val errors = parseSpecErrors(loadResource("invalid-spec.yaml")) - - assertTrue(errors.isNotEmpty(), "Failure should have error messages") - // Errors should be human-readable, not empty or codes-only - errors.forEach { error -> - assertTrue(error.length > 5, "Error message too short to be useful: '$error'") + fun `parse spec with missing info has descriptive warning messages`() { + val result = SpecParser.parse(loadResource("invalid-spec.yaml")) + assertIs(result) + assertTrue(result.warnings.isNotEmpty(), "Should have warning messages") + result.warnings.forEach { warning -> + assertTrue(warning.message.length > 5, "Warning message too short to be useful: '$warning'") } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTestBase.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTestBase.kt index e0d5fa1..0ea1c85 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTestBase.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTestBase.kt @@ -14,6 +14,6 @@ abstract class SpecParserTestBase { protected fun parseSpec(file: File): ApiSpec = when (val result = SpecParser.parse(file)) { is ParseResult.Success -> result.apiSpec - is ParseResult.Failure -> fail("Expected success but got errors: ${result.errors}") + is ParseResult.Failure -> fail("Expected success but got error: ${result.error}") } } diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecValidatorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecValidatorTest.kt index 67de584..a154f54 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecValidatorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecValidatorTest.kt @@ -1,18 +1,26 @@ package com.avsystem.justworks.core.parser +import arrow.core.raise.iorNel +import com.avsystem.justworks.core.Issue import io.swagger.v3.oas.models.OpenAPI import io.swagger.v3.oas.models.PathItem import io.swagger.v3.oas.models.Paths import io.swagger.v3.oas.models.info.Info import kotlin.test.Test -import kotlin.test.assertIs import kotlin.test.assertTrue class SpecValidatorTest { + private fun validateAndCollectWarnings(openApi: OpenAPI): List = + iorNel { SpecValidator.validate(openApi) }.fold( + { warnings -> warnings }, + { emptyList() }, + { warnings, _ -> warnings }, + ) + // -- VALID-01: Valid spec -- @Test - fun `valid OpenAPI object produces no errors`() { + fun `valid OpenAPI object produces no issues`() { val openApi = OpenAPI().apply { info = @@ -26,14 +34,14 @@ class SpecValidatorTest { } } - val errors = SpecValidator.validate(openApi) - assertTrue(errors.isEmpty(), "Valid spec should produce no errors, got: $errors") + val warnings = validateAndCollectWarnings(openApi) + assertTrue(warnings.isEmpty(), "Valid spec should produce no warnings, got: $warnings") } // -- VALID-02: Missing required fields -- @Test - fun `OpenAPI with null info produces errors`() { + fun `OpenAPI with null info produces warning`() { val openApi = OpenAPI().apply { info = null @@ -43,11 +51,11 @@ class SpecValidatorTest { } } - val issues = SpecValidator.validate(openApi) - assertTrue(issues.isNotEmpty(), "Missing info should produce issues") + val warnings = validateAndCollectWarnings(openApi) + assertTrue(warnings.isNotEmpty(), "Missing info should produce warnings") assertTrue( - issues.any { it.message.contains("info", ignoreCase = true) }, - "Error should mention 'info': $issues", + warnings.any { it.message.contains("info", ignoreCase = true) }, + "Warning should mention 'info': $warnings", ) } @@ -65,13 +73,11 @@ class SpecValidatorTest { paths = null } - val issues = SpecValidator.validate(openApi) - assertTrue(issues.isNotEmpty(), "Spec with no paths should produce issues") - val warning = issues.firstOrNull { it is SpecValidator.ValidationIssue.Warning } - assertIs(warning, "Expected a Warning for no paths, got: $issues") + val warnings = validateAndCollectWarnings(openApi) + assertTrue(warnings.isNotEmpty(), "Spec with no paths should produce warnings") assertTrue( - warning.message.contains("paths", ignoreCase = true), - "Warning should mention 'paths': ${warning.message}", + warnings.any { it.message.contains("paths", ignoreCase = true) }, + "Warning should mention 'paths': $warnings", ) } } diff --git a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksGenerateTask.kt b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksGenerateTask.kt index 51c4f91..434684b 100644 --- a/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksGenerateTask.kt +++ b/plugin/src/main/kotlin/com/avsystem/justworks/gradle/JustworksGenerateTask.kt @@ -47,11 +47,12 @@ abstract class JustworksGenerateTask : DefaultTask() { val outDir = outputDir.get().asFile.recreateDirectory() val spec = specFile.get().asFile - when (val result = SpecParser.parse(spec)) { + val result = SpecParser.parse(spec) + result.warnings.forEach { logger.warn(it.message) } + + when (result) { is ParseResult.Failure -> { - throw GradleException( - "Failed to parse spec (task: $name): ${spec.name}:\n${result.errors.joinToString("\n")}", - ) + throw GradleException("Failed to parse spec (task: $name): ${spec.name}:\n${result.error}") } is ParseResult.Success -> { From a79f34434ff0feec7536c16198cd70f7c6be2041 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 25 Mar 2026 13:09:08 +0100 Subject: [PATCH 4/6] refactor(core): extract Arrow-based helpers for validation and replace warn with ensureOrAccumulate / ensureNotNullOrAccumulate --- .../avsystem/justworks/core/ArrowHelpers.kt | 26 +++++++++++++++++++ .../com/avsystem/justworks/core/Issue.kt | 11 +++----- .../justworks/core/parser/SpecParser.kt | 10 ++++--- .../justworks/core/parser/SpecValidator.kt | 20 +++++++------- 4 files changed, 48 insertions(+), 19 deletions(-) create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt b/core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt new file mode 100644 index 0000000..5777d9f --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/ArrowHelpers.kt @@ -0,0 +1,26 @@ +package com.avsystem.justworks.core + +import arrow.core.Nel +import arrow.core.nonEmptyListOf +import arrow.core.raise.IorRaise +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind.AT_MOST_ONCE +import kotlin.contracts.contract + +@OptIn(ExperimentalContracts::class) +context(warnings: IorRaise>) +inline fun ensureOrAccumulate(condition: Boolean, error: () -> Error) { + contract { callsInPlace(error, AT_MOST_ONCE) } + if (!condition) { + warnings.accumulate(nonEmptyListOf(error())) + } +} + +@OptIn(ExperimentalContracts::class) +context(warnings: IorRaise>) +inline fun ensureNotNullOrAccumulate(value: B?, error: () -> Error) { + contract { callsInPlace(error, AT_MOST_ONCE) } + if (value == null) { + warnings.accumulate(nonEmptyListOf(error())) + } +} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt b/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt index afeef42..3e711ec 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt @@ -3,11 +3,14 @@ package com.avsystem.justworks.core import arrow.core.Nel +import arrow.core.leftIor import arrow.core.nonEmptyListOf import arrow.core.raise.ExperimentalRaiseAccumulateApi import arrow.core.raise.IorRaise -import arrow.core.raise.RaiseAccumulate +import arrow.core.raise.context.bind import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind.AT_MOST_ONCE +import kotlin.contracts.contract object Issue { data class Error(val message: String) @@ -17,9 +20,3 @@ object Issue { } typealias Warnings = IorRaise> - -context(warnings: Warnings) -fun warn(message: String): Nothing? { - warnings.accumulate(nonEmptyListOf(Issue.Warning(message))) - return null -} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt index 795396b..09f4119 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -10,6 +10,7 @@ import arrow.core.raise.context.ensure import arrow.core.raise.context.ensureNotNull import arrow.core.raise.iorNel import arrow.core.raise.nullable +import arrow.core.toNonEmptyListOrNull import com.avsystem.justworks.core.Issue import com.avsystem.justworks.core.Warnings import com.avsystem.justworks.core.model.ApiSpec @@ -26,7 +27,6 @@ import com.avsystem.justworks.core.model.RequestBody import com.avsystem.justworks.core.model.Response import com.avsystem.justworks.core.model.SchemaModel import com.avsystem.justworks.core.model.TypeRef -import com.avsystem.justworks.core.warn import io.swagger.parser.OpenAPIParser import io.swagger.v3.oas.models.OpenAPI import io.swagger.v3.oas.models.PathItem @@ -83,10 +83,14 @@ object SpecParser { } val result = iorNel { - either { + either { val swaggerResult = OpenAPIParser().readLocation(specFile.absolutePath, null, parseOptions) - swaggerResult?.messages?.forEach { warn(it) } + swaggerResult + ?.messages + ?.map(Issue::Warning) + ?.toNonEmptyListOrNull() + ?.let(::accumulate) val openApi = swaggerResult?.openAPI diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt index 271ca93..6b31bb1 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt @@ -1,7 +1,9 @@ package com.avsystem.justworks.core.parser +import com.avsystem.justworks.core.Issue import com.avsystem.justworks.core.Warnings -import com.avsystem.justworks.core.warn +import com.avsystem.justworks.core.ensureNotNullOrAccumulate +import com.avsystem.justworks.core.ensureOrAccumulate import io.swagger.v3.oas.models.OpenAPI object SpecValidator { @@ -15,25 +17,25 @@ object SpecValidator { */ context(_: Warnings) fun validate(openApi: OpenAPI) { - if (openApi.info == null) { - warn("Spec is missing required 'info' section") + ensureNotNullOrAccumulate(openApi.info) { + Issue.Warning("Spec is missing required 'info' section") } - if (openApi.paths.isNullOrEmpty()) { - warn("Spec has no paths defined") + ensureOrAccumulate(!openApi.paths.isNullOrEmpty()) { + Issue.Warning("Spec has no paths defined") } openApi.paths?.values?.forEach { pathItem -> pathItem.readOperationsMap()?.values?.forEach { operation -> - if (!operation.callbacks.isNullOrEmpty()) { - warn("Callbacks are not supported in v1 and will be ignored") + ensureOrAccumulate(operation.callbacks.isNullOrEmpty()) { + Issue.Warning("Callbacks are not supported in v1 and will be ignored") } } } openApi.components?.links?.let { links -> - if (links.isNotEmpty()) { - warn("Links are not supported in v1 and will be ignored") + ensureOrAccumulate(links.isNotEmpty()) { + Issue.Warning("Links are not supported in v1 and will be ignored") } } } From e62803e93b2c335cd638d70d34d961591f1c4305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 25 Mar 2026 13:32:39 +0100 Subject: [PATCH 5/6] fix(core): correct validation logic for links in SpecValidator - Change `ensureOrAccumulate` condition to check for empty links instead of non-empty. --- core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt | 5 ----- .../com/avsystem/justworks/core/parser/SpecValidator.kt | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt b/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt index 3e711ec..b8b8971 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt @@ -3,14 +3,9 @@ package com.avsystem.justworks.core import arrow.core.Nel -import arrow.core.leftIor -import arrow.core.nonEmptyListOf import arrow.core.raise.ExperimentalRaiseAccumulateApi import arrow.core.raise.IorRaise -import arrow.core.raise.context.bind import kotlin.contracts.ExperimentalContracts -import kotlin.contracts.InvocationKind.AT_MOST_ONCE -import kotlin.contracts.contract object Issue { data class Error(val message: String) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt index 6b31bb1..ac24363 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt @@ -34,7 +34,7 @@ object SpecValidator { } openApi.components?.links?.let { links -> - ensureOrAccumulate(links.isNotEmpty()) { + ensureOrAccumulate(links.isEmpty()) { Issue.Warning("Links are not supported in v1 and will be ignored") } } From f903545e6b73bf0c00d79f71f0ee411c71edd669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 26 Mar 2026 11:08:22 +0100 Subject: [PATCH 6/6] refactor(test): rename parseSpecErrors to parseSpecIssues and remove unused imports --- core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt | 2 -- .../com/avsystem/justworks/core/parser/SpecParserTest.kt | 5 ++--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt b/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt index b8b8971..098efed 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/Issue.kt @@ -1,5 +1,3 @@ -@file:OptIn(ExperimentalContracts::class, ExperimentalRaiseAccumulateApi::class) - package com.avsystem.justworks.core import arrow.core.Nel diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt index 7e5c01d..cae9d75 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt @@ -1,6 +1,5 @@ package com.avsystem.justworks.core.parser -import com.avsystem.justworks.core.Issue import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.EnumBackingType import com.avsystem.justworks.core.model.HttpMethod @@ -28,7 +27,7 @@ class SpecParserTest : SpecParserTestBase() { } } - private fun parseSpecErrors(file: File): List { + private fun parseSpecIssues(file: File): List { val result = SpecParser.parse(file) check(result is ParseResult.Failure) { "Expected failure" } return result.warnings.map { it.message } + result.error.message @@ -293,7 +292,7 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `mixed anyOf and oneOf raises error`() { - val errors = parseSpecErrors(loadResource("mixed-combinator-spec.yaml")) + val errors = parseSpecIssues(loadResource("mixed-combinator-spec.yaml")) val errorMessages = errors.joinToString("\n") assertTrue(