Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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: [<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();
Expand All @@ -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.
* <p>
* 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<String, Schema> original, Map<String, Schema> cloned) {
if (original == null || cloned == null) {
return;
}
original.forEach((name, originalValue) -> restoreNullable(originalValue, cloned.get(name)));
}

private static void restoreNullableInList(List<Schema> original, List<Schema> 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
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [<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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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: [<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<Object> root = new Schema<>();
root.setType("object");
root.setNullable(true);

Map<String, Schema> properties = new LinkedHashMap<>();
properties.put("inner", nullableStringSchema());

ArraySchema array = new ArraySchema();
array.setItems(nullableStringSchema());
properties.put("list", array);

Schema<Object> map = new Schema<>();
map.setType("object");
map.setAdditionalProperties(nullableStringSchema());
properties.put("map", map);

Schema<Object> negated = new Schema<>();
negated.setNot(nullableStringSchema());
properties.put("negated", negated);

Schema<Object> allOf = new Schema<>();
allOf.setAllOf(new ArrayList<>(List.of(nullableStringSchema())));
properties.put("allOf", allOf);

Schema<Object> oneOf = new Schema<>();
oneOf.setOneOf(new ArrayList<>(List.of(nullableStringSchema())));
properties.put("oneOf", oneOf);

Schema<Object> 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;
}

}
Original file line number Diff line number Diff line change
@@ -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
Loading