-
Notifications
You must be signed in to change notification settings - Fork 0
feat: nested sealed hierarchies for oneOf/anyOf subtypes #28
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -31,21 +31,40 @@ import kotlin.time.Instant | |||||||||
| * and one file per [EnumModel] (enum class), all annotated with kotlinx.serialization annotations. | ||||||||||
| */ | ||||||||||
| class ModelGenerator(private val modelPackage: String) { | ||||||||||
| fun generate(spec: ApiSpec): List<FileSpec> = context( | ||||||||||
| buildHierarchyInfo(spec.schemas), | ||||||||||
| InlineSchemaDeduplicator(spec.schemas.map { it.name }.toSet()), | ||||||||||
| ) { | ||||||||||
| val schemaFiles = spec.schemas.flatMap { generateSchemaFiles(it) } | ||||||||||
| fun generate(spec: ApiSpec): List<FileSpec> { | ||||||||||
| val hierarchyInfo = buildHierarchyInfo(spec.schemas) | ||||||||||
| return context( | ||||||||||
| hierarchyInfo, | ||||||||||
| InlineSchemaDeduplicator(spec.schemas.map { it.name }.toSet()), | ||||||||||
| ) { | ||||||||||
| val variantNames = hierarchyInfo.sealedHierarchies | ||||||||||
| .filterKeys { it !in hierarchyInfo.anyOfWithoutDiscriminator } | ||||||||||
| .values | ||||||||||
| .flatten() | ||||||||||
| .toSet() | ||||||||||
|
|
||||||||||
| val schemaFiles = spec.schemas | ||||||||||
| .filter { it.name !in variantNames } | ||||||||||
| .flatMap { generateSchemaFiles(it) } | ||||||||||
|
|
||||||||||
| val inlineSchemaFiles = collectAllInlineSchemas(spec).map { | ||||||||||
| if (it.isNested) generateNestedInlineClass(it) else generateDataClass(it) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| val inlineSchemaFiles = collectAllInlineSchemas(spec).map { | ||||||||||
| if (it.isNested) generateNestedInlineClass(it) else generateDataClass(it) | ||||||||||
| } | ||||||||||
| val enumFiles = spec.enums.map(::generateEnumClass) | ||||||||||
|
|
||||||||||
| val enumFiles = spec.enums.map(::generateEnumClass) | ||||||||||
| val serializersModuleFile = SerializersModuleGenerator(modelPackage).generate() | ||||||||||
|
|
||||||||||
| val serializersModuleFile = SerializersModuleGenerator(modelPackage).generate() | ||||||||||
| schemaFiles + inlineSchemaFiles + enumFiles + listOfNotNull(serializersModuleFile) | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| schemaFiles + inlineSchemaFiles + enumFiles + listOfNotNull(serializersModuleFile) | ||||||||||
| companion object { | ||||||||||
| fun buildClassNameLookup(spec: ApiSpec, modelPackage: String): Map<String, ClassName> { | ||||||||||
| val generator = ModelGenerator(modelPackage) | ||||||||||
| val hierarchy = generator.buildHierarchyInfo(spec.schemas) | ||||||||||
| return generator.buildClassNameLookup(hierarchy) | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| data class HierarchyInfo( | ||||||||||
|
|
@@ -88,6 +107,19 @@ class ModelGenerator(private val modelPackage: String) { | |||||||||
| return HierarchyInfo(sealedHierarchies, variantParents, anyOfWithoutDiscriminator, schemas) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| private fun buildClassNameLookup(hierarchy: HierarchyInfo): Map<String, ClassName> { | ||||||||||
| val lookup = mutableMapOf<String, ClassName>() | ||||||||||
| for ((parentName, variants) in hierarchy.sealedHierarchies) { | ||||||||||
| if (parentName in hierarchy.anyOfWithoutDiscriminator) continue | ||||||||||
| val parentClass = ClassName(modelPackage, parentName) | ||||||||||
| lookup[parentName] = parentClass | ||||||||||
| for (variantName in variants) { | ||||||||||
| lookup[variantName] = parentClass.nestedClass(variantName) | ||||||||||
| } | ||||||||||
| } | ||||||||||
| return lookup | ||||||||||
| } | ||||||||||
|
|
||||||||||
| context(deduplicator: InlineSchemaDeduplicator) | ||||||||||
| private fun collectAllInlineSchemas(spec: ApiSpec): List<SchemaModel> { | ||||||||||
| val endpointRefs = spec.endpoints.flatMap { endpoint -> | ||||||||||
|
|
@@ -122,7 +154,7 @@ class ModelGenerator(private val modelPackage: String) { | |||||||||
| if (schema.name in hierarchy.anyOfWithoutDiscriminator) { | ||||||||||
| listOf(generateSealedInterface(schema), generatePolymorphicSerializer(schema)) | ||||||||||
| } else { | ||||||||||
| listOf(generateSealedInterface(schema)) | ||||||||||
| listOf(generateSealedHierarchy(schema)) | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
|
|
@@ -185,6 +217,107 @@ class ModelGenerator(private val modelPackage: String) { | |||||||||
| return fileBuilder.build() | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Generates a sealed class with nested data class subtypes for oneOf and anyOf-with-discriminator schemas. | ||||||||||
| * Produces a single FileSpec containing the entire hierarchy. | ||||||||||
| */ | ||||||||||
| context(hierarchy: HierarchyInfo) | ||||||||||
| private fun generateSealedHierarchy(schema: SchemaModel): FileSpec { | ||||||||||
| val className = ClassName(modelPackage, schema.name) | ||||||||||
| val variants = hierarchy.sealedHierarchies[schema.name].orEmpty() | ||||||||||
| val schemasById = hierarchy.schemas.associateBy { it.name } | ||||||||||
|
|
||||||||||
| val parentBuilder = TypeSpec.classBuilder(className).addModifiers(KModifier.SEALED) | ||||||||||
| parentBuilder.addAnnotation(SERIALIZABLE) | ||||||||||
|
|
||||||||||
| if (schema.discriminator != null) { | ||||||||||
| parentBuilder.addAnnotation( | ||||||||||
| AnnotationSpec | ||||||||||
| .builder(JSON_CLASS_DISCRIMINATOR) | ||||||||||
| .addMember("%S", schema.discriminator.propertyName) | ||||||||||
| .build(), | ||||||||||
| ) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| if (schema.description != null) { | ||||||||||
| parentBuilder.addKdoc("%L", schema.description) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| for (variantName in variants) { | ||||||||||
| val variantSchema = schemasById[variantName] ?: continue | ||||||||||
|
||||||||||
| val variantSchema = schemasById[variantName] ?: continue | |
| val variantSchema = requireNotNull(schemasById[variantName]) { | |
| "Missing schema for sealed hierarchy variant '$variantName' in '${schema.name}'" | |
| } |
Copilot
AI
Mar 24, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nested variant properties currently resolve referenced schemas via TypeMapping.toTypeName(prop.type, modelPackage) without passing the new classNameLookup. If a nested variant (or its inline schemas) references another variant schema by name, this will emit a flat ClassName(modelPackage, "Variant") that no longer exists as a top-level type after nesting. Consider computing the lookup once from HierarchyInfo and threading it into these toTypeName calls.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
generate()now filters out variant schemas fromschemaFiles(soCircle.kt/Square.ktetc are no longer generated for discriminated hierarchies). After this change, any remainingTypeRef.Reference("Circle")usage outside the parent hierarchy (e.g., in other schema properties or inline schemas) must resolve to the nested type (Shape.Circle) or the generated model code will reference a missing top-level class. Consider building aclassNameLookupfromhierarchyInfoand using it consistently insideModelGenerator(not only inClientGenerator).