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..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 @@ -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) { + // 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/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 95bfa644bd4f..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 @@ -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(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(dogModel, "name"); + } + + private static class ModelNameTest { private final String expectedName; private final String expectedClassName; @@ -1098,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"}, + }; + } + } 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'