Skip to content

Commit d7dd6d0

Browse files
rvlasveldclaude
andauthored
[kotlin-spring] fix multi-level allOf inheritance (fixes #18206) (#23954)
A schema that both inherits via allOf and is itself inherited from was rendered as a `data class`, which Kotlin cannot subclass, so the generated code did not compile. Such a mid-level model is now rendered as an `open class`: its own properties are marked `open`, and its children emit a parent constructor call. Because `open class` gives up the compiler-generated data class members, the template generates equals/hashCode/toString/copy from allVars. Destructuring (componentN) is not generated and is a known limitation of this rendering. Adds the allOf-multilevel sample (Animal <- Dog <- BigDog) to the kotlin-server sample workflows so the generated code is compiled in CI. Claude-Session: https://claude.ai/code/session_01VBn7VWUxZL6mTUbg21xNjm Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 10e6c21 commit d7dd6d0

28 files changed

Lines changed: 1214 additions & 4 deletions

File tree

‎.github/workflows/samples-kotlin-server-jdk17.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ jobs:
4848
matrix:
4949
sample:
5050
# server
51+
- samples/server/others/kotlin-springboot/allOf-multilevel
5152
- samples/server/others/kotlin-springboot/oneOf-discriminator
5253
- samples/server/others/kotlin-springboot/oneOf-discriminator-const
5354
- samples/server/others/kotlin-springboot/oneOf-enum-discriminator

‎.github/workflows/samples-kotlin-server-jdk21.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ jobs:
2525
fail-fast: false
2626
matrix:
2727
sample:
28+
- samples/server/others/kotlin-springboot/allOf-multilevel
2829
- samples/server/others/kotlin-springboot/oneOf-discriminator
2930
- samples/server/others/kotlin-springboot/oneOf-discriminator-const
3031
- samples/server/others/kotlin-springboot/oneOf-enum-discriminator
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
generatorName: kotlin-spring
2+
outputDir: samples/server/others/kotlin-springboot/allOf-multilevel
3+
library: spring-boot
4+
inputSpec: modules/openapi-generator/src/test/resources/3_1/allof-multilevel-inheritance.yaml
5+
templateDir: modules/openapi-generator/src/main/resources/kotlin-spring
6+
additionalProperties:
7+
documentationProvider: none
8+
annotationLibrary: none
9+
useSwaggerUI: "false"
10+
interfaceOnly: "true"
11+
useSpringBoot3: "true"

‎modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1559,6 +1559,67 @@ public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs)
15591559
}
15601560
}
15611561

1562+
// Multi-level allOf inheritance: detect "mid-level" models — models that (a) have at least
1563+
// one child via allOf and (b) are themselves a child (have a parent) but are NOT a
1564+
// discriminator root. A data class cannot be subclassed, so these must become `open class`
1565+
// for their own subclasses to compile.
1566+
// Example: Animal (interface) <- Dog (open class, has child BigDog) <- BigDog (data class)
1567+
for (CodegenModel cm : allModelsMap.values()) {
1568+
boolean isMidLevel = cm.hasChildren
1569+
&& cm.discriminator == null
1570+
&& cm.parent != null
1571+
&& !Boolean.TRUE.equals(cm.vendorExtensions.get(CodegenConstants.X_IS_ONE_OF_INTERFACE));
1572+
if (isMidLevel) {
1573+
// Mark for `open class` rendering in the template
1574+
cm.vendorExtensions.put("x-is-open-class", true);
1575+
// Mark every *own* (non-inherited) property as `open` so subclasses can override it.
1576+
// Inherited properties (override) are implicitly open in an open class.
1577+
Stream.of(cm.vars, cm.requiredVars, cm.optionalVars, cm.allVars)
1578+
.flatMap(List::stream)
1579+
.filter(p -> !p.isInherited)
1580+
.forEach(p -> p.vendorExtensions.put("x-is-open-property", true));
1581+
// An open class gets none of the compiler-generated data class members, so the
1582+
// template writes equals/hashCode/toString/copy itself. They must list properties
1583+
// in the same order the constructor declares them (required before optional), so
1584+
// that positional arguments to copy() bind to the properties the caller expects.
1585+
List<CodegenProperty> constructorOrder = new ArrayList<>(cm.getRequiredVars());
1586+
constructorOrder.addAll(cm.getOptionalVars());
1587+
// toString() prints the property name as a label. Kotlin back-ticks names that are
1588+
// not valid identifiers (`2ndField`), but the back-ticks are only source syntax and
1589+
// a data class does not print them, so strip them for the label.
1590+
constructorOrder.forEach(p -> p.vendorExtensions.put(
1591+
"x-open-class-label", p.getName().replace("`", "")));
1592+
cm.vendorExtensions.put("x-open-class-vars", constructorOrder);
1593+
}
1594+
}
1595+
1596+
// For children of open (non-interface) parent classes, build a parent constructor call
1597+
// so the template can emit `: Dog(className = className, ...)`. This is a second pass
1598+
// because it reads x-is-open-class on the *parent*, which the loop above must have
1599+
// finished setting for every model first.
1600+
// x-parent-is-class tells the template the parent requires `()` (even when arg list is empty);
1601+
// x-parent-ctor-args holds the argument string. Kept separate so a parent with no properties
1602+
// still generates `: ParentClass()` rather than the compile-error `: ParentClass` (no parens).
1603+
for (CodegenModel cm : allModelsMap.values()) {
1604+
if (cm.parent != null) {
1605+
CodegenModel parentModel = allModelsMap.get(cm.parent);
1606+
if (parentModel != null
1607+
&& Boolean.TRUE.equals(parentModel.vendorExtensions.get("x-is-open-class"))) {
1608+
cm.vendorExtensions.put("x-parent-is-class", true);
1609+
List<String> ctorArgs = new ArrayList<>();
1610+
for (CodegenProperty prop : parentModel.getRequiredVars()) {
1611+
ctorArgs.add(prop.getName() + " = " + prop.getName());
1612+
}
1613+
for (CodegenProperty prop : parentModel.getOptionalVars()) {
1614+
ctorArgs.add(prop.getName() + " = " + prop.getName());
1615+
}
1616+
if (!ctorArgs.isEmpty()) {
1617+
cm.vendorExtensions.put("x-parent-ctor-args", String.join(", ", ctorArgs));
1618+
}
1619+
}
1620+
}
1621+
}
1622+
15621623
if (substituteGenericPagedModel && !pagedModelRegistry.isEmpty()) {
15631624
if (getAnnotationLibrary() == AnnotationLibrary.NONE) {
15641625
// No @ApiResponse annotations are generated when annotationLibrary=none,

‎modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@
1414
{{#vendorExtensions.x-class-extra-annotation}}
1515
{{{.}}}
1616
{{/vendorExtensions.x-class-extra-annotation}}
17-
{{#discriminator}}interface {{classname}}{{/discriminator}}{{^discriminator}}{{#hasVars}}data {{/hasVars}}class {{classname}}(
17+
{{#discriminator}}interface {{classname}}{{/discriminator}}{{^discriminator}}{{#vendorExtensions.x-is-open-class}}open {{/vendorExtensions.x-is-open-class}}{{^vendorExtensions.x-is-open-class}}{{#hasVars}}data {{/hasVars}}{{/vendorExtensions.x-is-open-class}}class {{classname}}(
1818
{{#requiredVars}}
1919
{{>dataClassReqVar}}{{^-last}},
2020
{{/-last}}{{/requiredVars}}{{#hasRequired}}{{#hasOptional}},
2121
{{/hasOptional}}{{/hasRequired}}{{#optionalVars}}{{>dataClassOptVar}}{{^-last}},
2222
{{/-last}}{{/optionalVars}}
2323
){{/discriminator}}{{! no newline
24-
}}{{#parent}} : {{{.}}}{{#isMap}}(){{/isMap}}{{! no newline
24+
}}{{#parent}} : {{{.}}}{{#vendorExtensions.x-parent-is-class}}({{{vendorExtensions.x-parent-ctor-args}}}){{/vendorExtensions.x-parent-is-class}}{{^vendorExtensions.x-parent-is-class}}{{#isMap}}(){{/isMap}}{{/vendorExtensions.x-parent-is-class}}{{! no newline
2525
}}{{#vendorExtensions.x-kotlin-implements}}, {{{.}}}{{/vendorExtensions.x-kotlin-implements}}{{! <- serializableModel is also handled via x-kotlin-implements
2626
}}{{#vendorExtensions.x-implements-sealed-interfaces}}{{#.}}, {{{.}}}{{/.}}{{/vendorExtensions.x-implements-sealed-interfaces}}{{! <- add sealed interface implementations
2727
}}{{/parent}}{{! no newline
@@ -43,6 +43,33 @@
4343
{{>interfaceOptVar}}{{! prevent indent}}
4444
{{/optionalVars}}
4545
{{/discriminator}}
46+
{{#vendorExtensions.x-is-open-class}}
47+
override fun equals(other: Any?): Boolean {
48+
if (this === other) return true
49+
if (other?.javaClass != javaClass) return false
50+
other as {{classname}}
51+
return {{#vendorExtensions.x-open-class-vars}}{{{name}}} == other.{{{name}}}{{^-last}}
52+
&& {{/-last}}{{/vendorExtensions.x-open-class-vars}}{{^vendorExtensions.x-open-class-vars}}true{{/vendorExtensions.x-open-class-vars}}
53+
}
54+
55+
override fun hashCode(): Int {
56+
return Objects.hash({{#vendorExtensions.x-open-class-vars}}{{{name}}}{{^-last}}, {{/-last}}{{/vendorExtensions.x-open-class-vars}})
57+
}
58+
59+
override fun toString(): String {
60+
return "{{classname}}(" +
61+
{{#vendorExtensions.x-open-class-vars}}
62+
"{{{vendorExtensions.x-open-class-label}}}=" + {{{name}}}{{^-last}} + ", " +{{/-last}}{{#-last}} +{{/-last}}
63+
{{/vendorExtensions.x-open-class-vars}}
64+
")"
65+
}
66+
67+
fun copy(
68+
{{#vendorExtensions.x-open-class-vars}}
69+
{{{name}}}: {{{dataType}}}{{^required}}?{{/required}} = this.{{{name}}}{{^-last}},{{/-last}}
70+
{{/vendorExtensions.x-open-class-vars}}
71+
): {{classname}} = {{classname}}({{#vendorExtensions.x-open-class-vars}}{{{name}}} = {{{name}}}{{^-last}}, {{/-last}}{{/vendorExtensions.x-open-class-vars}})
72+
{{/vendorExtensions.x-is-open-class}}
4673
{{#hasEnums}}{{#vars}}{{#isEnum}}
4774
/**
4875
* {{{description}}}

‎modules/openapi-generator/src/main/resources/kotlin-spring/dataClassOptVar.mustache‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@
77
@field:JsonSetter(nulls = Nulls.SKIP){{/vendorExtensions.x-has-json-setter-nulls-skip}}{{#vendorExtensions.x-has-json-setter-nulls-fail}}
88
@field:JsonSetter(nulls = Nulls.FAIL){{/vendorExtensions.x-has-json-setter-nulls-fail}}
99
@param:JsonProperty("{{{baseName}}}")
10-
@get:JsonProperty("{{{baseName}}}"){{#isInherited}} override{{/isInherited}} {{>modelMutable}} {{{name}}}: {{#vendorExtensions.x-is-jackson-optional-nullable}}JsonNullable<{{#isEnum}}{{#isArray}}{{baseType}}<{{/isArray}}{{classname}}.{{{nameInPascalCase}}}{{#isArray}}>{{/isArray}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}>{{/vendorExtensions.x-is-jackson-optional-nullable}}{{^vendorExtensions.x-is-jackson-optional-nullable}}{{#isEnum}}{{#isArray}}{{baseType}}<{{/isArray}}{{classname}}.{{{nameInPascalCase}}}{{#isArray}}>{{/isArray}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}?{{/vendorExtensions.x-is-jackson-optional-nullable}} = {{#vendorExtensions.x-is-jackson-optional-nullable}}JsonNullable.undefined(){{/vendorExtensions.x-is-jackson-optional-nullable}}{{^vendorExtensions.x-is-jackson-optional-nullable}}{{^defaultValue}}null{{/defaultValue}}{{#defaultValue}}{{^isNumber}}{{{defaultValue}}}{{/isNumber}}{{#isNumber}}{{{dataType}}}("{{{defaultValue}}}"){{/isNumber}}{{/defaultValue}}{{/vendorExtensions.x-is-jackson-optional-nullable}}
10+
@get:JsonProperty("{{{baseName}}}"){{#isInherited}} override{{/isInherited}}{{^isInherited}}{{#vendorExtensions.x-is-open-property}} open{{/vendorExtensions.x-is-open-property}}{{/isInherited}} {{>modelMutable}} {{{name}}}: {{#vendorExtensions.x-is-jackson-optional-nullable}}JsonNullable<{{#isEnum}}{{#isArray}}{{baseType}}<{{/isArray}}{{classname}}.{{{nameInPascalCase}}}{{#isArray}}>{{/isArray}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}>{{/vendorExtensions.x-is-jackson-optional-nullable}}{{^vendorExtensions.x-is-jackson-optional-nullable}}{{#isEnum}}{{#isArray}}{{baseType}}<{{/isArray}}{{classname}}.{{{nameInPascalCase}}}{{#isArray}}>{{/isArray}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}?{{/vendorExtensions.x-is-jackson-optional-nullable}} = {{#vendorExtensions.x-is-jackson-optional-nullable}}JsonNullable.undefined(){{/vendorExtensions.x-is-jackson-optional-nullable}}{{^vendorExtensions.x-is-jackson-optional-nullable}}{{^defaultValue}}null{{/defaultValue}}{{#defaultValue}}{{^isNumber}}{{{defaultValue}}}{{/isNumber}}{{#isNumber}}{{{dataType}}}("{{{defaultValue}}}"){{/isNumber}}{{/defaultValue}}{{/vendorExtensions.x-is-jackson-optional-nullable}}

‎modules/openapi-generator/src/main/resources/kotlin-spring/dataClassReqVar.mustache‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
@field:JsonSetter(nulls = Nulls.SKIP){{/vendorExtensions.x-has-json-setter-nulls-skip}}{{#vendorExtensions.x-has-json-setter-nulls-fail}}
77
@field:JsonSetter(nulls = Nulls.FAIL){{/vendorExtensions.x-has-json-setter-nulls-fail}}
88
@param:JsonProperty("{{{baseName}}}", required = true)
9-
@get:JsonProperty("{{{baseName}}}", required = true){{#isInherited}} override{{/isInherited}} {{>modelMutable}} {{{name}}}: {{#isEnum}}{{#isArray}}{{baseType}}<{{/isArray}}{{classname}}.{{{nameInPascalCase}}}{{#isArray}}>{{/isArray}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}{{#isNullable}}?{{/isNullable}}{{#defaultValue}} = {{^isNumber}}{{{defaultValue}}}{{/isNumber}}{{#isNumber}}{{{dataType}}}("{{{defaultValue}}}"){{/isNumber}}{{/defaultValue}}
9+
@get:JsonProperty("{{{baseName}}}", required = true){{#isInherited}} override{{/isInherited}}{{^isInherited}}{{#vendorExtensions.x-is-open-property}} open{{/vendorExtensions.x-is-open-property}}{{/isInherited}} {{>modelMutable}} {{{name}}}: {{#isEnum}}{{#isArray}}{{baseType}}<{{/isArray}}{{classname}}.{{{nameInPascalCase}}}{{#isArray}}>{{/isArray}}{{/isEnum}}{{^isEnum}}{{{dataType}}}{{/isEnum}}{{#isNullable}}?{{/isNullable}}{{#defaultValue}} = {{^isNumber}}{{{defaultValue}}}{{/isNumber}}{{#isNumber}}{{{dataType}}}("{{{defaultValue}}}"){{/isNumber}}{{/defaultValue}}

‎modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7896,4 +7896,108 @@ public void extraImportsDedupAgainstGeneratedImports() throws IOException {
78967896
Assert.assertEquals(countOccurrences(widgets, "import org.openapitools.model.Widget"), 1L,
78977897
"Extra import duplicating a generated type import must be emitted only once");
78987898
}
7899+
7900+
// ==================== multi-level allOf inheritance tests (fixes #18206) ====================
7901+
7902+
@Test(description = "multi-level allOf: mid-level model becomes open class, leaf stays data class with parent ctor call")
7903+
public void testMultiLevelAllOfInheritance() throws IOException {
7904+
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
7905+
output.deleteOnExit();
7906+
7907+
new DefaultGenerator().opts(new ClientOptInput()
7908+
.openAPI(new OpenAPIParser().readLocation("src/test/resources/3_1/allof-multilevel-inheritance.yaml", null, new ParseOptions()).getOpenAPI())
7909+
.config(new KotlinSpringServerCodegen() {{
7910+
setOutputDir(output.getAbsolutePath());
7911+
}}))
7912+
.generate();
7913+
7914+
String outputPath = output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/model";
7915+
7916+
// Animal: discriminator parent interface, with Jackson annotations listing ALL descendants
7917+
assertFileContains(Paths.get(outputPath + "/Animal.kt"),
7918+
"interface Animal",
7919+
"@JsonTypeInfo", "property = \"className\"", "visible = true",
7920+
"BigDog::class",
7921+
"Dog::class",
7922+
"Cat::class"
7923+
);
7924+
7925+
// Dog: open class (not data class) so BigDog can extend it; no ctor call to Animal (interface parent)
7926+
assertFileContains(Paths.get(outputPath + "/Dog.kt"),
7927+
"open class Dog(",
7928+
") : Animal {",
7929+
"override val className: kotlin.String",
7930+
"open val breed: kotlin.String?"
7931+
);
7932+
assertFileNotContains(Paths.get(outputPath + "/Dog.kt"), "data class Dog");
7933+
assertFileNotContains(Paths.get(outputPath + "/Dog.kt"), ": Animal(");
7934+
// open class generates equals/hashCode/toString/copy since data class would normally provide these
7935+
assertFileContains(Paths.get(outputPath + "/Dog.kt"),
7936+
"override fun equals(other: Any?): Boolean",
7937+
"if (other?.javaClass != javaClass) return false",
7938+
"override fun hashCode(): Int",
7939+
"Objects.hash(",
7940+
"override fun toString(): String",
7941+
"fun copy(",
7942+
"): Dog = Dog("
7943+
);
7944+
7945+
// Cat: leaf (no children) — stays a data class, unaffected
7946+
assertFileContains(Paths.get(outputPath + "/Cat.kt"), "data class Cat");
7947+
7948+
// BigDog: data class extending open class Dog with constructor call passing all parent ctor args
7949+
assertFileContains(Paths.get(outputPath + "/BigDog.kt"),
7950+
"data class BigDog(",
7951+
"override val className: kotlin.String",
7952+
"override val breed",
7953+
"override val color"
7954+
);
7955+
// Must call Dog's constructor (not bare `: Dog`)
7956+
assertFileContains(Paths.get(outputPath + "/BigDog.kt"), ") : Dog(");
7957+
assertFileNotContains(Paths.get(outputPath + "/BigDog.kt"), ") : Dog {");
7958+
7959+
// copy() must declare parameters in constructor order (required before optional), so that
7960+
// positional arguments bind to the same properties the constructor takes them for.
7961+
assertFileContains(Paths.get(outputPath + "/Dog.kt"),
7962+
" fun copy(\n"
7963+
+ " className: kotlin.String = this.className,\n"
7964+
+ " breed: kotlin.String? = this.breed,\n"
7965+
+ " color: kotlin.String? = this.color\n"
7966+
+ " ): Dog = Dog(className = className, breed = breed, color = color)"
7967+
);
7968+
}
7969+
7970+
@Test(description = "multi-level allOf: generated open class members handle back-ticked property names")
7971+
public void testMultiLevelAllOfWithEscapedPropertyNames() throws IOException {
7972+
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
7973+
output.deleteOnExit();
7974+
7975+
new DefaultGenerator().opts(new ClientOptInput()
7976+
.openAPI(new OpenAPIParser().readLocation("src/test/resources/3_1/allof-multilevel-escaped-names.yaml", null, new ParseOptions()).getOpenAPI())
7977+
.config(new KotlinSpringServerCodegen() {{
7978+
setOutputDir(output.getAbsolutePath());
7979+
}}))
7980+
.generate();
7981+
7982+
Path mid = Paths.get(output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/model/Mid.kt");
7983+
7984+
// "2nd_field" and "object" are not Kotlin identifiers, so the property is back-ticked.
7985+
// The generated members must emit the back-ticks literally, not HTML-escape them.
7986+
// (Scoped to the generated members: the KDoc @param block escapes them for every model,
7987+
// open class or not, which is a separate pre-existing issue.)
7988+
assertFileNotContains(mid,
7989+
"&#x60;2ndField&#x60; == other",
7990+
"Objects.hash(kind, &#x60;"
7991+
);
7992+
assertFileContains(mid,
7993+
"open val `2ndField`",
7994+
"`2ndField` == other.`2ndField`",
7995+
"Objects.hash(kind, `2ndField`, `object`)",
7996+
"`2ndField`: kotlin.String? = this.`2ndField`"
7997+
);
7998+
// toString builds by concatenation: "$`2ndField`" is not valid Kotlin interpolation,
7999+
// and the back-ticks are source syntax that a data class would not print.
8000+
assertFileContains(mid, "\"2ndField=\" + `2ndField`");
8001+
assertFileNotContains(mid, "$`2ndField`");
8002+
}
78998003
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
openapi: 3.1.0
2+
info:
3+
title: Multi-level allOf with property names that are not Kotlin identifiers
4+
version: "1.0"
5+
paths: {}
6+
components:
7+
schemas:
8+
Base:
9+
type: object
10+
discriminator:
11+
propertyName: kind
12+
required:
13+
- kind
14+
properties:
15+
kind:
16+
type: string
17+
# Mid has a child, so it renders as an `open class` whose equals/hashCode/
18+
# toString/copy are generated rather than supplied by the compiler. Its
19+
# properties need back-ticks in Kotlin, which those members must handle.
20+
Mid:
21+
allOf:
22+
- $ref: '#/components/schemas/Base'
23+
- type: object
24+
properties:
25+
2nd_field:
26+
type: string
27+
object:
28+
type: string
29+
Leaf:
30+
allOf:
31+
- $ref: '#/components/schemas/Mid'
32+
- type: object
33+
properties:
34+
extra:
35+
type: string

0 commit comments

Comments
 (0)