Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/samples-kotlin-server-jdk17.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ jobs:
matrix:
sample:
# server
- samples/server/others/kotlin-springboot/allOf-multilevel
- samples/server/others/kotlin-springboot/oneOf-discriminator
- samples/server/others/kotlin-springboot/oneOf-discriminator-const
- samples/server/others/kotlin-springboot/oneOf-enum-discriminator
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/samples-kotlin-server-jdk21.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ jobs:
fail-fast: false
matrix:
sample:
- samples/server/others/kotlin-springboot/allOf-multilevel
- samples/server/others/kotlin-springboot/oneOf-discriminator
- samples/server/others/kotlin-springboot/oneOf-discriminator-const
- samples/server/others/kotlin-springboot/oneOf-enum-discriminator
Expand Down
11 changes: 11 additions & 0 deletions bin/configs/kotlin-spring-boot-allof-multilevel.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
generatorName: kotlin-spring
outputDir: samples/server/others/kotlin-springboot/allOf-multilevel
library: spring-boot
inputSpec: modules/openapi-generator/src/test/resources/3_1/allof-multilevel-inheritance.yaml
templateDir: modules/openapi-generator/src/main/resources/kotlin-spring
additionalProperties:
documentationProvider: none
annotationLibrary: none
useSwaggerUI: "false"
interfaceOnly: "true"
useSpringBoot3: "true"
3 changes: 2 additions & 1 deletion docs/generators/kotlin-spring.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|useFlowForArrayReturnType|Whether to use Flow for array/collection return types when reactive is enabled. If false, will use List instead.| |true|
|useJackson3|Use Jackson 3 dependencies (tools.jackson package). Only available with `useSpringBoot4`. Defaults to true when `useSpringBoot4` is enabled.| |false|
|useResponseEntity|Whether (when false) to return actual type (e.g. List<Fruit>) and handle non-happy path responses via exceptions flow or (when true) return entire ResponseEntity (e.g. ResponseEntity<List<Fruit>>). If disabled, method are annotated using a @ResponseStatus annotation, which has the status of the first response declared in the Api definition| |true|
|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|
|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|
|useSpringBoot3|Generate code and provide dependencies for use with Spring Boot ≥ 3 (use jakarta instead of javax in imports). Enabling this option will also enable `useJakartaEe`.| |false|
|useSpringBoot4|Generate code and provide dependencies for use with Spring Boot 4.x. Enabling this option will also enable `useJakartaEe`.| |false|
Expand Down Expand Up @@ -314,7 +315,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|Composite|✓|OAS2,OAS3
|Polymorphism|✓|OAS2,OAS3
|Union|✗|OAS3
|allOf||OAS2,OAS3
|allOf||OAS2,OAS3
|anyOf|✗|OAS3
|oneOf|✓|OAS3
|not|✗|OAS3
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ public class KotlinSpringServerCodegen extends AbstractKotlinCodegen
public static final String GENERATE_PAGEABLE_CONSTRAINT_VALIDATION = "generatePageableConstraintValidation";
public static final String SUBSTITUTE_GENERIC_PAGED_MODEL = "substituteGenericPagedModel";
public static final String USE_SEALED_RESPONSE_INTERFACES = "useSealedResponseInterfaces";
public static final String USE_SEALED_DISCRIMINATOR_INTERFACES = "useSealedDiscriminatorInterfaces";
public static final String COMPANION_OBJECT = "companionObject";
public static final String SUSPEND_FUNCTIONS = "suspendFunctions";

Expand Down Expand Up @@ -178,6 +179,7 @@ public String getDescription() {
@Setter private boolean generatePageableConstraintValidation = false;
@Setter private boolean substituteGenericPagedModel = false;
@Setter private boolean useSealedResponseInterfaces = false;
@Setter private boolean useSealedDiscriminatorInterfaces = true;
@Setter private boolean companionObject = false;
@Setter private boolean useEnumValueInterface = false;
private String valuedEnumClassName = "ValuedEnum";
Expand Down Expand Up @@ -229,7 +231,8 @@ public KotlinSpringServerCodegen() {
)
.includeSchemaSupportFeatures(
SchemaSupportFeature.Polymorphism,
SchemaSupportFeature.oneOf
SchemaSupportFeature.oneOf,
SchemaSupportFeature.allOf
)
.includeParameterFeatures(
ParameterFeature.Cookie
Expand Down Expand Up @@ -295,6 +298,13 @@ public KotlinSpringServerCodegen() {
addSwitch(USE_SEALED_RESPONSE_INTERFACES,
"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)",
useSealedResponseInterfaces);
addSwitch(USE_SEALED_DISCRIMINATOR_INTERFACES,
"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.",
useSealedDiscriminatorInterfaces);
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");
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");
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");
Expand Down Expand Up @@ -577,6 +587,12 @@ public void processOpts() {
}
writePropertyBack(USE_SEALED_RESPONSE_INTERFACES, useSealedResponseInterfaces);

if (additionalProperties.containsKey(USE_SEALED_DISCRIMINATOR_INTERFACES)) {
this.setUseSealedDiscriminatorInterfaces(
Boolean.parseBoolean(additionalProperties.get(USE_SEALED_DISCRIMINATOR_INTERFACES).toString()));
}
writePropertyBack(USE_SEALED_DISCRIMINATOR_INTERFACES, useSealedDiscriminatorInterfaces);

if (additionalProperties.containsKey(COMPANION_OBJECT)) {
this.setCompanionObject(convertPropertyToBooleanAndWriteBack(COMPANION_OBJECT));
} else {
Expand Down Expand Up @@ -1319,26 +1335,65 @@ public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs)

Map<String, CodegenModel> allModelsMap = getAllModels(objs);

// For each oneOf interface with a discriminator, mark the discriminator property
// as inherited in each subtype and set its default value from the discriminator mapping
// For each discriminator parent (oneOf interfaces and allOf parents alike), mark the
// discriminator property as inherited in each child and set its default value.
for (CodegenModel cm : allModelsMap.values()) {
if (Boolean.TRUE.equals(cm.vendorExtensions.get(CodegenConstants.X_IS_ONE_OF_INTERFACE))
&& cm.discriminator != null) {
String discrimBaseName = cm.discriminator.getPropertyBaseName();
String discrimType = cm.discriminator.getPropertyType();
boolean isEnumDiscriminator = cm.discriminator.getIsEnum();

// Build child name -> mapping name lookup from discriminator mappings
Map<String, String> childToMappingName = new HashMap<>();
for (CodegenDiscriminator.MappedModel mm : cm.discriminator.getMappedModels()) {
childToMappingName.put(mm.getModelName(), mm.getMappingName());
if (cm.discriminator == null
|| cm.discriminator.getMappedModels() == null
|| cm.discriminator.getMappedModels().isEmpty()) continue;
String discrimBaseName = cm.discriminator.getPropertyBaseName();
String discrimType = cm.discriminator.getPropertyType();
boolean isEnumDiscriminator = cm.discriminator.getIsEnum();
for (CodegenDiscriminator.MappedModel mm : cm.discriminator.getMappedModels()) {
CodegenModel child = allModelsMap.get(mm.getModelName());
if (child != null && child != cm) {
markPropertyAsInherited(child, discrimBaseName, discrimType,
mm.getMappingName(), isEnumDiscriminator);
}
}
}

for (String childName : cm.oneOf) {
CodegenModel child = allModelsMap.get(childName);
if (child != null) {
String mappingName = childToMappingName.get(childName);
markPropertyAsInherited(child, discrimBaseName, discrimType, mappingName, isEnumDiscriminator);
// Multi-level allOf inheritance: detect "mid-level" models — models that (a) have at least
// one child via allOf and (b) are themselves a child (have a parent) but are NOT a
// discriminator root. These must become `open class` so their own subclasses can extend them.
// Example: Animal (sealed interface) ← Dog (open class, has child BigDog) ← BigDog (data class)
for (CodegenModel cm : allModelsMap.values()) {
boolean isMidLevel = cm.hasChildren
&& cm.discriminator == null
&& cm.parent != null
&& !Boolean.TRUE.equals(cm.vendorExtensions.get(CodegenConstants.X_IS_ONE_OF_INTERFACE));
if (isMidLevel) {
// Mark for `open class` rendering in the template
cm.vendorExtensions.put("x-is-open-class", true);
// Mark every *own* (non-inherited) property as `open` so subclasses can override it.
// Inherited properties (override) are implicitly open in an open class.
Stream.of(cm.vars, cm.requiredVars, cm.optionalVars, cm.allVars)
.flatMap(List::stream)
.filter(p -> !p.isInherited)
.forEach(p -> p.vendorExtensions.put("x-model-is-open", true));
}
}

// For children of open (non-interface) parent classes, build a parent constructor call
// so the template can emit `: Dog(className = className, ...)`.
// x-parent-is-class tells the template the parent requires `()` (even when arg list is empty);
// x-parent-ctor-args holds the argument string. Kept separate so a parent with no properties
// still generates `: ParentClass()` rather than the compile-error `: ParentClass` (no parens).
for (CodegenModel cm : allModelsMap.values()) {
if (cm.parent != null) {
CodegenModel parentModel = allModelsMap.get(cm.parent);
if (parentModel != null
&& Boolean.TRUE.equals(parentModel.vendorExtensions.get("x-is-open-class"))) {
cm.vendorExtensions.put("x-parent-is-class", true);
List<String> ctorArgs = new ArrayList<>();
for (CodegenProperty prop : parentModel.getRequiredVars()) {
ctorArgs.add(prop.getName() + " = " + prop.getName());
}
for (CodegenProperty prop : parentModel.getOptionalVars()) {
ctorArgs.add(prop.getName() + " = " + prop.getName());
}
if (!ctorArgs.isEmpty()) {
cm.vendorExtensions.put("x-parent-ctor-args", String.join(", ", ctorArgs));
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@
{{#vendorExtensions.x-class-extra-annotation}}
{{{.}}}
{{/vendorExtensions.x-class-extra-annotation}}
{{#discriminator}}interface {{classname}}{{/discriminator}}{{^discriminator}}{{#hasVars}}data {{/hasVars}}class {{classname}}(
{{#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}}(
{{#requiredVars}}
{{>dataClassReqVar}}{{^-last}},
{{/-last}}{{/requiredVars}}{{#hasRequired}}{{#hasOptional}},
{{/hasOptional}}{{/hasRequired}}{{#optionalVars}}{{>dataClassOptVar}}{{^-last}},
{{/-last}}{{/optionalVars}}
){{/discriminator}}{{! no newline
}}{{#parent}} : {{{.}}}{{#isMap}}(){{/isMap}}{{! no newline
}}{{#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
}}{{#vendorExtensions.x-kotlin-implements}}, {{{.}}}{{/vendorExtensions.x-kotlin-implements}}{{! <- serializableModel is also handled via x-kotlin-implements
}}{{#vendorExtensions.x-implements-sealed-interfaces}}{{#.}}, {{{.}}}{{/.}}{{/vendorExtensions.x-implements-sealed-interfaces}}{{! <- add sealed interface implementations
}}{{/parent}}{{! no newline
Expand All @@ -43,6 +43,29 @@
{{>interfaceOptVar}}{{! prevent indent}}
{{/optionalVars}}
{{/discriminator}}
{{#vendorExtensions.x-is-open-class}}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other?.javaClass != javaClass) return false
other as {{classname}}
return {{#allVars}}{{name}} == other.{{name}}{{^-last}}
&& {{/-last}}{{/allVars}}{{^allVars}}true{{/allVars}}
}

override fun hashCode(): Int {
return Objects.hash({{#allVars}}{{name}}{{^-last}}, {{/-last}}{{/allVars}})
}

override fun toString(): String {
return "{{classname}}({{#allVars}}{{name}}=${{name}}{{^-last}}, {{/-last}}{{/allVars}})"
}

fun copy(
{{#allVars}}
{{name}}: {{{dataType}}}{{^required}}?{{/required}} = this.{{name}}{{^-last}},{{/-last}}
{{/allVars}}
): {{classname}} = {{classname}}({{#allVars}}{{name}} = {{name}}{{^-last}}, {{/-last}}{{/allVars}})
{{/vendorExtensions.x-is-open-class}}
{{#hasEnums}}{{#vars}}{{#isEnum}}
/**
* {{{description}}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
@Deprecated(message = ""){{/deprecated}}{{#vendorExtensions.x-field-extra-annotation}}
{{{.}}}{{/vendorExtensions.x-field-extra-annotation}}{{#vendorExtensions.x-has-json-setter-nulls-fail}}
@field:JsonSetter(nulls = Nulls.FAIL){{/vendorExtensions.x-has-json-setter-nulls-fail}}
@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}}
@get:JsonProperty("{{{baseName}}}"){{#isInherited}} override{{/isInherited}}{{^isInherited}}{{#vendorExtensions.x-model-is-open}} open{{/vendorExtensions.x-model-is-open}}{{/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}}
Loading