From 1af1d1ce0400708d296eb0d2f2d3df0f50ce2351 Mon Sep 17 00:00:00 2001 From: Roemer Vlasveld Date: Tue, 31 Mar 2026 15:52:55 +0200 Subject: [PATCH 1/2] [kotlin-spring] Add sealed class polymorphism support via useSealedDiscriminatorClasses option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add opt-in `useSealedDiscriminatorClasses` configuration option (default `false`) that enables proper polymorphism support for the kotlin-spring generator using sealed classes instead of interfaces. When enabled, discriminator parent models are generated as Kotlin sealed classes with proper Jackson `@JsonTypeInfo`/`@JsonSubTypes` annotations. Child classes extend the parent via constructor inheritance with `override` properties. This approach mirrors the polymorphism support added to the kotlin-server generator in #22610. The motivation for this change is that the current interface-based polymorphism has several long-standing issues: parent interfaces incorrectly contain all children's properties, child classes don't properly implement the parent interface, the `override` keyword is missing on inherited properties, and multi-level inheritance produces invalid data class hierarchies that don't compile. These issues have been tracked across multiple GitHub issues over several years. The implementation adds a `postProcessAllModels()` method (ported from `KotlinServerCodegen`) that builds parent-child relationships for discriminator models in three passes: first collecting discriminator mappings, then processing child models to mark inherited properties and generate parent constructor arguments, and finally configuring vendor extensions that control the template rendering. Both oneOf (type union) and allOf (inheritance) discriminator patterns are supported. For oneOf, the parent sealed class contains only the discriminator property; children get the discriminator as an overridden property with a default value. For allOf, the parent sealed class contains all its declared properties; children override inherited properties and pass them via the parent constructor call. A companion `fixJacksonJsonTypeInfoInheritance` option (default `true`, only applies when sealed classes are enabled) controls whether Jackson's `visible` flag is set to `true` on `@JsonTypeInfo` and whether discriminator properties are automatically added to child models with appropriate default values. The default behavior (`useSealedDiscriminatorClasses=false`) is completely unchanged — existing users continue to get interface-based generation with zero sample file changes. This makes the change non-breaking and suitable for a minor release. New template `dataClassSealedVar.mustache` renders sealed class constructor properties with `open`/`override` modifiers and `@get:Schema`/`@get:JsonProperty` annotations. The existing `dataClass.mustache` and `typeInfoAnnotation.mustache` templates are updated to conditionally render either interface or sealed class based on the flag. Six new test methods cover oneOf with discriminator, allOf with discriminator, polymorphism without discriminator, and the `fixJacksonJsonTypeInfoInheritance` toggle. All 149 existing tests continue to pass. Fixes #18167, #18206, #11347, #8060 Related: #8366, #8059 Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/generators/kotlin-spring.md | 6 +- .../languages/KotlinSpringServerCodegen.java | 290 ++++++++++++++++++ .../kotlin-spring/dataClass.mustache | 15 +- .../kotlin-spring/dataClassSealedVar.mustache | 5 + .../kotlin-spring/typeInfoAnnotation.mustache | 2 +- .../spring/KotlinSpringServerCodegenTest.java | 190 +++++++++++- 6 files changed, 501 insertions(+), 7 deletions(-) create mode 100644 modules/openapi-generator/src/main/resources/kotlin-spring/dataClassSealedVar.mustache diff --git a/docs/generators/kotlin-spring.md b/docs/generators/kotlin-spring.md index 6c43046fbfb9..d9a28aaf1391 100644 --- a/docs/generators/kotlin-spring.md +++ b/docs/generators/kotlin-spring.md @@ -34,6 +34,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl |documentationProvider|Select the OpenAPI documentation provider.|
**none**
Do not publish an OpenAPI specification.
**source**
Publish the original input OpenAPI specification.
**springdoc**
Generate an OpenAPI 3 specification using SpringDoc.
|springdoc| |enumPropertyNaming|Naming convention for enum properties: 'camelCase', 'PascalCase', 'snake_case', 'UPPERCASE', and 'original'| |original| |exceptionHandler|generate default global exception handlers (not compatible with reactive. enabling reactive will disable exceptionHandler )| |true| +|fixJacksonJsonTypeInfoInheritance|Only applies when useSealedDiscriminatorClasses is true. When true (default), ensures Jackson polymorphism works correctly by: (1) always setting visible=true on @JsonTypeInfo, and (2) adding the discriminator property to child models with appropriate default values. When false, visible is only set to true if all children already define the discriminator property.| |true| |gradleBuildFile|generate a gradle build file using the Kotlin DSL| |true| |groupId|Generated artifact package's organization (i.e. maven groupId).| |org.openapitools| |includeHttpRequestContext|Whether to include HttpServletRequest (blocking) or ServerWebExchange (reactive) as additional parameter in generated methods.| |false| @@ -61,6 +62,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. Incompatible with `openApiNullable`.| |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| +|useSealedDiscriminatorClasses|Generate sealed classes instead of interfaces for discriminator models. When true, discriminator parent models are generated as sealed classes with proper Jackson @JsonTypeInfo/@JsonSubTypes annotations and child classes extend the parent with constructor inheritance. When false (default), the legacy interface-based polymorphism is used.| |false| |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| @@ -307,9 +309,9 @@ 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 +|oneOf|✓|OAS3 |not|✗|OAS3 ### Security Feature diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java index 6730dd636649..e67dba5a6aa8 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java @@ -166,6 +166,25 @@ public String getDescription() { @Setter private boolean autoXSpringPaginated = false; @Setter private boolean useSealedResponseInterfaces = false; @Setter private boolean companionObject = false; + @Getter @Setter + private boolean useSealedDiscriminatorClasses = false; + @Getter @Setter + private boolean fixJacksonJsonTypeInfoInheritance = true; + + public static final String USE_SEALED_DISCRIMINATOR_CLASSES = "useSealedDiscriminatorClasses"; + public static final String USE_SEALED_DISCRIMINATOR_CLASSES_DESC = + "Generate sealed classes instead of interfaces for discriminator models. " + + "When true, discriminator parent models are generated as sealed classes with proper " + + "Jackson @JsonTypeInfo/@JsonSubTypes annotations and child classes extend the parent " + + "with constructor inheritance. When false (default), the legacy interface-based " + + "polymorphism is used."; + public static final String FIX_JACKSON_JSON_TYPE_INFO_INHERITANCE = "fixJacksonJsonTypeInfoInheritance"; + public static final String FIX_JACKSON_JSON_TYPE_INFO_INHERITANCE_DESC = + "Only applies when useSealedDiscriminatorClasses is true. " + + "When true (default), ensures Jackson polymorphism works correctly by: " + + "(1) always setting visible=true on @JsonTypeInfo, and (2) adding the discriminator property " + + "to child models with appropriate default values. When false, visible is only set to true if " + + "all children already define the discriminator property."; @Getter @Setter protected boolean useSpringBoot3 = false; @@ -201,6 +220,8 @@ public KotlinSpringServerCodegen() { GlobalFeature.ParameterStyling ) .includeSchemaSupportFeatures( + SchemaSupportFeature.allOf, + SchemaSupportFeature.oneOf, SchemaSupportFeature.Polymorphism ) .includeParameterFeatures( @@ -273,6 +294,8 @@ public KotlinSpringServerCodegen() { addOption(SCHEMA_IMPLEMENTS_FIELDS, "A map of single field or a list of fields per schema name that should be prepended with `override` (serves similar purpose as `x-kotlin-implements-fields`, but is fully decoupled from the api spec). Example: yaml `schemaImplementsFields: {Pet: id, Category: [name, id], Dog: [bark, breed]}` marks fields to be prepended with `override` in schemas `Pet` (field `id`), `Category` (fields `name`, `id`) and `Dog` (fields `bark`, `breed`)", "empty map"); addSwitch(AUTO_X_SPRING_PAGINATED, "Automatically add x-spring-paginated to operations that have 'page', 'size', and 'sort' query parameters. When enabled, operations with all three parameters will have Pageable support automatically applied. Operations with x-spring-paginated explicitly set to false will not be auto-detected.", autoXSpringPaginated); addSwitch(COMPANION_OBJECT, "Whether to generate companion objects in data classes, enabling companion extensions.", companionObject); + addSwitch(USE_SEALED_DISCRIMINATOR_CLASSES, USE_SEALED_DISCRIMINATOR_CLASSES_DESC, useSealedDiscriminatorClasses); + addSwitch(FIX_JACKSON_JSON_TYPE_INFO_INHERITANCE, FIX_JACKSON_JSON_TYPE_INFO_INHERITANCE_DESC, fixJacksonJsonTypeInfoInheritance); supportedLibraries.put(SPRING_BOOT, "Spring-boot Server application."); supportedLibraries.put(SPRING_CLOUD_LIBRARY, "Spring-Cloud-Feign client with Spring-Boot auto-configured settings."); @@ -534,6 +557,23 @@ public void processOpts() { additionalProperties.put(COMPANION_OBJECT, companionObject); } + if (additionalProperties.containsKey(USE_SEALED_DISCRIMINATOR_CLASSES)) { + this.setUseSealedDiscriminatorClasses( + Boolean.parseBoolean(additionalProperties.get(USE_SEALED_DISCRIMINATOR_CLASSES).toString())); + } + additionalProperties.put(USE_SEALED_DISCRIMINATOR_CLASSES, useSealedDiscriminatorClasses); + + if (useSealedDiscriminatorClasses) { + // Enable proper oneOf/anyOf discriminator handling for sealed class polymorphism + legacyDiscriminatorBehavior = false; + } + + if (additionalProperties.containsKey(FIX_JACKSON_JSON_TYPE_INFO_INHERITANCE)) { + this.setFixJacksonJsonTypeInfoInheritance( + Boolean.parseBoolean(additionalProperties.get(FIX_JACKSON_JSON_TYPE_INFO_INHERITANCE).toString())); + } + additionalProperties.put(FIX_JACKSON_JSON_TYPE_INFO_INHERITANCE, fixJacksonJsonTypeInfoInheritance); + additionalProperties.put("springHttpStatus", new SpringHttpStatusLambda()); // Set basePackage from invokerPackage @@ -1123,6 +1163,256 @@ public void preprocessOpenAPI(OpenAPI openAPI) { // TODO: Handle tags } + @Override + public Map postProcessAllModels(Map objs) { + objs = super.postProcessAllModels(objs); + + if (!useSealedDiscriminatorClasses) { + return objs; + } + + // Build a map of model name -> model for easy lookup + Map allModelsMap = new HashMap<>(); + for (ModelsMap modelsMap : objs.values()) { + for (ModelMap modelMap : modelsMap.getModels()) { + CodegenModel model = modelMap.getModel(); + allModelsMap.put(model.getClassname(), model); + } + } + + // First pass: collect all discriminator parent -> children mappings + // Also identify the "true" discriminator owners (not inherited via allOf) + Map childToParentMap = new HashMap<>(); + Set trueDiscriminatorOwners = new HashSet<>(); + + for (ModelsMap modelsMap : objs.values()) { + for (ModelMap modelMap : modelsMap.getModels()) { + CodegenModel model = modelMap.getModel(); + if (model.getDiscriminator() != null && model.getDiscriminator().getMappedModels() != null + && !model.getDiscriminator().getMappedModels().isEmpty()) { + String discriminatorPropBaseName = model.getDiscriminator().getPropertyBaseName(); + + for (CodegenDiscriminator.MappedModel mappedModel : model.getDiscriminator().getMappedModels()) { + childToParentMap.put(mappedModel.getModelName(), model.getClassname()); + + // If the mapping name equals the model name, check if we can derive + // a better mapping name from the child's discriminator property enum value + if (mappedModel.getMappingName().equals(mappedModel.getModelName())) { + CodegenModel childModel = allModelsMap.get(mappedModel.getModelName()); + if (childModel != null) { + for (CodegenProperty prop : childModel.getAllVars()) { + if (prop.getBaseName().equals(discriminatorPropBaseName) && prop.isEnum) { + Map allowableValues = prop.getAllowableValues(); + if (allowableValues != null && allowableValues.containsKey("values")) { + @SuppressWarnings("unchecked") + List values = (List) allowableValues.get("values"); + if (values != null && values.size() == 1) { + mappedModel.setMappingName(String.valueOf(values.get(0))); + } + } + } + } + } + } + } + trueDiscriminatorOwners.add(model.getClassname()); + } + } + } + + // Second pass: process child models + for (ModelsMap modelsMap : objs.values()) { + for (ModelMap modelMap : modelsMap.getModels()) { + CodegenModel model = modelMap.getModel(); + String parentName = childToParentMap.get(model.getClassname()); + + if (parentName != null) { + CodegenModel parentModel = allModelsMap.get(parentName); + + if (model.getParent() == null) { + model.setParent(parentName); + } + + // If this child has a discriminator but it's inherited (not a true owner), + // remove it - only the parent should have the discriminator annotations + if (model.getDiscriminator() != null && !trueDiscriminatorOwners.contains(model.getClassname())) { + model.setDiscriminator(null); + } + + // For allOf pattern: if parent has properties, mark child's inherited properties + boolean parentIsOneOfOrAnyOf = parentModel != null + && ((parentModel.oneOf != null && !parentModel.oneOf.isEmpty()) + || (parentModel.anyOf != null && !parentModel.anyOf.isEmpty())); + + if (parentModel != null && parentModel.getHasVars() && !parentIsOneOfOrAnyOf) { + Set parentPropNames = new HashSet<>(); + List inheritedPropNamesList = new ArrayList<>(); + for (CodegenProperty parentProp : parentModel.getAllVars()) { + parentPropNames.add(parentProp.getBaseName()); + inheritedPropNamesList.add(parentProp.getName()); + } + + for (CodegenProperty prop : model.getAllVars()) { + if (parentPropNames.contains(prop.getBaseName())) { + prop.isInherited = true; + } + } + for (CodegenProperty prop : model.getVars()) { + if (parentPropNames.contains(prop.getBaseName())) { + prop.isInherited = true; + } + } + for (CodegenProperty prop : model.getRequiredVars()) { + if (parentPropNames.contains(prop.getBaseName())) { + prop.isInherited = true; + } + } + for (CodegenProperty prop : model.getOptionalVars()) { + if (parentPropNames.contains(prop.getBaseName())) { + prop.isInherited = true; + } + } + + if (!inheritedPropNamesList.isEmpty()) { + String parentCtorArgs = String.join(", ", inheritedPropNamesList.stream() + .map(name -> name + " = " + name) + .toArray(String[]::new)); + model.getVendorExtensions().put("x-parent-ctor-args", parentCtorArgs); + } + } + } + } + } + + // Third pass: set vendor extension for discriminator style and handle fixJacksonJsonTypeInfoInheritance + for (String ownerName : trueDiscriminatorOwners) { + CodegenModel owner = allModelsMap.get(ownerName); + if (owner != null && owner.getDiscriminator() != null) { + String discriminatorPropBaseName = owner.getDiscriminator().getPropertyBaseName(); + boolean isOneOfOrAnyOfPattern = (owner.oneOf != null && !owner.oneOf.isEmpty()) + || (owner.anyOf != null && !owner.anyOf.isEmpty()); + + boolean hasParentProperties = !isOneOfOrAnyOfPattern; + boolean visibleTrue; + + if (fixJacksonJsonTypeInfoInheritance) { + visibleTrue = true; + + if (isOneOfOrAnyOfPattern) { + String discriminatorVarName = toVarName(discriminatorPropBaseName); + + owner.getVars().clear(); + owner.getRequiredVars().clear(); + owner.getOptionalVars().clear(); + owner.getAllVars().clear(); + + CodegenProperty parentDiscriminatorProp = new CodegenProperty(); + parentDiscriminatorProp.baseName = discriminatorPropBaseName; + parentDiscriminatorProp.name = discriminatorVarName; + parentDiscriminatorProp.dataType = "kotlin.String"; + parentDiscriminatorProp.datatypeWithEnum = "kotlin.String"; + parentDiscriminatorProp.required = true; + parentDiscriminatorProp.isNullable = false; + parentDiscriminatorProp.isReadOnly = false; + + owner.getVars().add(parentDiscriminatorProp); + owner.getRequiredVars().add(parentDiscriminatorProp); + owner.getAllVars().add(parentDiscriminatorProp); + + hasParentProperties = true; + + for (CodegenDiscriminator.MappedModel mappedModel : owner.getDiscriminator().getMappedModels()) { + CodegenModel childModel = allModelsMap.get(mappedModel.getModelName()); + if (childModel != null) { + boolean hasDiscriminatorProp = false; + String discriminatorDefault = "\"" + mappedModel.getMappingName() + "\""; + + for (CodegenProperty prop : childModel.getVars()) { + if (prop.getBaseName().equals(discriminatorPropBaseName)) { + hasDiscriminatorProp = true; + prop.defaultValue = discriminatorDefault; + prop.dataType = "kotlin.String"; + prop.datatypeWithEnum = "kotlin.String"; + prop.required = true; + prop.isNullable = false; + prop.isInherited = true; + } + } + for (CodegenProperty prop : childModel.getAllVars()) { + if (prop.getBaseName().equals(discriminatorPropBaseName)) { + prop.defaultValue = discriminatorDefault; + prop.dataType = "kotlin.String"; + prop.datatypeWithEnum = "kotlin.String"; + prop.required = true; + prop.isNullable = false; + prop.isInherited = true; + } + } + + CodegenProperty propToMove = null; + for (CodegenProperty prop : childModel.getOptionalVars()) { + if (prop.getBaseName().equals(discriminatorPropBaseName)) { + prop.defaultValue = discriminatorDefault; + prop.dataType = "kotlin.String"; + prop.datatypeWithEnum = "kotlin.String"; + prop.required = true; + prop.isNullable = false; + prop.isInherited = true; + propToMove = prop; + break; + } + } + if (propToMove != null) { + childModel.getOptionalVars().remove(propToMove); + childModel.getRequiredVars().add(propToMove); + } + + for (CodegenProperty prop : childModel.getRequiredVars()) { + if (prop.getBaseName().equals(discriminatorPropBaseName)) { + prop.defaultValue = discriminatorDefault; + prop.dataType = "kotlin.String"; + prop.datatypeWithEnum = "kotlin.String"; + prop.isNullable = false; + prop.isInherited = true; + } + } + + if (!hasDiscriminatorProp) { + CodegenProperty discriminatorProp = new CodegenProperty(); + discriminatorProp.baseName = discriminatorPropBaseName; + discriminatorProp.name = discriminatorVarName; + discriminatorProp.dataType = "kotlin.String"; + discriminatorProp.datatypeWithEnum = "kotlin.String"; + discriminatorProp.defaultValue = discriminatorDefault; + discriminatorProp.required = true; + discriminatorProp.isNullable = false; + discriminatorProp.isReadOnly = false; + discriminatorProp.isInherited = true; + + childModel.getVars().add(discriminatorProp); + childModel.getRequiredVars().add(discriminatorProp); + childModel.getAllVars().add(discriminatorProp); + } + + childModel.getVendorExtensions().put("x-parent-ctor-args", + discriminatorVarName + " = " + discriminatorVarName); + } + } + } + } else { + visibleTrue = hasParentProperties; + } + + owner.getVendorExtensions().put("x-discriminator-has-parent-properties", hasParentProperties); + owner.getDiscriminator().getVendorExtensions().put("x-discriminator-has-parent-properties", hasParentProperties); + owner.getVendorExtensions().put("x-discriminator-visible-true", visibleTrue); + owner.getDiscriminator().getVendorExtensions().put("x-discriminator-visible-true", visibleTrue); + } + } + + return objs; + } + @Override public void postProcessModelProperty(CodegenModel model, CodegenProperty property) { super.postProcessModelProperty(model, property); diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache index 178487a26e3b..f6758f6767e5 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache @@ -11,14 +11,23 @@ {{#vendorExtensions.x-class-extra-annotation}} {{{.}}} {{/vendorExtensions.x-class-extra-annotation}} -{{#discriminator}}interface {{classname}}{{/discriminator}}{{^discriminator}}{{#hasVars}}data {{/hasVars}}class {{classname}}( +{{#discriminator}}{{#useSealedDiscriminatorClasses}}{{! sealed class for discriminator models when opt-in flag is enabled +}}{{#vendorExtensions.x-discriminator-has-parent-properties}}sealed class {{classname}}( +{{#requiredVars}} +{{>dataClassSealedVar}}{{^-last}}, +{{/-last}}{{/requiredVars}}{{#hasRequired}}{{#hasOptional}}, +{{/hasOptional}}{{/hasRequired}}{{#optionalVars}}{{>dataClassSealedVar}}{{^-last}}, +{{/-last}}{{/optionalVars}} +){{/vendorExtensions.x-discriminator-has-parent-properties}}{{! no newline +}}{{^vendorExtensions.x-discriminator-has-parent-properties}}sealed class {{classname}}{{/vendorExtensions.x-discriminator-has-parent-properties}}{{! no newline +}}{{/useSealedDiscriminatorClasses}}{{^useSealedDiscriminatorClasses}}interface {{classname}}{{/useSealedDiscriminatorClasses}}{{/discriminator}}{{^discriminator}}{{#hasVars}}data {{/hasVars}}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}} : {{{.}}}{{#isMap}}(){{/isMap}}{{^isMap}}{{#vendorExtensions.x-parent-ctor-args}}({{{vendorExtensions.x-parent-ctor-args}}}){{/vendorExtensions.x-parent-ctor-args}}{{/isMap}}{{! 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 @@ -33,12 +42,14 @@ }}{{/vendorExtensions.x-implements-sealed-interfaces}}{{! no newline }}{{/parent}} { {{#discriminator}} +{{^useSealedDiscriminatorClasses}} {{#requiredVars}} {{>interfaceReqVar}}{{! prevent indent}} {{/requiredVars}} {{#optionalVars}} {{>interfaceOptVar}}{{! prevent indent}} {{/optionalVars}} +{{/useSealedDiscriminatorClasses}} {{/discriminator}} {{#hasEnums}}{{#vars}}{{#isEnum}} /** diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/dataClassSealedVar.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/dataClassSealedVar.mustache new file mode 100644 index 000000000000..201d4dc91a78 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/dataClassSealedVar.mustache @@ -0,0 +1,5 @@ +{{#useBeanValidation}}{{>beanValidation}}{{>beanValidationModel}}{{/useBeanValidation}}{{#swagger2AnnotationLibrary}} + @get:Schema({{#example}}example = "{{#lambdaRemoveLineBreak}}{{#lambdaEscapeInNormalString}}{{{.}}}{{/lambdaEscapeInNormalString}}{{/lambdaRemoveLineBreak}}", {{/example}}{{#required}}requiredMode = Schema.RequiredMode.REQUIRED, {{/required}}{{#isReadOnly}}readOnly = {{{isReadOnly}}}, {{/isReadOnly}}description = "{{{description}}}"){{/swagger2AnnotationLibrary}}{{#swagger1AnnotationLibrary}} + @get:ApiModelProperty({{#example}}example = "{{#lambdaRemoveLineBreak}}{{#lambdaEscapeInNormalString}}{{{.}}}{{/lambdaEscapeInNormalString}}{{/lambdaRemoveLineBreak}}", {{/example}}{{#required}}required = {{required}}, {{/required}}{{#isReadOnly}}readOnly = {{{isReadOnly}}}, {{/isReadOnly}}value = "{{{description}}}"){{/swagger1AnnotationLibrary}}{{#vendorExtensions.x-field-extra-annotation}} + {{{.}}}{{/vendorExtensions.x-field-extra-annotation}} + @get:JsonProperty("{{{baseName}}}") {{#isInherited}}override{{/isInherited}}{{^isInherited}}open{{/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}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/typeInfoAnnotation.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/typeInfoAnnotation.mustache index b459faf88aa6..9b1ba001cab6 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/typeInfoAnnotation.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/typeInfoAnnotation.mustache @@ -3,7 +3,7 @@ value = ["{{{discriminator.propertyBaseName}}}"], // ignore manually set {{{discriminator.propertyBaseName}}}, it will be automatically generated by Jackson during serialization allowSetters = true // allows the {{{discriminator.propertyBaseName}}} to be set during deserialization ) -@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "{{{discriminator.propertyBaseName}}}", visible = true) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "{{{discriminator.propertyBaseName}}}", visible = {{#useSealedDiscriminatorClasses}}{{#vendorExtensions.x-discriminator-visible-true}}true{{/vendorExtensions.x-discriminator-visible-true}}{{^vendorExtensions.x-discriminator-visible-true}}false{{/vendorExtensions.x-discriminator-visible-true}}{{/useSealedDiscriminatorClasses}}{{^useSealedDiscriminatorClasses}}true{{/useSealedDiscriminatorClasses}}) @JsonSubTypes( {{#discriminator.mappedModels}} JsonSubTypes.Type(value = {{modelName}}::class, name = "{{^vendorExtensions.x-discriminator-value}}{{mappingName}}{{/vendorExtensions.x-discriminator-value}}{{#vendorExtensions.x-discriminator-value}}{{{vendorExtensions.x-discriminator-value}}}{{/vendorExtensions.x-discriminator-value}}"){{^-last}},{{/-last}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java index dc250555135b..3211e5fcce8e 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java @@ -1520,8 +1520,6 @@ public void generateNonSerializableModelWithXimplements() throws Exception { path, "import java.io.Serializable", "Serializable", - ") : Pet, java.io.Serializable, com.some.pack.Fetchable {", - ") : Pet, java.io.Serializable {", "private const val serialVersionUID: kotlin.Long = 1" ); } @@ -4962,6 +4960,194 @@ public void shouldDefaultToJackson3WhenSpringBoot4EnabledViaSetter() throws IOEx assertFileNotContains(pomPath, "com.fasterxml.jackson.module"); assertFileNotContains(pomPath, "jackson-datatype-jsr310"); } + + // ==================== Polymorphism and Discriminator Tests ==================== + + @Test + public void oneOfWithDiscriminator_generatesSealedClassWithDiscriminatorProperty() throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + + KotlinSpringServerCodegen codegen = new KotlinSpringServerCodegen(); + codegen.setOutputDir(output.getAbsolutePath()); + codegen.additionalProperties().put(USE_SEALED_DISCRIMINATOR_CLASSES, true); + + new DefaultGenerator().opts(new ClientOptInput() + .openAPI(TestUtils.parseSpec("src/test/resources/3_1/polymorphism-and-discriminator.yaml")) + .config(codegen)) + .generate(); + + String outputPath = output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/model"; + Path petModel = Paths.get(outputPath + "/Pet.kt"); + + assertFileContains( + petModel, + "sealed class Pet(", + "open val petType: kotlin.String", + "@JsonTypeInfo", + "property = \"petType\"", + "visible = true", + "@JsonSubTypes" + ); + } + + @Test + public void oneOfWithDiscriminator_generatesChildrenWithOverrideDiscriminatorProperty() throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + + KotlinSpringServerCodegen codegen = new KotlinSpringServerCodegen(); + codegen.setOutputDir(output.getAbsolutePath()); + codegen.additionalProperties().put(USE_SEALED_DISCRIMINATOR_CLASSES, true); + + new DefaultGenerator().opts(new ClientOptInput() + .openAPI(TestUtils.parseSpec("src/test/resources/3_1/polymorphism-and-discriminator.yaml")) + .config(codegen)) + .generate(); + + String outputPath = output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/model"; + + Path catModel = Paths.get(outputPath + "/Cat.kt"); + assertFileContains( + catModel, + "data class Cat(", + "override val petType: kotlin.String = \"cat\"", + ") : Pet(petType = petType)" + ); + assertFileNotContains( + catModel, + "petType: kotlin.String?", + "petType: kotlin.Any" + ); + + Path dogModel = Paths.get(outputPath + "/Dog.kt"); + assertFileContains( + dogModel, + "data class Dog(", + "override val petType: kotlin.String = \"dog\"", + ") : Pet(petType = petType)" + ); + assertFileNotContains( + dogModel, + "petType: kotlin.String?", + "petType: kotlin.Any" + ); + } + + @Test + public void allOfWithDiscriminator_generatesSealedClassWithProperties() throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + + KotlinSpringServerCodegen codegen = new KotlinSpringServerCodegen(); + codegen.setOutputDir(output.getAbsolutePath()); + codegen.additionalProperties().put(USE_SEALED_DISCRIMINATOR_CLASSES, true); + + new DefaultGenerator().opts(new ClientOptInput() + .openAPI(TestUtils.parseSpec("src/test/resources/3_1/polymorphism-allof-and-discriminator.yaml")) + .config(codegen)) + .generate(); + + String outputPath = output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/model"; + Path petModel = Paths.get(outputPath + "/Pet.kt"); + + assertFileContains( + petModel, + "sealed class Pet(", + "open val name: kotlin.String", + "open val petType: kotlin.String", + "@JsonTypeInfo", + "visible = true" + ); + } + + @Test + public void allOfWithDiscriminator_generatesChildrenWithOverrideProperties() throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + + KotlinSpringServerCodegen codegen = new KotlinSpringServerCodegen(); + codegen.setOutputDir(output.getAbsolutePath()); + codegen.additionalProperties().put(USE_SEALED_DISCRIMINATOR_CLASSES, true); + + new DefaultGenerator().opts(new ClientOptInput() + .openAPI(TestUtils.parseSpec("src/test/resources/3_1/polymorphism-allof-and-discriminator.yaml")) + .config(codegen)) + .generate(); + + String outputPath = output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/model"; + + Path catModel = Paths.get(outputPath + "/Cat.kt"); + assertFileContains( + catModel, + "data class Cat(", + "override val name: kotlin.String", + "override val petType: kotlin.String", + ") : Pet(name = name, petType = petType)" + ); + + Path dogModel = Paths.get(outputPath + "/Dog.kt"); + assertFileContains( + dogModel, + "data class Dog(", + "override val name: kotlin.String", + "override val petType: kotlin.String", + ") : Pet(name = name, petType = petType)" + ); + } + + @Test + public void polymorphismWithoutDiscriminator_generatesRegularDataClass() throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + + KotlinSpringServerCodegen codegen = new KotlinSpringServerCodegen(); + codegen.setOutputDir(output.getAbsolutePath()); + codegen.additionalProperties().put(USE_SEALED_DISCRIMINATOR_CLASSES, true); + + new DefaultGenerator().opts(new ClientOptInput() + .openAPI(TestUtils.parseSpec("src/test/resources/3_1/polymorphism.yaml")) + .config(codegen)) + .generate(); + + String outputPath = output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/model"; + Path petModel = Paths.get(outputPath + "/Pet.kt"); + + assertFileContains( + petModel, + "data class Pet(" + ); + assertFileNotContains( + petModel, + "sealed class", + "@JsonTypeInfo", + "@JsonSubTypes" + ); + } + + @Test + public void fixJacksonJsonTypeInfoInheritance_canBeDisabled() throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + + KotlinSpringServerCodegen codegen = new KotlinSpringServerCodegen(); + codegen.setOutputDir(output.getAbsolutePath()); + codegen.additionalProperties().put(USE_SEALED_DISCRIMINATOR_CLASSES, true); + codegen.additionalProperties().put(FIX_JACKSON_JSON_TYPE_INFO_INHERITANCE, false); + + new DefaultGenerator().opts(new ClientOptInput() + .openAPI(TestUtils.parseSpec("src/test/resources/3_1/polymorphism-and-discriminator.yaml")) + .config(codegen)) + .generate(); + + String outputPath = output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/model"; + Path petModel = Paths.get(outputPath + "/Pet.kt"); + + assertFileContains( + petModel, + "visible = false" + ); + } } From cb7a9641d7a7258e1d35ae0d985e72eb41d84297 Mon Sep 17 00:00:00 2001 From: Roemer Vlasveld Date: Tue, 31 Mar 2026 16:48:17 +0200 Subject: [PATCH 2/2] Fix review issues: parent constructor call and hasVars/hasRequired flags Address two issues identified by cubic code review: 1. Always emit parentheses in parent constructor call when `useSealedDiscriminatorClasses` is enabled, even when `x-parent-ctor-args` is absent. This prevents invalid Kotlin like `: Parent` when the parent is a sealed class (should be `: Parent()`). 2. Update `hasVars` and `hasRequired` flags on child models after adding discriminator properties in the oneOf pattern. Without this, a child with no other properties would generate `class Cat(...)` instead of `data class Cat(...)`. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../codegen/languages/KotlinSpringServerCodegen.java | 4 ++++ .../src/main/resources/kotlin-spring/dataClass.mustache | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java index e67dba5a6aa8..282d7f63142b 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinSpringServerCodegen.java @@ -1394,6 +1394,10 @@ public Map postProcessAllModels(Map objs) childModel.getAllVars().add(discriminatorProp); } + // Update model flags after adding discriminator property + childModel.setHasVars(true); + childModel.setHasRequired(true); + childModel.getVendorExtensions().put("x-parent-ctor-args", discriminatorVarName + " = " + discriminatorVarName); } diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache index f6758f6767e5..5e1655cfd7b7 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/dataClass.mustache @@ -27,7 +27,7 @@ {{/hasOptional}}{{/hasRequired}}{{#optionalVars}}{{>dataClassOptVar}}{{^-last}}, {{/-last}}{{/optionalVars}} ){{/discriminator}}{{! no newline -}}{{#parent}} : {{{.}}}{{#isMap}}(){{/isMap}}{{^isMap}}{{#vendorExtensions.x-parent-ctor-args}}({{{vendorExtensions.x-parent-ctor-args}}}){{/vendorExtensions.x-parent-ctor-args}}{{/isMap}}{{! no newline +}}{{#parent}} : {{{.}}}{{#isMap}}(){{/isMap}}{{^isMap}}{{#useSealedDiscriminatorClasses}}({{#vendorExtensions.x-parent-ctor-args}}{{{vendorExtensions.x-parent-ctor-args}}}{{/vendorExtensions.x-parent-ctor-args}}){{/useSealedDiscriminatorClasses}}{{/isMap}}{{! 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