From ff5dc0ff8ff184540267169dc580cf26c0d83a7c Mon Sep 17 00:00:00 2001 From: DavidGrath Date: Wed, 27 Aug 2025 06:04:18 +0100 Subject: [PATCH 1/4] Models with `additionalProperties` and `properties` that are complex now generate correct code --- .../codegen/OpenAPINormalizer.java | 3 + .../codegen/DefaultCodegenTest.java | 56 +++++++++++++++++++ .../src/test/resources/3_1/issue_20213.yaml | 30 ++++++++++ 3 files changed, 89 insertions(+) create mode 100644 modules/openapi-generator/src/test/resources/3_1/issue_20213.yaml diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java index 7a95434248a5..bab3272ad30a 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java @@ -728,6 +728,9 @@ public Schema normalizeSchema(Schema schema, Set visitedSchemas) { } else if (schema.getAdditionalProperties() instanceof Schema) { // map normalizeMapSchema(schema); normalizeSchema((Schema) schema.getAdditionalProperties(), visitedSchemas); + if (schema.getProperties() != null && !schema.getProperties().isEmpty()) { + normalizeProperties(schema.getProperties(), visitedSchemas); + } } else if (ModelUtils.isOneOf(schema)) { // oneOf return normalizeOneOf(schema, visitedSchemas); } else if (ModelUtils.isAnyOf(schema)) { // anyOf diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java index ed4287ab0fc0..4d34e8197d37 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java @@ -40,6 +40,7 @@ import org.junit.jupiter.api.Assertions; import org.openapitools.codegen.config.CodegenConfigurator; import org.openapitools.codegen.config.GlobalSettings; +import org.openapitools.codegen.java.assertions.JavaFileAssert; import org.openapitools.codegen.languages.SpringCodegen; import org.openapitools.codegen.model.ModelMap; import org.openapitools.codegen.model.ModelsMap; @@ -55,6 +56,7 @@ import java.nio.file.Files; import java.util.*; import java.util.concurrent.*; +import java.util.function.Function; import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; @@ -5017,4 +5019,58 @@ public void testQueryIsJsonMimeType() { assertTrue(codegenOperation.queryParams.stream().allMatch(p -> p.queryIsJsonMimeType)); } + + @Test(description = "Issue #20213") + public void testModelAdditionalPropertiesWithNullableProperty() throws Exception { + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + File spring = new File(output, "spring"); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("spring") + .setInputSpec("src/test/resources/3_1/issue_20213.yaml") + .setOutputDir(spring.getAbsolutePath().replace("\\", "/")); + + final ClientOptInput clientOptInput = configurator.toClientOptInput(); + DefaultGenerator generator = new DefaultGenerator(); + Map files = generator.opts(clientOptInput).generate() + .stream().collect(Collectors.toMap(File::getName, Function.identity())); + + JavaFileAssert.assertThat(files.get("SampleObjectWithAdditionalFalse.java")) + .assertProperty("someString") + .withType("JsonNullable"); + assertFalse(files.containsKey("SampleObjectWithAdditionalFalseSomeString.java")); + + + File tsAngular = new File(output, "ts-angular"); + final CodegenConfigurator tsConfigurator = new CodegenConfigurator() + .setGeneratorName("typescript-angular") + .setInputSpec("src/test/resources/3_1/issue_20213.yaml") + .setOutputDir(tsAngular.getAbsolutePath().replace("\\", "/")); + + final ClientOptInput tsClientOptInput = tsConfigurator.toClientOptInput(); + DefaultGenerator tsGenerator = new DefaultGenerator(); + Map tsFiles = tsGenerator.opts(tsClientOptInput).generate() + .stream().collect(Collectors.toMap(File::getName, Function.identity())); + + System.out.println(Files.readString(tsFiles.get("sampleObjectWithAdditionalFalse.ts").toPath())); + TestUtils.assertFileContains(tsFiles.get("sampleObjectWithAdditionalFalse.ts").toPath(), "someString?: string"); + assertFalse(tsFiles.containsKey("sampleObjectWithAdditionalFalseSomeString.ts")); + + File javaClient = new File(output, "java"); + final CodegenConfigurator javaClientConfigurator = new CodegenConfigurator() + .setGeneratorName("java") + .setInputSpec("src/test/resources/3_1/issue_20213.yaml") + .setOutputDir(javaClient.getAbsolutePath().replace("\\", "/")); + + final ClientOptInput javaClientClientOptInput = javaClientConfigurator.toClientOptInput(); + DefaultGenerator javaClientGenerator = new DefaultGenerator(); + Map javaClientFiles = javaClientGenerator.opts(javaClientClientOptInput).generate() + .stream().collect(Collectors.toMap(File::getName, Function.identity())); + + JavaFileAssert.assertThat(javaClientFiles.get("SampleObjectWithAdditionalFalse.java")) + .assertProperty("someString") + .withType("String"); + assertFalse(javaClientFiles.containsKey("SampleObjectWithAdditionalFalseSomeString.java")); + } } diff --git a/modules/openapi-generator/src/test/resources/3_1/issue_20213.yaml b/modules/openapi-generator/src/test/resources/3_1/issue_20213.yaml new file mode 100644 index 000000000000..2726053d0a8b --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_1/issue_20213.yaml @@ -0,0 +1,30 @@ +# filename: nullable-properties-with-additional-properties-false.yaml +openapi: 3.1.0 +info: + title: "" + version: 1.0.0 +components: + schemas: + # For reference, an object without additionalProperties: false, but with nullable properties + SampleObject: + properties: + someString: + anyOf: + - type: string + - type: 'null' + + # For reference, an object with additionalProperties: false, but without any nullable properties + ReferenceObject: + additionalProperties: false + properties: + someString: + type: string + + # The broken case: an object with additionalProperties: false and nullable properties + SampleObjectWithAdditionalFalse: + additionalProperties: false + properties: + someString: + anyOf: + - type: string + - type: 'null' \ No newline at end of file From 4b8953caf28bb665247d7186db05dddcad65fb6a Mon Sep 17 00:00:00 2001 From: DavidGrath Date: Wed, 27 Aug 2025 17:03:35 +0100 Subject: [PATCH 2/4] Removed print statement --- .../test/java/org/openapitools/codegen/DefaultCodegenTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java index 4d34e8197d37..ec3ead0844e3 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java @@ -5053,7 +5053,6 @@ public void testModelAdditionalPropertiesWithNullableProperty() throws Exception Map tsFiles = tsGenerator.opts(tsClientOptInput).generate() .stream().collect(Collectors.toMap(File::getName, Function.identity())); - System.out.println(Files.readString(tsFiles.get("sampleObjectWithAdditionalFalse.ts").toPath())); TestUtils.assertFileContains(tsFiles.get("sampleObjectWithAdditionalFalse.ts").toPath(), "someString?: string"); assertFalse(tsFiles.containsKey("sampleObjectWithAdditionalFalseSomeString.ts")); From 4a9c7bbef7983cac0cc29bafadd0ea598ab99ab8 Mon Sep 17 00:00:00 2001 From: DavidGrath Date: Fri, 29 Aug 2025 04:25:56 +0100 Subject: [PATCH 3/4] Reworked tests for Issue #20213 --- .../codegen/DefaultCodegenTest.java | 53 ------------------- .../codegen/OpenAPINormalizerTest.java | 5 ++ .../codegen/java/JavaClientCodegenTest.java | 25 +++++++++ .../3_1/simplifyOneOfAnyOf_test.yaml | 12 ++++- 4 files changed, 41 insertions(+), 54 deletions(-) diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java index ec3ead0844e3..55be06da8003 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/DefaultCodegenTest.java @@ -5019,57 +5019,4 @@ public void testQueryIsJsonMimeType() { assertTrue(codegenOperation.queryParams.stream().allMatch(p -> p.queryIsJsonMimeType)); } - - @Test(description = "Issue #20213") - public void testModelAdditionalPropertiesWithNullableProperty() throws Exception { - File output = Files.createTempDirectory("test").toFile(); - output.deleteOnExit(); - File spring = new File(output, "spring"); - - final CodegenConfigurator configurator = new CodegenConfigurator() - .setGeneratorName("spring") - .setInputSpec("src/test/resources/3_1/issue_20213.yaml") - .setOutputDir(spring.getAbsolutePath().replace("\\", "/")); - - final ClientOptInput clientOptInput = configurator.toClientOptInput(); - DefaultGenerator generator = new DefaultGenerator(); - Map files = generator.opts(clientOptInput).generate() - .stream().collect(Collectors.toMap(File::getName, Function.identity())); - - JavaFileAssert.assertThat(files.get("SampleObjectWithAdditionalFalse.java")) - .assertProperty("someString") - .withType("JsonNullable"); - assertFalse(files.containsKey("SampleObjectWithAdditionalFalseSomeString.java")); - - - File tsAngular = new File(output, "ts-angular"); - final CodegenConfigurator tsConfigurator = new CodegenConfigurator() - .setGeneratorName("typescript-angular") - .setInputSpec("src/test/resources/3_1/issue_20213.yaml") - .setOutputDir(tsAngular.getAbsolutePath().replace("\\", "/")); - - final ClientOptInput tsClientOptInput = tsConfigurator.toClientOptInput(); - DefaultGenerator tsGenerator = new DefaultGenerator(); - Map tsFiles = tsGenerator.opts(tsClientOptInput).generate() - .stream().collect(Collectors.toMap(File::getName, Function.identity())); - - TestUtils.assertFileContains(tsFiles.get("sampleObjectWithAdditionalFalse.ts").toPath(), "someString?: string"); - assertFalse(tsFiles.containsKey("sampleObjectWithAdditionalFalseSomeString.ts")); - - File javaClient = new File(output, "java"); - final CodegenConfigurator javaClientConfigurator = new CodegenConfigurator() - .setGeneratorName("java") - .setInputSpec("src/test/resources/3_1/issue_20213.yaml") - .setOutputDir(javaClient.getAbsolutePath().replace("\\", "/")); - - final ClientOptInput javaClientClientOptInput = javaClientConfigurator.toClientOptInput(); - DefaultGenerator javaClientGenerator = new DefaultGenerator(); - Map javaClientFiles = javaClientGenerator.opts(javaClientClientOptInput).generate() - .stream().collect(Collectors.toMap(File::getName, Function.identity())); - - JavaFileAssert.assertThat(javaClientFiles.get("SampleObjectWithAdditionalFalse.java")) - .assertProperty("someString") - .withType("String"); - assertFalse(javaClientFiles.containsKey("SampleObjectWithAdditionalFalseSomeString.java")); - } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java index 1d41104f8dc0..d7fb7d51ef3d 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java @@ -939,6 +939,11 @@ public void testOpenAPINormalizerSimplifyOneOfAnyOf31Spec() { assertEquals(schema22.getAnyOf(), null); assertEquals(schema22.getTypes(), Set.of("string")); assertEquals(schema22.getEnum().size(), 2); + + Schema schema23 = openAPI.getComponents().getSchemas().get("AnyOfNullableAdditionalPropertiesTest"); + assertEquals(((Schema) schema23.getProperties().get("str")).getAnyOf(), null); + assertTrue(((Schema) schema23.getProperties().get("str")).getNullable()); + assertEquals(schema22.getTypes(), Set.of("string")); } @Test diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaClientCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaClientCodegenTest.java index 0b84b410fae0..ed0388fb3e5a 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaClientCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/java/JavaClientCodegenTest.java @@ -67,6 +67,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.entry; import static org.assertj.core.api.InstanceOfAssertFactories.FILE; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.openapitools.codegen.CodegenConstants.*; import static org.openapitools.codegen.TestUtils.*; import static org.openapitools.codegen.languages.JavaClientCodegen.*; @@ -3833,6 +3834,30 @@ public void queryParameterJsonSerialization(String library) { ); } + @Test(description = "Issue #20213") + public void givenModelHasFalseAdditionalPropertiesAndPropertyHasNullAsAnyOfTypeThenModelIsCorrect() throws Exception { + File output = Files.createTempDirectory("test").toFile(); + output.deleteOnExit(); + + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("java") + .setInputSpec("src/test/resources/3_1/issue_20213.yaml") + .setOutputDir(output.getAbsolutePath().replace("\\", "/")); + + final ClientOptInput clientOptInput = configurator.toClientOptInput(); + DefaultGenerator defaultGenerator = new DefaultGenerator(); + Map fileMap = defaultGenerator.opts(clientOptInput).generate() + .stream().collect(Collectors.toMap(File::getName, Function.identity())); + + JavaFileAssert.assertThat(fileMap.get("SampleObjectWithAdditionalFalse.java")) + .assertProperty("someString") + .withType("String") + .assertPropertyAnnotations() + .containsWithName("javax.annotation.Nullable"); + assertFalse(fileMap.containsKey("SampleObjectWithAdditionalFalseSomeString.java")); + + } + @DataProvider(name = "springClients") public static Object[] springClients() { return new Object[]{RESTCLIENT, WEBCLIENT}; diff --git a/modules/openapi-generator/src/test/resources/3_1/simplifyOneOfAnyOf_test.yaml b/modules/openapi-generator/src/test/resources/3_1/simplifyOneOfAnyOf_test.yaml index 588abf3e84c2..cbc387d8c514 100644 --- a/modules/openapi-generator/src/test/resources/3_1/simplifyOneOfAnyOf_test.yaml +++ b/modules/openapi-generator/src/test/resources/3_1/simplifyOneOfAnyOf_test.yaml @@ -137,4 +137,14 @@ components: OneOfNullAndRef3: oneOf: - $ref: '#/components/schemas/Parent' - - type: "null" \ No newline at end of file + - type: "null" + AnyOfNullableAdditionalPropertiesTest: + description: to test anyOf with additional properties + additionalProperties: false + properties: + str: + anyOf: + - type: string + - type: 'null' + - type: null + - $ref: null \ No newline at end of file From 2ae8b2f7f14c6c2d8f6293e46ea9fcd898ec6712 Mon Sep 17 00:00:00 2001 From: DavidGrath Date: Mon, 1 Sep 2025 21:04:30 +0100 Subject: [PATCH 4/4] Change based on @EduMenges recent fix --- .../java/org/openapitools/codegen/OpenAPINormalizer.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java index bab3272ad30a..d16da5a634a9 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java @@ -726,9 +726,10 @@ public Schema normalizeSchema(Schema schema, Set visitedSchemas) { normalizeSchema(result.getItems(), visitedSchemas); return result; } else if (schema.getAdditionalProperties() instanceof Schema) { // map - normalizeMapSchema(schema); - normalizeSchema((Schema) schema.getAdditionalProperties(), visitedSchemas); - if (schema.getProperties() != null && !schema.getProperties().isEmpty()) { + if(!ModelUtils.isModelWithPropertiesOnly(schema)) { + normalizeMapSchema(schema); + normalizeSchema((Schema) schema.getAdditionalProperties(), visitedSchemas); + } else { normalizeProperties(schema.getProperties(), visitedSchemas); } } else if (ModelUtils.isOneOf(schema)) { // oneOf