diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java index fe612c09dcd9..b4f6a1cb51d3 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java @@ -2572,7 +2572,15 @@ public static boolean isParent(Schema schema) { */ public static Schema cloneSchema(Schema schema, boolean openapi31) { if (openapi31) { - return AnnotationsUtils.clone(schema, openapi31); + Schema result = AnnotationsUtils.clone(schema, openapi31); + // `nullable` is not a valid OAS 3.1 keyword, so the 3.1 serializer used by + // AnnotationsUtils.clone silently drops it -- at every level of the schema, not just + // the root. OpenAPINormalizer rewrites an OAS 3.1 `type: [, "null"]` declaration + // into `nullable: true` plus a plain type, so dropping it here would turn cloned + // schemas (e.g. properties merged into a model from an `allOf` parent) and every + // schema nested inside them into non-nullable ones. Carry it over explicitly. + restoreNullable(schema, result); + return result; } else { // AnnotationsUtils.clone doesn't support custom schema types for OpenAPI < 3.1 String schemaType = schema.getType(); @@ -2586,6 +2594,60 @@ public static Schema cloneSchema(Schema schema, boolean openapi31) { } } + /** + * Copies the `nullable` flag from {@code original} onto {@code cloned}, recursing through + * every nested schema (properties, array items, additionalProperties, composed sub-schemas + * and `not`). Used to repair an OpenAPI 3.1 clone, whose serializer drops the flag because + * `nullable` is not a valid OAS 3.1 keyword. + *

+ * The two schemas are walked in parallel, so this relies on {@code cloned} having the same + * shape as {@code original} -- which holds because it is a faithful deep copy in every other + * respect. Nested lists are matched positionally and defensively bounded by the shorter of + * the two. + * + * @param original the schema that was cloned + * @param cloned the clone to repair + */ + private static void restoreNullable(Schema original, Schema cloned) { + if (original == null || cloned == null) { + return; + } + + if (original.getNullable() != null) { + cloned.setNullable(original.getNullable()); + } + + restoreNullableInMap(original.getProperties(), cloned.getProperties()); + restoreNullable(original.getItems(), cloned.getItems()); + restoreNullable(original.getNot(), cloned.getNot()); + + if (original.getAdditionalProperties() instanceof Schema + && cloned.getAdditionalProperties() instanceof Schema) { + restoreNullable((Schema) original.getAdditionalProperties(), + (Schema) cloned.getAdditionalProperties()); + } + + restoreNullableInList(original.getAllOf(), cloned.getAllOf()); + restoreNullableInList(original.getOneOf(), cloned.getOneOf()); + restoreNullableInList(original.getAnyOf(), cloned.getAnyOf()); + } + + private static void restoreNullableInMap(Map original, Map cloned) { + if (original == null || cloned == null) { + return; + } + original.forEach((name, originalValue) -> restoreNullable(originalValue, cloned.get(name))); + } + + private static void restoreNullableInList(List original, List cloned) { + if (original == null || cloned == null) { + return; + } + for (int i = 0; i < Math.min(original.size(), cloned.size()); i++) { + restoreNullable(original.get(i), cloned.get(i)); + } + } + /** * Simplifies the schema by removing the oneOfAnyOf if the oneOfAnyOf only contains a single non-null sub-schema * 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 7ce9bfcc1b7d..8eaf983dab1a 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 @@ -348,6 +348,38 @@ public void testDateTimeFormParameterHasDefaultValue() { Assertions.assertNull(codegenParameter.getSchema()); } + @Test + public void testOAS31NullableTypeArrayIsInheritedThroughAllOf() { + final OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_1/allof-nullable-type-array.yaml"); + new OpenAPINormalizer(openAPI, Map.of("NORMALIZE_31SPEC", "true")).normalize(); + + final DefaultCodegen codegen = new DefaultCodegen(); + codegen.setOpenAPI(openAPI); + + // `id`, the items of `tags` and the values of `meta` are all declared with the OAS 3.1 + // `type: [, 'null']` form on Base, so they must stay nullable both on Base itself + // and on Derived, which pulls them in via `allOf: [$ref: Base, {...}]`. Nullability must + // survive at every nesting level, not just on the property itself. + for (String modelName : List.of("Base", "Derived", "Control")) { + Schema schema = openAPI.getComponents().getSchemas().get(modelName); + CodegenModel model = codegen.fromModel(modelName, schema); + + assertTrue(varNamed(model, "id").isNullable, + "`id` must be nullable on " + modelName); + assertTrue(varNamed(model, "tags").items.isNullable, + "the items of `tags` must be nullable on " + modelName); + assertTrue(varNamed(model, "meta").additionalProperties.isNullable, + "the values of `meta` must be nullable on " + modelName); + } + } + + private static CodegenProperty varNamed(CodegenModel model, String baseName) { + return model.vars.stream() + .filter(v -> baseName.equals(v.baseName)) + .findFirst() + .orElseThrow(() -> new AssertionError("no `" + baseName + "` var on " + model.name)); + } + @Test public void testOAS31ContentMediaTypeBinaryFormParameter() { final OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_1/binary-schema.yaml"); diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java index ef42900e8051..ce27b7e719f0 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java @@ -31,6 +31,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -1263,4 +1264,94 @@ public void resolveMinimumBound_nestedAllOf_mostRestrictiveAcrossAllLevels() { assertEquals(bound.minBound, BigDecimal.valueOf(20)); } + /** + * `nullable` is not a valid OAS 3.1 keyword, so a naive 3.1 clone drops it. The normalizer + * relies on `nullable` to carry an OAS 3.1 `type: [, "null"]` declaration, so cloning + * must preserve it. + */ + @Test + public void testCloneSchemaPreservesNullableForOpenAPI31() { + Schema schema = new Schema<>(); + schema.setType("string"); + schema.setNullable(true); + + Schema cloned31 = ModelUtils.cloneSchema(schema, true); + assertTrue(Boolean.TRUE.equals(cloned31.getNullable()), + "nullable must be preserved when cloning an OpenAPI 3.1 schema"); + + Schema cloned30 = ModelUtils.cloneSchema(schema, false); + assertTrue(Boolean.TRUE.equals(cloned30.getNullable()), + "nullable must be preserved when cloning an OpenAPI 3.0 schema"); + } + + /** + * The OAS 3.1 serializer drops `nullable` at every level of the schema, so cloning must + * restore it on nested schemas too, not only on the root. This covers every branch + * {@code restoreNullable} recurses into. + */ + @Test + public void testCloneSchemaPreservesNestedNullableForOpenAPI31() { + Schema root = new Schema<>(); + root.setType("object"); + root.setNullable(true); + + Map properties = new LinkedHashMap<>(); + properties.put("inner", nullableStringSchema()); + + ArraySchema array = new ArraySchema(); + array.setItems(nullableStringSchema()); + properties.put("list", array); + + Schema map = new Schema<>(); + map.setType("object"); + map.setAdditionalProperties(nullableStringSchema()); + properties.put("map", map); + + Schema negated = new Schema<>(); + negated.setNot(nullableStringSchema()); + properties.put("negated", negated); + + Schema allOf = new Schema<>(); + allOf.setAllOf(new ArrayList<>(List.of(nullableStringSchema()))); + properties.put("allOf", allOf); + + Schema oneOf = new Schema<>(); + oneOf.setOneOf(new ArrayList<>(List.of(nullableStringSchema()))); + properties.put("oneOf", oneOf); + + Schema anyOf = new Schema<>(); + anyOf.setAnyOf(new ArrayList<>(List.of(nullableStringSchema()))); + properties.put("anyOf", anyOf); + + root.setProperties(properties); + + Schema cloned = ModelUtils.cloneSchema(root, true); + + assertTrue(Boolean.TRUE.equals(cloned.getNullable()), "root nullable must be preserved"); + assertNestedNullable(property(cloned, "inner"), "a nested property"); + assertNestedNullable(property(cloned, "list").getItems(), "array items"); + assertNestedNullable((Schema) property(cloned, "map").getAdditionalProperties(), "additionalProperties"); + assertNestedNullable(property(cloned, "negated").getNot(), "a `not` sub-schema"); + assertNestedNullable(property(cloned, "allOf").getAllOf().get(0), "an `allOf` sub-schema"); + assertNestedNullable(property(cloned, "oneOf").getOneOf().get(0), "a `oneOf` sub-schema"); + assertNestedNullable(property(cloned, "anyOf").getAnyOf().get(0), "an `anyOf` sub-schema"); + } + + private static Schema property(Schema schema, String name) { + return schema.getProperties().get(name); + } + + private static void assertNestedNullable(Schema schema, String location) { + assertNotNull(schema, "expected a cloned schema at " + location); + assertTrue(Boolean.TRUE.equals(schema.getNullable()), + "nullable must be preserved on " + location); + } + + private static Schema nullableStringSchema() { + Schema schema = new Schema<>(); + schema.setType("string"); + schema.setNullable(true); + return schema; + } + } diff --git a/modules/openapi-generator/src/test/resources/3_1/allof-nullable-type-array.yaml b/modules/openapi-generator/src/test/resources/3_1/allof-nullable-type-array.yaml new file mode 100644 index 000000000000..05e2fa5aa055 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_1/allof-nullable-type-array.yaml @@ -0,0 +1,56 @@ +openapi: 3.1.0 +info: + title: allOf with OAS 3.1 nullable type array + version: 1.0.0 +paths: {} +components: + schemas: + Base: + type: object + properties: + id: + type: + - string + - 'null' + tags: + type: array + items: + type: + - string + - 'null' + meta: + type: object + additionalProperties: + type: + - string + - 'null' + name: + type: string + Derived: + allOf: + - $ref: '#/components/schemas/Base' + - type: object + properties: + email: + type: string + Control: + type: object + properties: + id: + type: + - string + - 'null' + tags: + type: array + items: + type: + - string + - 'null' + meta: + type: object + additionalProperties: + type: + - string + - 'null' + email: + type: string