Skip to content

[kotlin-spring] fix multi-level allOf inheritance (fixes #18206) - #23954

Merged
wing328 merged 1 commit into
OpenAPITools:masterfrom
rvlasveld:fix/kotlin-spring-multilevel-allof-inheritance
Sep 27, 2026
Merged

wing328 merged 1 commit into
OpenAPITools:masterfrom
rvlasveld:fix/kotlin-spring-multilevel-allof-inheritance

Conversation

@rvlasveld

@rvlasveld rvlasveld commented Jun 5, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #18206.

The bug

A schema that both inherits via allOf and is itself inherited from was rendered as a data class. Kotlin cannot subclass a data class, so the generated code did not compile at all:

Animal  <-  Dog  <-  BigDog
data class Dog(...) : Animal          // generated
data class BigDog(...) : Dog          // error: this type is final, so it cannot be inherited from

The fix

Such a "mid-level" model — has a parent, has children, is not itself a discriminator root — is now rendered as an open class, its own properties marked open, and its children emit a parent constructor call:

open class Dog(
    @get:JsonProperty("className", required = true) override val className: kotlin.String,
    @get:JsonProperty("breed") open val breed: kotlin.String? = null,
    @get:JsonProperty("color") override val color: kotlin.String? = "red"
) : Animal { ... }

data class BigDog(
    ...
) : Dog(className = className, breed = breed, color = color) { ... }

open class loses the data class members, so the template writes them

An open class gets no compiler-generated equals/hashCode/toString/copy, so dataClass.mustache generates them. Details worth a reviewer's eye:

  • equals uses an exact-class check (other?.javaClass != javaClass) rather than is Dog. This keeps symmetry with the leaf data class: Dog(...) != BigDog(...) in both directions.
  • All four list properties in constructor order (required before optional), so positional arguments to copy() bind to the same properties the constructor takes them for, matching how sibling data classes behave.
  • componentN() is not generated — destructuring is unavailable on mid-level models. Known limitation of this rendering.

Known trade-off: copy() slices subclasses

Dog.copy() returns a Dog. Called through a Dog-typed reference that actually holds a BigDog, it returns a plain Dog and drops BigDog's own fields:

val d: Dog = someBigDog
d.copy(color = "blue")   // -> Dog, not BigDog

BigDog.copy() (the data class one, different arity) is unaffected, and the two never collide on the JVM. Vanilla Kotlin open classes have no copy() at all, so this is a member the generator introduces rather than one it preserves.

Maintainer decision: keep copy() for parity with the data classes around it, or drop it from open class models to avoid the slicing footgun entirely? I lean toward keeping it — an ergonomic copy() that behaves like every other generated model is worth more than the narrow parent-typed-reference case — but this is a legitimate design call and I am happy to remove it.

Why open class and not something else

Kotlin makes this a forced choice: a data class is final and cannot be a superclass, so a mid-level model must give up exactly one of three things.

Give up Approach
data-class-ness of the mid-level type open class (this PR)
the mid-level type being instantiable under its spec name interface + a generated Impl
the BigDog is Dog relationship flatten (merge ancestor properties)

Flattening is what Swift, Rust and most Scala generators do (supportsInheritance = false). It would make every model a data class again with no hand-written members, but it drops the subtype relationship — and it is precisely what the reporters on #18206 are asking to escape ("without inheritance ... we must write a lot of redundant code"). It is also a per-generator flag shared by every Kotlin generator, so it could not be scoped to this case.

Interface + DogImpl would fix every downside here: all concrete types stay data classes, so copy()/destructuring/equals come free and nothing can slice. It was rejected on source stability — Dog would stop being constructible, so merely adding an unrelated BigDog schema to a spec would break every existing Dog(...) call site. With open class, that same spec change leaves Dog(className = ..., breed = ...) compiling unchanged; only copy()/destructuring semantics shift.

Worth noting the generator already picks the interface strategy where it is free: when a mid-level model has its own discriminator it renders as interface Dog : Animal, and BigDog : Dog needs no open class at all. That path is untouched here. The rule ends up coherent — interface when the type is abstract by nature, open class when it is concrete — and this PR only addresses the concrete case.

Property names that are not Kotlin identifiers

The generated members emit property names unescaped and build toString() by concatenation rather than string interpolation. Both matter for back-ticked names: &#x60; would not compile, and $2ndField`` is not valid Kotlin interpolation (it needs ${...}). The `toString()` label also drops the back-ticks, matching what a `data class` prints. Covered by `testMultiLevelAllOfWithEscapedPropertyNames` against a dedicated spec.

Depth

Chains deeper than three levels work: each mid-level model is open, and each child emits a constructor call carrying its parent's full parameter list in order. Verified on A <- B <- C <- D (two stacked open classes) — generates and compiles.

Unrelated pre-existing issue spotted

@param lines in the generated KDoc HTML-escape back-ticked names (@param &#x60;2ndField&#x60;). This affects plain data class models too and is not introduced here, so it is left alone — happy to file it separately.

Verification

  • KotlinSpringServerCodegenTest: 270 passing, 2 new — one covering the whole shape — open class for the mid-level model, leaf stays a data class, parent constructor call is emitted, all four generated members present, and copy()'s parameter order matches the constructor; the other covering property names that need back-ticks.
  • The generated sample compiles. New allOf-multilevel sample added to the samples-kotlin-server-jdk17/jdk21 workflows, so CI builds it. Verified locally with gradlew compileKotlin → BUILD SUCCESSFUL.

For maintainers

🤖 Generated with Claude Code

https://claude.ai/code/session_01VBn7VWUxZL6mTUbg21xNjm

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 34 files

Re-trigger cubic

@rvlasveld
rvlasveld marked this pull request as draft June 5, 2026 11:11
@rvlasveld
rvlasveld marked this pull request as ready for review June 5, 2026 12:58

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 34 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="samples/server/others/kotlin-springboot/allOf-multilevel/gradle/wrapper/gradle-wrapper.properties">

<violation number="1" location="samples/server/others/kotlin-springboot/allOf-multilevel/gradle/wrapper/gradle-wrapper.properties:3">
P1: Gradle wrapper pinned to 8.1.1 is too old for JDK 21</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Gradle wrapper pinned to 8.1.1 is too old for JDK 21

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/server/others/kotlin-springboot/allOf-multilevel/gradle/wrapper/gradle-wrapper.properties, line 3:

<comment>Gradle wrapper pinned to 8.1.1 is too old for JDK 21</comment>

<file context>
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
</file context>
Suggested change
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Not fixing this one — the pin is correct here, and hand-editing it would break the build. Three reasons:

  1. This file is generated output. It comes from kotlin-spring/libraries/spring-boot/gradle-wrapper.properties.mustache, so the next ./bin/generate-samples.sh run reverts any manual edit and the samples CI job then fails on a dirty tree.

  2. 8.1.1 is deliberate, not stale. The template line is:

    {{#useSpringBoot4}}gradle-8.14.5{{/useSpringBoot4}}{{^useSpringBoot4}}gradle-8.1.1{{/useSpringBoot4}}

    This sample uses useSpringBoot3, so 8.1.1 is the correct branch. Every non-SpringBoot4 kotlin-spring sample in the repo pins the same version; only kotlin-spring-cloud-4 has 8.14.5. Hardcoding 8.14 would make this the one inconsistent sample.

  3. CI never uses the committed pin. Both samples-kotlin-server-jdk17 and jdk21 install Gradle 8.14 and run arguments: wrapper in the sample directory before building, which rewrites gradle-wrapper.properties, and only then run ./gradlew build -x test. So the jdk21 job builds with Gradle 8.14 on Java 21 and the incompatibility never occurs.

The underlying concern is real in exactly one situation: running ./gradlew directly on a JDK 21 machine without that wrapper step. I hit that locally and worked around it by temporarily bumping to 8.14 to compile the sample. But that affects every non-SpringBoot4 kotlin-spring sample equally and is not introduced by this PR — a fix belongs in the three gradle-wrapper.properties.mustache templates, with all kotlin-spring samples regenerated, as its own change.

@rvlasveld

Copy link
Copy Markdown
Contributor Author

@wing328, could you help get this reviewed?

…#18206)

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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBn7VWUxZL6mTUbg21xNjm
@rvlasveld
rvlasveld force-pushed the fix/kotlin-spring-multilevel-allof-inheritance branch from 02ec314 to 16acf5d Compare September 20, 2026 14:25

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 28 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="samples/server/others/kotlin-springboot/allOf-multilevel/README.md">

<violation number="1" location="samples/server/others/kotlin-springboot/allOf-multilevel/README.md:7">
P3: A Gradle wrapper is checked in, so this sentence incorrectly tells users they need Gradle installed. State that the Gradle wrapper is included and only the Maven wrapper is absent.</violation>

<violation number="2" location="samples/server/others/kotlin-springboot/allOf-multilevel/README.md:13">
P3: The run instructions can't work for this sample: the pom.xml declares only maven-source-plugin and kotlin-maven-plugin (no `spring-boot-maven-plugin`, unlike `samples/server/petstore/kotlin-springboot/pom.xml`), `build.gradle.kts` sets `tasks.bootJar { enabled = false }`, and the sample has no `@SpringBootApplication` main class. The produced jar has no Main-Class, so `java -jar target/openapi-spring-1.0.0.jar` (and the corresponding gradle command) fails with "no main manifest attribute". Either make the sample runnable (add the Spring Boot plugin plus a main class) or replace these instructions with build/compile-only steps.</violation>
</file>

<file name="modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache">

<violation number="1" location="modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache:69">
P1: `copy` does not mirror the constructor property type: inline enums use `Class.Enum`, required nullable fields use `T?`, and `openApiNullable` fields use `JsonNullable<T>`. These mismatches make generated mid-level models fail Kotlin compilation; render the same type branches as the property partials.</violation>
</file>

<file name="samples/server/others/kotlin-springboot/allOf-multilevel/build.gradle.kts">

<violation number="1" location="samples/server/others/kotlin-springboot/allOf-multilevel/build.gradle.kts:20">
P1: Gradle Kotlin DSL requires `plugins {}` before other script statements, so this placement makes the sample fail during build configuration. Move the block immediately after the import.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic


fun copy(
{{#vendorExtensions.x-open-class-vars}}
{{{name}}}: {{{dataType}}}{{^required}}?{{/required}} = this.{{{name}}}{{^-last}},{{/-last}}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: copy does not mirror the constructor property type: inline enums use Class.Enum, required nullable fields use T?, and openApiNullable fields use JsonNullable<T>. These mismatches make generated mid-level models fail Kotlin compilation; render the same type branches as the property partials.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache, line 69:

<comment>`copy` does not mirror the constructor property type: inline enums use `Class.Enum`, required nullable fields use `T?`, and `openApiNullable` fields use `JsonNullable<T>`. These mismatches make generated mid-level models fail Kotlin compilation; render the same type branches as the property partials.</comment>

<file context>
@@ -43,6 +43,33 @@
+
+    fun copy(
+{{#vendorExtensions.x-open-class-vars}}
+        {{{name}}}: {{{dataType}}}{{^required}}?{{/required}} = this.{{{name}}}{{^-last}},{{/-last}}
+{{/vendorExtensions.x-open-class-vars}}
+    ): {{classname}} = {{classname}}({{#vendorExtensions.x-open-class-vars}}{{{name}}} = {{{name}}}{{^-last}}, {{/-last}}{{/vendorExtensions.x-open-class-vars}})
</file context>
Suggested change
{{{name}}}: {{{dataType}}}{{^required}}?{{/required}} = this.{{{name}}}{{^-last}},{{/-last}}
{{{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}}{{#isNullable}}?{{/isNullable}}{{^required}}{{^isNullable}}?{{/isNullable}}{{/required}}{{/vendorExtensions.x-is-jackson-optional-nullable}} = this.{{{name}}}{{^-last}},{{/-last}}

enabled = false
}

plugins {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Gradle Kotlin DSL requires plugins {} before other script statements, so this placement makes the sample fail during build configuration. Move the block immediately after the import.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/server/others/kotlin-springboot/allOf-multilevel/build.gradle.kts, line 20:

<comment>Gradle Kotlin DSL requires `plugins {}` before other script statements, so this placement makes the sample fail during build configuration. Move the block immediately after the import.</comment>

<file context>
@@ -0,0 +1,49 @@
+    enabled = false
+}
+
+plugins {
+    val kotlinVersion = "1.9.25"
+    id("org.jetbrains.kotlin.jvm") version kotlinVersion
</file context>


## Getting Started

This document assumes you have either maven or gradle available, either via the wrapper or otherwise. This does not come with a gradle / maven wrapper checked in.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: A Gradle wrapper is checked in, so this sentence incorrectly tells users they need Gradle installed. State that the Gradle wrapper is included and only the Maven wrapper is absent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/server/others/kotlin-springboot/allOf-multilevel/README.md, line 7:

<comment>A Gradle wrapper is checked in, so this sentence incorrectly tells users they need Gradle installed. State that the Gradle wrapper is included and only the Maven wrapper is absent.</comment>

<file context>
@@ -0,0 +1,21 @@
+
+## Getting Started
+
+This document assumes you have either maven or gradle available, either via the wrapper or otherwise. This does not come with a gradle / maven wrapper checked in.
+
+By default a [`pom.xml`](pom.xml) file will be generated. If you specified `gradleBuildFile=true` when generating this project, a `build.gradle.kts` will also be generated. Note this uses [Gradle Kotlin DSL](https://github.com/gradle/kotlin-dsl).
</file context>
Suggested change
This document assumes you have either maven or gradle available, either via the wrapper or otherwise. This does not come with a gradle / maven wrapper checked in.
This document assumes you have Maven available or use the checked-in Gradle wrapper; no Maven wrapper is included.


To build the project using maven, run:
```bash
mvn package && java -jar target/openapi-spring-1.0.0.jar

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The run instructions can't work for this sample: the pom.xml declares only maven-source-plugin and kotlin-maven-plugin (no spring-boot-maven-plugin, unlike samples/server/petstore/kotlin-springboot/pom.xml), build.gradle.kts sets tasks.bootJar { enabled = false }, and the sample has no @SpringBootApplication main class. The produced jar has no Main-Class, so java -jar target/openapi-spring-1.0.0.jar (and the corresponding gradle command) fails with "no main manifest attribute". Either make the sample runnable (add the Spring Boot plugin plus a main class) or replace these instructions with build/compile-only steps.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/server/others/kotlin-springboot/allOf-multilevel/README.md, line 13:

<comment>The run instructions can't work for this sample: the pom.xml declares only maven-source-plugin and kotlin-maven-plugin (no `spring-boot-maven-plugin`, unlike `samples/server/petstore/kotlin-springboot/pom.xml`), `build.gradle.kts` sets `tasks.bootJar { enabled = false }`, and the sample has no `@SpringBootApplication` main class. The produced jar has no Main-Class, so `java -jar target/openapi-spring-1.0.0.jar` (and the corresponding gradle command) fails with "no main manifest attribute". Either make the sample runnable (add the Spring Boot plugin plus a main class) or replace these instructions with build/compile-only steps.</comment>

<file context>
@@ -0,0 +1,21 @@
+
+To build the project using maven, run:
+```bash
+mvn package && java -jar target/openapi-spring-1.0.0.jar
+```
+
</file context>

@wing328

wing328 commented Sep 27, 2026

Copy link
Copy Markdown
Member

tested locally and the result is good.

let's give it a try

thanks for the PR

@wing328
wing328 merged commit d7dd6d0 into OpenAPITools:master Sep 27, 2026
60 checks passed
@wing328 wing328 added this to the 7.26.0 milestone Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG][Kotlin][kotlin-spring] Multi-level inheritance doesn't work

2 participants