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..8e688ecff4c3 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<>(); @@ -3706,7 +3720,10 @@ protected void addAdditionPropertiesToCodeGenModel(CodegenModel codegenModel, Sc } /** - * Add schema's properties to "properties" and "required" list + * Add schema's properties to "properties" and "required" list. + * + *

oneOf/anyOf sub-schema properties are merged into the parent only when + * {@code skipOneOfPropertyMergeInParent} is disabled (model generation default). * * @param properties all properties * @param required required property only @@ -3714,6 +3731,22 @@ protected void addAdditionPropertiesToCodeGenModel(CodegenModel codegenModel, Sc * @param visitedSchemas circuit-breaker - the schemas with which the method was called before for recursive structures */ protected void addProperties(Map properties, List required, Schema schema, Set visitedSchemas) { + addProperties(properties, required, schema, visitedSchemas, false); + } + + /** + * Add schema's properties to "properties" and "required" list. + * + * @param properties all properties + * @param required required property only + * @param schema schema in which the properties will be added to the lists + * @param visitedSchemas circuit-breaker - the schemas with which the method was called before for recursive structures + * @param flattenComposedSchemas when true, oneOf/anyOf sub-schema properties are always flattened into the + * parent regardless of {@code skipOneOfPropertyMergeInParent}. This is required + * for form-data parameters, which need an individual field per alternative, + * unlike model definitions where alternatives are represented as separate types. + */ + protected void addProperties(Map properties, List required, Schema schema, Set visitedSchemas, boolean flattenComposedSchemas) { if (schema == null) { return; } @@ -3729,7 +3762,7 @@ protected void addProperties(Map properties, List requir if (schema.getAllOf() != null) { for (Object component : schema.getAllOf()) { - addProperties(properties, required, (Schema) component, visitedSchemas); + addProperties(properties, required, (Schema) component, visitedSchemas, flattenComposedSchemas); } } @@ -3737,15 +3770,21 @@ 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 - unless flattenComposedSchemas is + // requested (e.g. for form-data parameters, which need one field per alternative). + if (flattenComposedSchemas || !skipOneOfPropertyMergeInParent) { + if (schema.getOneOf() != null) { + for (Object component : schema.getOneOf()) { + addProperties(properties, required, (Schema) component, visitedSchemas, flattenComposedSchemas); + } } - } - 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, flattenComposedSchemas); + } } } @@ -3754,7 +3793,7 @@ protected void addProperties(Map properties, List requir if (StringUtils.isNotBlank(schema.get$ref())) { Schema interfaceSchema = ModelUtils.getReferencedSchema(this.openAPI, schema); - addProperties(properties, required, interfaceSchema, visitedSchemas); + addProperties(properties, required, interfaceSchema, visitedSchemas, flattenComposedSchemas); return; } if (schema.getProperties() != null) { @@ -7444,9 +7483,11 @@ public List fromRequestBodyToFormParameters(RequestBody body, // TODO in the future have this return one codegenParameter of type object or composed which includes all definition // that will be needed for complex composition use cases // https://github.com/OpenAPITools/openapi-generator/issues/10415 - addProperties(properties, allRequired, schema, new HashSet<>()); + // For form data, always flatten oneOf/anyOf alternatives into individual fields + // regardless of skipOneOfPropertyMergeInParent (which only applies to model generation). boolean isOneOfOrAnyOf = ModelUtils.isOneOf(schema) || ModelUtils.isAnyOf(schema); + addProperties(properties, allRequired, schema, new HashSet<>(), isOneOfOrAnyOf); if (!properties.isEmpty()) { for (Map.Entry entry : properties.entrySet()) { 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/main/java/org/openapitools/codegen/languages/ProtobufSchemaCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ProtobufSchemaCodegen.java index dac8e52cfcd5..f093394abe70 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ProtobufSchemaCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ProtobufSchemaCodegen.java @@ -1955,8 +1955,8 @@ public GeneratorLanguage generatorLanguage() { * In this case, the second allOf that defines a map with string keys and Pet values will be part of model's property. */ @Override - protected void addProperties(Map properties, List required, Schema schema, Set visitedSchemas){ - super.addProperties(properties, required, schema, visitedSchemas); + protected void addProperties(Map properties, List required, Schema schema, Set visitedSchemas, boolean flattenComposedSchemas){ + super.addProperties(properties, required, schema, visitedSchemas, flattenComposedSchemas); if(schema.getAdditionalProperties() != null) { String addtionalPropertiesName = "default_map"; if(schema.getTitle() != null) { 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' diff --git a/samples/client/others/kotlin-oneOf-discriminator-kotlinx-serialization/docs/Animal.md b/samples/client/others/kotlin-oneOf-discriminator-kotlinx-serialization/docs/Animal.md index aaffda15d819..f7f8dfd7e74e 100644 --- a/samples/client/others/kotlin-oneOf-discriminator-kotlinx-serialization/docs/Animal.md +++ b/samples/client/others/kotlin-oneOf-discriminator-kotlinx-serialization/docs/Animal.md @@ -4,11 +4,6 @@ ## Properties | Name | Type | Description | Notes | | ------------ | ------------- | ------------- | ------------- | -| **discriminator** | **kotlin.String** | | | -| **anotherDiscriminator** | **kotlin.String** | | | -| **propertyA** | **kotlin.String** | | [optional] | -| **sameNameProperty** | **kotlin.String** | | [optional] | -| **propertyB** | **kotlin.String** | | [optional] | diff --git a/samples/client/others/kotlin-oneOf-discriminator-kotlinx-serialization/docs/AnotherAnimal.md b/samples/client/others/kotlin-oneOf-discriminator-kotlinx-serialization/docs/AnotherAnimal.md index c5bc8ab96395..86b54cd3638d 100644 --- a/samples/client/others/kotlin-oneOf-discriminator-kotlinx-serialization/docs/AnotherAnimal.md +++ b/samples/client/others/kotlin-oneOf-discriminator-kotlinx-serialization/docs/AnotherAnimal.md @@ -4,11 +4,6 @@ ## Properties | Name | Type | Description | Notes | | ------------ | ------------- | ------------- | ------------- | -| **discriminator** | **kotlin.String** | | | -| **anotherDiscriminator** | **kotlin.String** | | | -| **propertyA** | **kotlin.String** | | [optional] | -| **sameNameProperty** | **kotlin.String** | | [optional] | -| **propertyB** | **kotlin.String** | | [optional] | diff --git a/samples/client/petstore/typescript-fetch/builds/kebab-case/models/enum-test.ts b/samples/client/petstore/typescript-fetch/builds/kebab-case/models/enum-test.ts index d23b075ea7d2..4ea9062e1ed8 100644 --- a/samples/client/petstore/typescript-fetch/builds/kebab-case/models/enum-test.ts +++ b/samples/client/petstore/typescript-fetch/builds/kebab-case/models/enum-test.ts @@ -142,7 +142,7 @@ export type EnumTestEnumNumberEnum = typeof EnumTestEnumNumberEnum[keyof typeof * Check if a given object implements the EnumTest interface. */ export function instanceOfEnumTest(value: object): value is EnumTest { - if ((!('enumStringRequired' in value) && !('enum_string_required' in value)) || (value['enumStringRequired'] === undefined && value['enum_string_required'] === undefined)) return false; + if ((!('enumStringRequired' in (value as Record)) && !('enum_string_required' in (value as Record))) || ((value as Record)['enumStringRequired'] === undefined && (value as Record)['enum_string_required'] === undefined)) return false; return true; } diff --git a/samples/client/petstore/typescript-fetch/builds/kebab-case/models/format-test.ts b/samples/client/petstore/typescript-fetch/builds/kebab-case/models/format-test.ts index 822bb60636fb..c80896e71861 100644 --- a/samples/client/petstore/typescript-fetch/builds/kebab-case/models/format-test.ts +++ b/samples/client/petstore/typescript-fetch/builds/kebab-case/models/format-test.ts @@ -122,7 +122,7 @@ export interface FormatTest { */ export function instanceOfFormatTest(value: object): value is FormatTest { if (!('number' in value) || value['number'] === undefined) return false; - if ((!('_byte' in value) && !('byte' in value)) || (value['_byte'] === undefined && value['byte'] === undefined)) return false; + if ((!('_byte' in (value as Record)) && !('byte' in (value as Record))) || ((value as Record)['_byte'] === undefined && (value as Record)['byte'] === undefined)) return false; if (!('date' in value) || value['date'] === undefined) return false; if (!('password' in value) || value['password'] === undefined) return false; return true;