Skip to content

Commit 2f13add

Browse files
rvlasveldclaude
andauthored
[kotlin-spring] seal allOf discriminator parent interfaces by default (#23953)
A model with a discriminator is rendered as an interface. Because the generator knows every subtype at generation time, that interface can be sealed, which lets callers use exhaustive `when` matching and stops external code from adding implementors that the discriminator mapping does not know about. Adds `useSealedDiscriminatorInterfaces` (default true). Set it to false to restore the plain `interface` rendering, which is needed when the generated interface is implemented from outside the generated package. Also declares SchemaSupportFeature.allOf, which the generator supports but did not advertise. Claude-Session: https://claude.ai/code/session_01VBn7VWUxZL6mTUbg21xNjm Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a343817 commit 2f13add

8 files changed

Lines changed: 112 additions & 7 deletions

File tree

‎docs/generators/kotlin-spring.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
7474
|useFlowForArrayReturnType|Whether to use Flow for array/collection return types when reactive is enabled. If false, will use List instead.| |true|
7575
|useJackson3|Use Jackson 3 dependencies (tools.jackson package). Only available with `useSpringBoot4`. Defaults to true when `useSpringBoot4` is enabled.| |false|
7676
|useResponseEntity|Whether (when false) to return actual type (e.g. List&lt;Fruit&gt;) and handle non-happy path responses via exceptions flow or (when true) return entire ResponseEntity (e.g. ResponseEntity&lt;List&lt;Fruit&gt;&gt;). If disabled, method are annotated using a @ResponseStatus annotation, which has the status of the first response declared in the Api definition| |true|
77+
|useSealedDiscriminatorInterfaces|Generate sealed interfaces instead of plain interfaces for allOf discriminator parent models. When true (default), discriminator parents rendered as `sealed interface`, enabling exhaustive `when` matching and preventing external implementors (which cannot know all subtypes). Set to false to restore the legacy plain `interface` behavior, e.g. when you implement the generated interface from a module outside the generated package.| |true|
7778
|useSealedResponseInterfaces|Generate sealed interfaces for endpoint responses that all possible response types implement. Allows controllers to return any valid response type in a type-safe manner (e.g., sealed interface CreateUserResponse implemented by User, ConflictResponse, ErrorResponse)| |false|
7879
|useSpringBoot3|Generate code and provide dependencies for use with Spring Boot &ge; 3 (use jakarta instead of javax in imports). Enabling this option will also enable `useJakartaEe`.| |false|
7980
|useSpringBoot4|Generate code and provide dependencies for use with Spring Boot 4.x. Enabling this option will also enable `useJakartaEe`.| |false|
@@ -325,7 +326,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
325326
|Composite|✓|OAS2,OAS3
326327
|Polymorphism|✓|OAS2,OAS3
327328
|Union|✗|OAS3
328-
|allOf|✗|OAS2,OAS3
329+
|allOf|✓|OAS2,OAS3
329330
|anyOf|✗|OAS3
330331
|oneOf|✓|OAS3
331332
|not|✗|OAS3

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ public class KotlinSpringServerCodegen extends AbstractKotlinCodegen
115115
public static final String GENERATE_PAGEABLE_CONSTRAINT_VALIDATION = "generatePageableConstraintValidation";
116116
public static final String SUBSTITUTE_GENERIC_PAGED_MODEL = "substituteGenericPagedModel";
117117
public static final String USE_SEALED_RESPONSE_INTERFACES = "useSealedResponseInterfaces";
118+
public static final String USE_SEALED_DISCRIMINATOR_INTERFACES = "useSealedDiscriminatorInterfaces";
118119
public static final String COMPANION_OBJECT = "companionObject";
119120
public static final String SUSPEND_FUNCTIONS = "suspendFunctions";
120121

@@ -201,6 +202,7 @@ public void setAutoXSpringPaginated(boolean autoXSpringPaginated) {
201202
@Setter private boolean generatePageableConstraintValidation = false;
202203
@Setter private boolean substituteGenericPagedModel = false;
203204
@Setter private boolean useSealedResponseInterfaces = false;
205+
@Setter private boolean useSealedDiscriminatorInterfaces = true;
204206
@Setter private boolean companionObject = false;
205207
@Setter private boolean useEnumValueInterface = false;
206208
private String valuedEnumClassName = "ValuedEnum";
@@ -262,7 +264,8 @@ public KotlinSpringServerCodegen() {
262264
)
263265
.includeSchemaSupportFeatures(
264266
SchemaSupportFeature.Polymorphism,
265-
SchemaSupportFeature.oneOf
267+
SchemaSupportFeature.oneOf,
268+
SchemaSupportFeature.allOf
266269
)
267270
.includeParameterFeatures(
268271
ParameterFeature.Cookie
@@ -329,6 +332,13 @@ public KotlinSpringServerCodegen() {
329332
addSwitch(USE_SEALED_RESPONSE_INTERFACES,
330333
"Generate sealed interfaces for endpoint responses that all possible response types implement. Allows controllers to return any valid response type in a type-safe manner (e.g., sealed interface CreateUserResponse implemented by User, ConflictResponse, ErrorResponse)",
331334
useSealedResponseInterfaces);
335+
addSwitch(USE_SEALED_DISCRIMINATOR_INTERFACES,
336+
"Generate sealed interfaces instead of plain interfaces for allOf discriminator parent models. " +
337+
"When true (default), discriminator parents rendered as `sealed interface`, enabling exhaustive " +
338+
"`when` matching and preventing external implementors (which cannot know all subtypes). " +
339+
"Set to false to restore the legacy plain `interface` behavior, e.g. when you implement " +
340+
"the generated interface from a module outside the generated package.",
341+
useSealedDiscriminatorInterfaces);
332342
addOption(X_KOTLIN_IMPLEMENTS_SKIP, "A list of fully qualified interfaces that should NOT be implemented despite their presence in vendor extension `x-kotlin-implements`. Example: yaml `xKotlinImplementsSkip: [com.some.pack.WithPhotoUrls]` skips implementing the interface in any schema", "empty list");
333343
addOption(X_KOTLIN_IMPLEMENTS_FIELDS_SKIP, "A list of fields per schema name that should NOT be created with `override` keyword despite their presence in vendor extension `x-kotlin-implements-fields` for the schema. Example: yaml `xKotlinImplementsFieldsSkip: Pet: [photoUrls]` skips `override` for `photoUrls` in schema `Pet`", "empty map");
334344
addOption(SCHEMA_IMPLEMENTS, "A map of single interface or a list of interfaces per schema name that should be implemented (serves similar purpose as `x-kotlin-implements`, but is fully decoupled from the api spec). Example: yaml `schemaImplements: {Pet: com.some.pack.WithId, Category: [com.some.pack.CategoryInterface], Dog: [com.some.pack.Canine, com.some.pack.OtherInterface]}` implements interfaces in schemas `Pet` (interface `com.some.pack.WithId`), `Category` (interface `com.some.pack.CategoryInterface`), `Dog`(interfaces `com.some.pack.Canine`, `com.some.pack.OtherInterface`)", "empty map");
@@ -652,6 +662,12 @@ public void processOpts() {
652662
}
653663
writePropertyBack(USE_SEALED_RESPONSE_INTERFACES, useSealedResponseInterfaces);
654664

665+
if (additionalProperties.containsKey(USE_SEALED_DISCRIMINATOR_INTERFACES)) {
666+
this.setUseSealedDiscriminatorInterfaces(
667+
Boolean.parseBoolean(additionalProperties.get(USE_SEALED_DISCRIMINATOR_INTERFACES).toString()));
668+
}
669+
writePropertyBack(USE_SEALED_DISCRIMINATOR_INTERFACES, useSealedDiscriminatorInterfaces);
670+
655671
if (additionalProperties.containsKey(COMPANION_OBJECT)) {
656672
this.setCompanionObject(convertPropertyToBooleanAndWriteBack(COMPANION_OBJECT));
657673
} else {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
{{#vendorExtensions.x-class-extra-annotation}}
1515
{{{.}}}
1616
{{/vendorExtensions.x-class-extra-annotation}}
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}}(
17+
{{#discriminator}}{{#useSealedDiscriminatorInterfaces}}sealed {{/useSealedDiscriminatorInterfaces}}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}},

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

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8105,4 +8105,92 @@ public void testMultiLevelAllOfWithEscapedPropertyNames() throws IOException {
81058105
assertFileContains(mid, "\"2ndField=\" + `2ndField`");
81068106
assertFileNotContains(mid, "$`2ndField`");
81078107
}
8108+
8109+
// ==================== allOf sealed interface tests ====================
8110+
8111+
@Test(description = "allOf discriminator parent generates sealed interface by default")
8112+
public void testAllOfDiscriminatorParentIsSealedInterfaceByDefault() throws IOException {
8113+
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
8114+
output.deleteOnExit();
8115+
8116+
new DefaultGenerator().opts(new ClientOptInput()
8117+
.openAPI(new OpenAPIParser().readLocation("src/test/resources/3_1/polymorphism-allof-and-discriminator.yaml", null, new ParseOptions()).getOpenAPI())
8118+
.config(new KotlinSpringServerCodegen() {{
8119+
setOutputDir(output.getAbsolutePath());
8120+
}}))
8121+
.generate();
8122+
8123+
String outputPath = output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/model";
8124+
8125+
// Pet should be a sealed interface with Jackson annotations
8126+
assertFileContains(Paths.get(outputPath + "/Pet.kt"),
8127+
"sealed interface Pet",
8128+
"@JsonTypeInfo", "property = \"petType\"", "visible = true",
8129+
"@JsonSubTypes"
8130+
);
8131+
// Children must remain data classes — sealing only applies to the discriminator parent
8132+
assertFileContains(Paths.get(outputPath + "/Dog.kt"), "data class Dog");
8133+
assertFileContains(Paths.get(outputPath + "/Cat.kt"), "data class Cat");
8134+
assertFileNotContains(Paths.get(outputPath + "/Dog.kt"), "sealed");
8135+
assertFileNotContains(Paths.get(outputPath + "/Cat.kt"), "sealed");
8136+
}
8137+
8138+
@Test(description = "allOf discriminator parent generates plain interface when useSealedDiscriminatorInterfaces=false")
8139+
public void testAllOfDiscriminatorOptOutPlainInterface() throws IOException {
8140+
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
8141+
output.deleteOnExit();
8142+
8143+
new DefaultGenerator().opts(new ClientOptInput()
8144+
.openAPI(new OpenAPIParser().readLocation("src/test/resources/3_1/polymorphism-allof-and-discriminator.yaml", null, new ParseOptions()).getOpenAPI())
8145+
.config(new KotlinSpringServerCodegen() {{
8146+
setOutputDir(output.getAbsolutePath());
8147+
additionalProperties().put(USE_SEALED_DISCRIMINATOR_INTERFACES, "false");
8148+
}}))
8149+
.generate();
8150+
8151+
String outputPath = output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/model";
8152+
8153+
// Opt-out: plain interface, not sealed
8154+
assertFileContains(Paths.get(outputPath + "/Pet.kt"), "interface Pet");
8155+
assertFileNotContains(Paths.get(outputPath + "/Pet.kt"), "sealed interface Pet");
8156+
}
8157+
8158+
@Test(description = "allOf sealed interface correctly composes external x-kotlin-implements supertypes")
8159+
public void testSealedInterfaceWithExternalSupertypes() throws IOException {
8160+
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
8161+
output.deleteOnExit();
8162+
8163+
new DefaultGenerator().opts(new ClientOptInput()
8164+
.openAPI(new OpenAPIParser().readLocation("src/test/resources/3_0/kotlin/petstore-with-x-kotlin-implements.yaml", null, new ParseOptions()).getOpenAPI())
8165+
.config(new KotlinSpringServerCodegen() {{
8166+
setOutputDir(output.getAbsolutePath());
8167+
}}))
8168+
.generate();
8169+
8170+
String outputPath = output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/model";
8171+
8172+
// Pet has x-kotlin-implements external supertypes; sealed should come before those
8173+
assertFileContains(Paths.get(outputPath + "/Pet.kt"),
8174+
"sealed interface Pet : com.some.pack.Named"
8175+
);
8176+
}
8177+
8178+
@Test(description = "oneOf sealed interface stays sealed when useSealedDiscriminatorInterfaces=false (different template path)")
8179+
public void testOneOfInterfaceStillSealedWhenAllOfFlagOff() throws IOException {
8180+
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
8181+
output.deleteOnExit();
8182+
8183+
new DefaultGenerator().opts(new ClientOptInput()
8184+
.openAPI(new OpenAPIParser().readLocation("src/test/resources/3_0/kotlin/polymorphism-oneof-discriminator.yaml", null, new ParseOptions()).getOpenAPI())
8185+
.config(new KotlinSpringServerCodegen() {{
8186+
setOutputDir(output.getAbsolutePath());
8187+
additionalProperties().put(USE_SEALED_DISCRIMINATOR_INTERFACES, "false");
8188+
}}))
8189+
.generate();
8190+
8191+
String outputPath = output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/model";
8192+
8193+
// oneOf parent uses oneof_interface.mustache, not dataClass.mustache — flag has no effect on it
8194+
assertFileContains(Paths.get(outputPath + "/Animal.kt"), "sealed interface Animal");
8195+
}
81088196
}

‎samples/server/others/kotlin-springboot/allOf-multilevel/src/main/kotlin/org/openapitools/model/Animal.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import jakarta.validation.Valid
3131
JsonSubTypes.Type(value = Dog::class, name = "Dog")
3232
)
3333

34-
interface Animal {
34+
sealed interface Animal {
3535

3636
val className: kotlin.String
3737

‎samples/server/petstore/kotlin-springboot-include-http-request-context-delegate/src/main/kotlin/org/openapitools/model/Pet.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import io.swagger.annotations.ApiModelProperty
4444
JsonSubTypes.Type(value = Dog::class, name = "Dog")
4545
)
4646

47-
interface Pet : com.some.pack.Named, com.some.pack.WithCategory, com.some.pack.WithDefaultMethods, com.some.pack.WithPhotoUrls, java.io.Serializable {
47+
sealed interface Pet : com.some.pack.Named, com.some.pack.WithCategory, com.some.pack.WithDefaultMethods, com.some.pack.WithPhotoUrls, java.io.Serializable {
4848

4949
@get:ApiModelProperty(required = true, value = "")
5050
override val name: kotlin.String

‎samples/server/petstore/kotlin-springboot-request-cookie/src/main/kotlin/org/openapitools/model/Animal.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import io.swagger.v3.oas.annotations.media.Schema
3434
JsonSubTypes.Type(value = Dog::class, name = "DOG")
3535
)
3636

37-
interface Animal {
37+
sealed interface Animal {
3838
@get:Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "")
3939
val className: kotlin.String
4040

‎samples/server/petstore/kotlin-springboot-x-kotlin-implements/src/main/kotlin/org/openapitools/model/Pet.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import io.swagger.annotations.ApiModelProperty
4444
JsonSubTypes.Type(value = Dog::class, name = "Dog")
4545
)
4646

47-
interface Pet : com.some.pack.Named, com.some.pack.WithCategory, com.some.pack.WithDefaultMethods, com.some.pack.WithId, java.io.Serializable {
47+
sealed interface Pet : com.some.pack.Named, com.some.pack.WithCategory, com.some.pack.WithDefaultMethods, com.some.pack.WithId, java.io.Serializable {
4848

4949
@get:ApiModelProperty(required = true, value = "")
5050
override val name: kotlin.String

0 commit comments

Comments
 (0)