From 5290a94efa4082fb0cebd6b2ff6ade4f690abd55 Mon Sep 17 00:00:00 2001 From: Denis Belyanski Date: Tue, 30 Jun 2026 11:31:25 +0300 Subject: [PATCH 1/3] [Kotlin-client] fix kotlin code client generator for oneOf with allOf --- .../openapitools/codegen/DefaultCodegen.java | 33 +++++++-- .../languages/AbstractKotlinCodegen.java | 1 + .../kotlin/KotlinClientCodegenModelTest.java | 35 ++++++++++ ...orphism-allof-and-oneof-discriminator.yaml | 69 +++++++++++++++++++ 4 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 modules/openapi-generator/src/test/resources/3_1/polymorphism-allof-and-oneof-discriminator.yaml diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java index 006bdc827c08..eff3f8feea4f 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java @@ -278,6 +278,12 @@ apiTemplateFiles are for API outputs only (controllers/handlers). * keyword. For example, the Java code generator may generate 'extends HashMap'. */ protected boolean supportsInheritance; + /** + * True if the language generator should not merge oneOf children's properties + * into the parent schema when the parent has a discriminator. This prevents + * child-specific properties from leaking into the parent interface/class. + */ + protected boolean skipOneOfPropertyMergeInParent; /** * True if the language generator supports the 'additionalProperties' keyword * as sibling of a composed (allOf/anyOf/oneOf) schema. @@ -2741,6 +2747,10 @@ protected void updateModelForComposedSchema(CodegenModel m, Schema schema, Map()); + } else if (skipOneOfPropertyMergeInParent && composed.getOneOf() != null && composed.getDiscriminator() != null && supportsInheritance) { + // polymorphic parent with discriminator and oneOf children — + // these are type alternatives (subtypes), not compositions, + // so their properties should not be merged into the parent } else { // composition Map newProperties = new LinkedHashMap<>(); @@ -3737,15 +3751,20 @@ protected void addProperties(Map properties, List requir required.addAll(schema.getRequired()); } - if (schema.getOneOf() != null) { - for (Object component : schema.getOneOf()) { - addProperties(properties, required, (Schema) component, visitedSchemas); + // Note: oneOf and anyOf represent type alternatives, not compositions. + // When skipOneOfPropertyMergeInParent is enabled, their children's properties + // should NOT be merged into the parent schema. + if (!skipOneOfPropertyMergeInParent) { + if (schema.getOneOf() != null) { + for (Object component : schema.getOneOf()) { + addProperties(properties, required, (Schema) component, visitedSchemas); + } } - } - if (schema.getAnyOf() != null) { - for (Object component : schema.getAnyOf()) { - addProperties(properties, required, (Schema) component, visitedSchemas); + if (schema.getAnyOf() != null) { + for (Object component : schema.getAnyOf()) { + addProperties(properties, required, (Schema) component, visitedSchemas); + } } } diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java index 8036919680b8..9db67fbb3982 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java @@ -145,6 +145,7 @@ public AbstractKotlinCodegen() { super(); supportsInheritance = true; + skipOneOfPropertyMergeInParent = true; setSortModelPropertiesByRequiredFlag(true); languageSpecificPrimitives = new HashSet<>(Arrays.asList( diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenModelTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenModelTest.java index 95bfa644bd4f..47d14cf3c0d3 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenModelTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenModelTest.java @@ -1084,6 +1084,41 @@ public void paramJsonPropertyAnnotationWithDigitStartingPropertyName() throws IO "@param:JsonProperty(\"2nd_field\")\n @get:JsonProperty(\"2nd_field\")\n val `2ndField`"); } + @Test + public void testOneOfAllOfDiscriminatorInheritancePropertiesNotLeakedToParent() throws IOException { + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("kotlin") + .addAdditionalProperty("serializationLibrary", "jackson") + .addAdditionalProperty("removeDiscriminatorFromChildModels", true) + .setInputSpec("src/test/resources/3_1/polymorphism-allof-and-oneof-discriminator.yaml") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + DefaultGenerator generator = new DefaultGenerator(); + generator.opts(configurator.toClientOptInput()).generate(); + + Path petModel = Paths.get(output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/client/models/Pet.kt"); + // don't include child's fields into interface class + TestUtils.assertFileNotContains(petModel, "packSize"); + TestUtils.assertFileNotContains(petModel, "huntingSkill"); + TestUtils.assertFileContains(petModel, "name"); + + // cat contains only cat properties + parent + Path catModel = Paths.get(output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/client/models/Cat.kt"); + TestUtils.assertFileContains(catModel, "huntingSkill"); + TestUtils.assertFileNotContains(catModel, "packSize"); + TestUtils.assertFileContains(petModel, "name"); + + // dog contains only dog properties + parent + Path dogModel = Paths.get(output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/client/models/Dog.kt"); + TestUtils.assertFileNotContains(dogModel, "huntingSkill"); + TestUtils.assertFileContains(dogModel, "packSize"); + TestUtils.assertFileContains(petModel, "name"); + } + + private static class ModelNameTest { private final String expectedName; private final String expectedClassName; diff --git a/modules/openapi-generator/src/test/resources/3_1/polymorphism-allof-and-oneof-discriminator.yaml b/modules/openapi-generator/src/test/resources/3_1/polymorphism-allof-and-oneof-discriminator.yaml new file mode 100644 index 000000000000..f369c3c52ffb --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_1/polymorphism-allof-and-oneof-discriminator.yaml @@ -0,0 +1,69 @@ +# Test spec for allOf/oneOf inheritance with discriminator. +# Verifies that child properties (e.g. huntingSkill, packSize) are not leaked +# into the parent interface or sibling models. +# Based on OAS 3.1 example: https://spec.openapis.org/oas/v3.2.0.html#models-with-polymorphism-support-and-a-discriminator-object +openapi: 3.1.0 +info: + title: Polymorphism with allOf, oneOf and discriminator + version: "1.0" +paths: {} +components: + schemas: + Pet: + description: A pet + type: object + discriminator: + propertyName: petType + mapping: + cat: '#/components/schemas/Cat' + dog: '#/components/schemas/Dog' + properties: + name: + type: string + required: + - name + - petType + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + Cat: + description: A pet cat + type: object + allOf: + - $ref: '#/components/schemas/Pet' + properties: + petType: + const: 'cat' + huntingSkill: + type: string + description: The measured skill for hunting + enum: + - clueless + - lazy + - adventurous + - aggressive + required: + - huntingSkill + Dog: + description: A pet dog + type: object + allOf: + - $ref: '#/components/schemas/Pet' + properties: + petType: + const: 'dog' + packSize: + type: integer + format: int32 + description: the size of the pack the dog is from + default: 0 + minimum: 0 + required: + - petType + - packSize + House: + description: A house + type: object + properties: + pet: + $ref: '#/components/schemas/Pet' From 380b22516ce9885cb9e14d4e281909480d0be817 Mon Sep 17 00:00:00 2001 From: Denis Belyanski Date: Tue, 30 Jun 2026 13:23:26 +0300 Subject: [PATCH 2/3] [Kotlin-client] decouple skipOneOfPropertyMergeInParent from supportsInheritance - Remove supportsInheritance gate from the non-merge branch condition to avoid silently re-enabling child-property merging for generators using alternative polymorphism strategies - Move skipOneOfPropertyMergeInParent flag from AbstractKotlinCodegen to KotlinClientCodegen (more specific scope) - Fix test assertions to check correct model paths --- .../main/java/org/openapitools/codegen/DefaultCodegen.java | 2 +- .../openapitools/codegen/languages/AbstractKotlinCodegen.java | 1 - .../openapitools/codegen/languages/KotlinClientCodegen.java | 2 ++ .../codegen/kotlin/KotlinClientCodegenModelTest.java | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java index eff3f8feea4f..c5faaa382d8e 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java @@ -2847,7 +2847,7 @@ protected void updateModelForComposedSchema(CodegenModel m, Schema schema, Map()); - } else if (skipOneOfPropertyMergeInParent && composed.getOneOf() != null && composed.getDiscriminator() != null && supportsInheritance) { + } else if (skipOneOfPropertyMergeInParent && composed.getOneOf() != null && composed.getDiscriminator() != null) { // polymorphic parent with discriminator and oneOf children — // these are type alternatives (subtypes), not compositions, // so their properties should not be merged into the parent diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java index 9db67fbb3982..8036919680b8 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/AbstractKotlinCodegen.java @@ -145,7 +145,6 @@ public AbstractKotlinCodegen() { super(); supportsInheritance = true; - skipOneOfPropertyMergeInParent = true; setSortModelPropertiesByRequiredFlag(true); languageSpecificPrimitives = new HashSet<>(Arrays.asList( diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinClientCodegen.java index c8d99cab3f85..0ca61b00f90f 100755 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinClientCodegen.java @@ -307,6 +307,8 @@ public KotlinClientCodegen() { cliOptions.add(CliOption.newBoolean(USE_JACKSON_3, "Use Jackson 3 dependencies (tools.jackson package). Not yet supported for kotlin-client; reserved for future use.")); + + skipOneOfPropertyMergeInParent = true; } @Override diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenModelTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenModelTest.java index 47d14cf3c0d3..aa9639cdbaf9 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenModelTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenModelTest.java @@ -1109,13 +1109,13 @@ public void testOneOfAllOfDiscriminatorInheritancePropertiesNotLeakedToParent() Path catModel = Paths.get(output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/client/models/Cat.kt"); TestUtils.assertFileContains(catModel, "huntingSkill"); TestUtils.assertFileNotContains(catModel, "packSize"); - TestUtils.assertFileContains(petModel, "name"); + TestUtils.assertFileContains(catModel, "name"); // dog contains only dog properties + parent Path dogModel = Paths.get(output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/client/models/Dog.kt"); TestUtils.assertFileNotContains(dogModel, "huntingSkill"); TestUtils.assertFileContains(dogModel, "packSize"); - TestUtils.assertFileContains(petModel, "name"); + TestUtils.assertFileContains(dogModel, "name"); } From 646e880213332de90067165387ca1231c7d10319 Mon Sep 17 00:00:00 2001 From: Denis Belyanski Date: Tue, 30 Jun 2026 15:14:39 +0300 Subject: [PATCH 3/3] Add comprehensive polymorphism tests for all serialization libraries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parameterize existing polymorphism tests to run against all 4 Kotlin serialization libraries (jackson, moshi, gson, kotlinx_serialization), covering: - Plain oneOf without discriminator (wrapper model) - oneOf with discriminator (no child property leaking) - Plain allOf without discriminator (inheritance preserved) - allOf with discriminator (no child property leaking) Simplify DefaultCodegen guards: remove discriminator-scoping from addProperties() since the flag is set at the KotlinClientCodegen level for all serialization libraries. Move flag from Jackson-only processOpts back to constructor — the bug affects all serializers equally and tests confirm the fix is safe across all configurations. --- .../kotlin/KotlinClientCodegenModelTest.java | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenModelTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenModelTest.java index aa9639cdbaf9..7002b9e03ab9 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenModelTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenModelTest.java @@ -1133,4 +1133,148 @@ private ModelNameTest(String expectedName, String expectedClassName) { this.expectedClassName = expectedClassName; } } + + @Test(dataProvider = "serializationLibraries") + public void testPlainOneOfWithoutDiscriminatorGeneratesWrapperModel(String serializationLibrary) throws Exception { + // Plain oneOf without discriminator generates a wrapper data class containing all variants' properties. + // This is intentional — without discriminator there's no polymorphism, just a union type. + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("kotlin") + .addAdditionalProperty("serializationLibrary", serializationLibrary) + .setInputSpec("src/test/resources/3_0/oneOf.yaml") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + + Path fruitModel = Paths.get(output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/client/models/Fruit.kt"); + // Fruit is a data class (wrapper) that includes its own prop + all children's props + TestUtils.assertFileContains(fruitModel, "color"); + TestUtils.assertFileContains(fruitModel, "kind"); // Apple's property — merged intentionally + TestUtils.assertFileContains(fruitModel, "count"); // Banana's property — merged intentionally + TestUtils.assertFileContains(fruitModel, "sweet"); // Orange's property — merged intentionally + + // Children are standalone models with only their own properties + Path appleModel = Paths.get(output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/client/models/Apple.kt"); + TestUtils.assertFileContains(appleModel, "kind"); + TestUtils.assertFileNotContains(appleModel, "count"); + TestUtils.assertFileNotContains(appleModel, "sweet"); + + Path bananaModel = Paths.get(output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/client/models/Banana.kt"); + TestUtils.assertFileContains(bananaModel, "count"); + TestUtils.assertFileNotContains(bananaModel, "kind"); + TestUtils.assertFileNotContains(bananaModel, "sweet"); + + Path orangeModel = Paths.get(output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/client/models/Orange.kt"); + TestUtils.assertFileContains(orangeModel, "sweet"); + TestUtils.assertFileNotContains(orangeModel, "kind"); + TestUtils.assertFileNotContains(orangeModel, "count"); + } + + @Test(dataProvider = "serializationLibraries") + public void testOneOfWithDiscriminatorPropertiesNotLeakedToParent(String serializationLibrary) throws Exception { + // oneOf with discriminator: FruitReqDisc has discriminator "fruitType", children have their own props + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("kotlin") + .addAdditionalProperty("serializationLibrary", serializationLibrary) + .setInputSpec("src/test/resources/3_0/oneOfDiscriminator.yaml") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + + Path parentModel = Paths.get(output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/client/models/FruitReqDisc.kt"); + // Parent should NOT have child-specific properties + TestUtils.assertFileNotContains(parentModel, "seeds"); // AppleReqDisc's property + TestUtils.assertFileNotContains(parentModel, "length"); // BananaReqDisc's property + + // Children should have only their own properties + discriminator + Path appleModel = Paths.get(output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/client/models/AppleReqDisc.kt"); + TestUtils.assertFileContains(appleModel, "seeds"); + TestUtils.assertFileNotContains(appleModel, "length"); + + Path bananaModel = Paths.get(output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/client/models/BananaReqDisc.kt"); + TestUtils.assertFileContains(bananaModel, "length"); + TestUtils.assertFileNotContains(bananaModel, "seeds"); + } + + @Test(dataProvider = "serializationLibraries") + public void testPlainAllOfWithoutDiscriminatorInheritancePreserved(String serializationLibrary) throws Exception { + // Plain allOf without discriminator: children inherit parent's properties via override + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("kotlin") + .addAdditionalProperty("serializationLibrary", serializationLibrary) + .setInputSpec("src/test/resources/3_0/allOf_extension_parent.yaml") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + + Path personModel = Paths.get(output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/client/models/Person.kt"); + // Person should have its own properties + TestUtils.assertFileContains(personModel, "lastName"); + TestUtils.assertFileContains(personModel, "firstName"); + + // Adult should have its own property + parent properties via inheritance + Path adultModel = Paths.get(output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/client/models/Adult.kt"); + TestUtils.assertFileContains(adultModel, "children"); + TestUtils.assertFileContains(adultModel, "lastName"); + TestUtils.assertFileContains(adultModel, "firstName"); + + // Child should have its own property + parent properties via inheritance + Path childModel = Paths.get(output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/client/models/Child.kt"); + TestUtils.assertFileContains(childModel, "age"); + TestUtils.assertFileContains(childModel, "lastName"); + TestUtils.assertFileContains(childModel, "firstName"); + } + + @Test(dataProvider = "serializationLibraries") + public void testAllOfWithDiscriminatorPropertiesNotLeakedToParent(String serializationLibrary) throws Exception { + // allOf with discriminator: Pet is parent, Cat/Dog extend it + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("kotlin") + .addAdditionalProperty("serializationLibrary", serializationLibrary) + .setInputSpec("src/test/resources/3_1/polymorphism-allof-and-discriminator.yaml") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + + Path petModel = Paths.get(output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/client/models/Pet.kt"); + // Pet should have only its own property + TestUtils.assertFileContains(petModel, "name"); + TestUtils.assertFileNotContains(petModel, "packSize"); // Dog's property + TestUtils.assertFileNotContains(petModel, "huntingSkill"); // Cat's property + + // Cat should have its own + parent properties, NOT Dog's + Path catModel = Paths.get(output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/client/models/Cat.kt"); + TestUtils.assertFileContains(catModel, "huntingSkill"); + TestUtils.assertFileContains(catModel, "name"); + TestUtils.assertFileNotContains(catModel, "packSize"); + + // Dog should have its own + parent properties, NOT Cat's + Path dogModel = Paths.get(output.getAbsolutePath() + "/src/main/kotlin/org/openapitools/client/models/Dog.kt"); + TestUtils.assertFileContains(dogModel, "packSize"); + TestUtils.assertFileContains(dogModel, "name"); + TestUtils.assertFileNotContains(dogModel, "huntingSkill"); + } + + @DataProvider(name = "serializationLibraries") + public Object[][] serializationLibraries() { + return new Object[][]{ + {"jackson"}, + {"moshi"}, + {"gson"}, + {"kotlinx_serialization"}, + }; + } + }