Skip to content

Commit 7ee4a73

Browse files
wing328eric-driggsclaudeericdriggs
authored
Normalizer: new rule LOOSE_NULL_DEFINITIONS to allow more null definitions in 3.0 spec. (#23932)
* fix: recognize {type: object, nullable: true} as null-type schema in OpenAPI 3.0.x The SIMPLIFY_ONEOF_ANYOF normalizer failed to simplify anyOf schemas where the nullable branch uses {type: "object", nullable: true} instead of an untyped schema or {type: "null"}. This caused Java (and likely other) code generators to produce Object or synthetic wrapper classes instead of the intended typed nullable field. This pattern is a valid OpenAPI 3.0.x idiom for expressing nullability alongside a $ref in anyOf/oneOf. It is produced by apispec >= 6.7.1 (the most widely used OpenAPI spec generator for Python/Flask/Marshmallow) and potentially other spec generators. Root cause: isNullTypeSchema() did not recognize an empty nullable object ({type: "object", nullable: true} with no properties and no $ref) as a null-type schema. The fix adds a check for this pattern, scoped to 3.0.x only via !(schema instanceof JsonSchema), since OpenAPI 3.1 expresses nullability differently via type arrays. Test coverage: - isNullTypeSchemaTest: 3.0 sentinel (true), sentinel with properties (false) - isNullTypeSchemaTestWith31Spec: 3.1 sentinel correctly returns false - isNullTypeSchemaInlineAnyOfSentinelTest: inline anyOf sub-schema recognized - testAnyOfNullableObjectSentinelResolvesToTypedField: end-to-end Java codegen produces Address field, no synthetic OrderShippingAddress wrapper Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * address PR feedback * add LOOSE_NULL_DEFINITIONS to normalizer rule * update doc, tests * fix tests * update * update doc --------- Co-authored-by: eric-r-driggs <eric.driggs@disney.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Eric Driggs <ericdriggs@users.noreply.github.com>
1 parent 4e31e4d commit 7ee4a73

8 files changed

Lines changed: 162 additions & 6 deletions

File tree

docs/customization.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,13 @@ Example:
644644
java -jar modules/openapi-generator-cli/target/openapi-generator-cli.jar generate -g java -i modules/openapi-generator/src/test/resources/3_0/required-properties.yaml -o /tmp/java-okhttp/ --openapi-normalizer NORMALIZER_CLASS=org.openapitools.codegen.OpenAPINormalizerTest$RemoveRequiredNormalizer
645645
```
646646
647+
- `LOOSE_NULL_DEFINITIONS`: When set to true, allow more schema definitions in OpenAPI 3.0 spec to be the same as `null` in OpenAPI 3.1 spec by setting ModelUtils.looseNullDefinitions to true.
648+
649+
Example:
650+
```
651+
java -jar modules/openapi-generator-cli/target/openapi-generator-cli.jar generate -g java -i modules/openapi-generator/src/test/resources/bugs/issue_anyof_bare_nullable_object.yaml -o /tmp/java-okhttp/ --openapi-normalizer LOOSE_NULL_DEFINITIONS=true
652+
```
653+
647654
- `REMOVE_PROPERTIES_FROM_TYPE_OTHER_THAN_OBJECT`: When set to true, remove the "properties" of a schema with type other than "object".
648655
649656
Example:

modules/openapi-generator/src/main/java/org/openapitools/codegen/OpenAPINormalizer.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ public class OpenAPINormalizer {
165165
// when set to true, sort model properties by name to ensure deterministic output
166166
final String SORT_MODEL_PROPERTIES = "SORT_MODEL_PROPERTIES";
167167

168+
// when set to true, some more schema definitions are considered as `null` in 3.1 spec
169+
final String LOOSE_NULL_DEFINITIONS = "LOOSE_NULL_DEFINITIONS";
170+
168171
// ============= end of rules =============
169172

170173
/**
@@ -225,6 +228,7 @@ public OpenAPINormalizer(OpenAPI openAPI, Map<String, String> inputRules) {
225228
ruleNames.add(SIMPLIFY_ONEOF_ANYOF_ENUM);
226229
ruleNames.add(REMOVE_PROPERTIES_FROM_TYPE_OTHER_THAN_OBJECT);
227230
ruleNames.add(SORT_MODEL_PROPERTIES);
231+
ruleNames.add(LOOSE_NULL_DEFINITIONS);
228232
ruleNames.add(REPLACE_ONE_OF_BY_DISCRIMINATOR_MAPPING);
229233

230234
// rules that are default to true
@@ -341,6 +345,11 @@ public void processRules(Map<String, String> inputRules) {
341345
if (bearerAuthSecuritySchemeName != null) {
342346
rules.put(SET_BEARER_AUTH_FOR_NAME, true);
343347
}
348+
349+
// update ModelUtils to allow loose null definitions if the normalizer rule LOOSE_NULL_DEFINITIONS is set
350+
if (Boolean.TRUE.equals(rules.get(LOOSE_NULL_DEFINITIONS))) {
351+
ModelUtils.looseNullDefinitions = true;
352+
}
344353
}
345354

346355
/**

modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ public class ModelUtils {
8181
private static final ObjectMapper JSON_MAPPER;
8282
private static final ObjectMapper YAML_MAPPER;
8383

84+
// allow more schema definitions to be the `null` type in 3.1 spec
85+
// e.g. {type: object, nullable: true} which is any type that's nullable
86+
public static boolean looseNullDefinitions = false;
87+
8488
static {
8589
JSON_MAPPER = ObjectMapperFactory.createJson();
8690
YAML_MAPPER = ObjectMapperFactory.createYaml();
@@ -2394,6 +2398,18 @@ public static boolean isNullTypeSchema(OpenAPI openAPI, Schema schema) {
23942398
return false;
23952399
}
23962400

2401+
// OpenAPI 3.0.x: nullable object with no properties or constraints expresses nullability, which
2402+
// is any type that's nullable, i.e. {type: object, nullable: true}
2403+
// given that the normalizer rule `LOOSE_NULL_DEFINITIONS` is enabled
2404+
if (looseNullDefinitions &&
2405+
!(schema instanceof JsonSchema) // 3.0.x only
2406+
&& "object".equals(schema.getType())
2407+
&& Boolean.TRUE.equals(schema.getNullable())
2408+
&& schema.get$ref() == null
2409+
&& schema.getAdditionalProperties() == null) {
2410+
return true;
2411+
}
2412+
23972413
// convert referenced enum of null only to `nullable:true`
23982414
if (schema.getEnum() != null && schema.getEnum().size() == 1) {
23992415
if ("null".equals(String.valueOf(schema.getEnum().get(0)))) {

modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1834,7 +1834,7 @@ public Schema normalizeSchema(Schema schema, Set<Schema> visitedSchemas) {
18341834
}
18351835

18361836
@Test
1837-
public void testREPLACE_ONE_OF_BY_DISCRIMINATOR_MAPPING() {
1837+
public void testReplaceOneOfByDiscriminatorMapping() {
18381838
OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/oneOf_issue_23527.yaml");
18391839

18401840
Map<String, String> inputRules = Map.of("REPLACE_ONE_OF_BY_DISCRIMINATOR_MAPPING", "true");
@@ -1847,12 +1847,11 @@ public void testREPLACE_ONE_OF_BY_DISCRIMINATOR_MAPPING() {
18471847
}
18481848

18491849
@Test
1850-
public void issue_14769() {
1850+
public void testIssue14769() {
18511851
OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/oneOf_issue_14769.yaml");
18521852
Map<String, String> inputRules = Map.of("REPLACE_ONE_OF_BY_DISCRIMINATOR_MAPPING", "true");
18531853
OpenAPINormalizer openAPINormalizer = new OpenAPINormalizer(openAPI, inputRules);
18541854
openAPINormalizer.normalize();
1855-
// ModelUtils.dumpAsYaml(openAPI);
18561855
Schema vehicle = openAPI.getComponents().getSchemas().get("Vehicle");
18571856
Map<String, String> mapping = vehicle.getDiscriminator().getMapping();
18581857
assertEquals(mapping, Map.of("car", "#/components/schemas/Car", "plane", "#/components/schemas/Plane" ));
@@ -1865,15 +1864,36 @@ public void issue_14769() {
18651864
}
18661865

18671866
@Test
1868-
public void oneOf_issue_23276() {
1867+
public void oneOfIssue23276() {
18691868
OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/oneOf_issue_23276.yaml");
18701869
Map<String, String> inputRules = Map.of("REPLACE_ONE_OF_BY_DISCRIMINATOR_MAPPING", "true");
18711870
OpenAPINormalizer openAPINormalizer = new OpenAPINormalizer(openAPI, inputRules);
18721871
openAPINormalizer.normalize();
1873-
// ModelUtils.dumpAsYaml(openAPI);
18741872
Schema payload = (Schema)openAPI.getComponents().getSchemas().get("DeviceLifecycleEvent").getProperties().get("payload");
18751873
// inline oneOf are not converted
18761874
assertNotNull(payload.getOneOf());
18771875
}
18781876

1877+
@Test
1878+
public void testLooseNullDefinitions() {
1879+
OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/bugs/issue_anyof_bare_nullable_object.yaml");
1880+
1881+
Schema<?> order = openAPI.getComponents().getSchemas().get("Order");
1882+
assertEquals(((Schema) order.getProperties().get("shippingAddress").getAnyOf().get(0)).get$ref(), "#/components/schemas/Address");
1883+
assertEquals(((Schema) order.getProperties().get("shippingAddress").getAnyOf().get(1)).getNullable(), true);
1884+
assertEquals(((Schema) order.getProperties().get("shippingAddress").getAnyOf().get(1)).getType(), "object");
1885+
1886+
Map<String, String> options = Map.of("LOOSE_NULL_DEFINITIONS", "true");
1887+
OpenAPINormalizer openAPINormalizer = new OpenAPINormalizer(openAPI, options);
1888+
openAPINormalizer.normalize();
1889+
1890+
Schema<?> order2 = openAPI.getComponents().getSchemas().get("Order");
1891+
assertEquals(order2.getProperties().get("shippingAddress").get$ref(), null);
1892+
assertEquals(order2.getProperties().get("shippingAddress").getNullable(), true);
1893+
assertEquals( ((Schema) order2.getProperties().get("shippingAddress").getAllOf().get(0)).get$ref(), "#/components/schemas/Address");
1894+
1895+
// reset to false after tests
1896+
ModelUtils.looseNullDefinitions = false;
1897+
}
1898+
18791899
}

modules/openapi-generator/src/test/java/org/openapitools/codegen/utils/ModelUtilsTest.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,43 @@ public void isNullTypeSchemaTest() {
654654

655655
schema = openAPI.getComponents().getSchemas().get("JustDescription");
656656
assertFalse(ModelUtils.isNullTypeSchema(openAPI, schema));
657+
658+
// {type: "object", nullable: true} with no properties/constraints expresses nullability (OAS 3.0.3)
659+
// but only if the normalizer rule `LOOSE_NULL_DEFINITIONS` is enabled
660+
schema = openAPI.getComponents().getSchemas().get("BareNullableObject");
661+
assertFalse(ModelUtils.isNullTypeSchema(openAPI, schema));
662+
663+
// {type: "object", nullable: true} WITH properties is a real object, not expressing nullability
664+
// whether normalizer rule `LOOSE_NULL_DEFINITIONS` is enabled or not
665+
schema = openAPI.getComponents().getSchemas().get("NullableObjectWithProperties");
666+
assertFalse(ModelUtils.isNullTypeSchema(openAPI, schema));
667+
668+
// {type: "object", nullable: true, additionalProperties: ...} is a nullable map, not expressing nullability
669+
// whether normalizer rule `LOOSE_NULL_DEFINITIONS` is enabled or not
670+
schema = openAPI.getComponents().getSchemas().get("NullableObjectMap");
671+
assertFalse(ModelUtils.isNullTypeSchema(openAPI, schema));
672+
}
673+
674+
@Test
675+
public void isNullTypeSchemaTestBareNullableObject() {
676+
OpenAPI openAPI = TestUtils.parseSpec(
677+
"src/test/resources/bugs/issue_anyof_bare_nullable_object.yaml");
678+
Schema order = (Schema) openAPI.getComponents().getSchemas().get("Order");
679+
Schema shippingProp = (Schema) order.getProperties().get("shippingAddress");
680+
assertNotNull(shippingProp.getAnyOf(), "shippingAddress should have anyOf");
681+
682+
List<Schema> anyOf = shippingProp.getAnyOf();
683+
assertEquals(anyOf.size(), 2);
684+
685+
// first sub-schema is the $ref to Address
686+
Schema refSchema = anyOf.get(0);
687+
assertFalse(ModelUtils.isNullTypeSchema(openAPI, refSchema));
688+
689+
// second sub-schema is {type: object, nullable: true} which is just any type that's also nullable
690+
// when normalizer rule `LOOSE_NULL_DEFINITIONS` is NOT enabled
691+
Schema bareNullableObject = anyOf.get(1);
692+
assertFalse(ModelUtils.isNullTypeSchema(openAPI, bareNullableObject),
693+
"not `null` (3.1 spec) as normalizer rule `LOOSE_NULL_DEFINITIONS` is NOT enabled");
657694
}
658695

659696
@Test
@@ -695,6 +732,12 @@ public void isNullTypeSchemaTestWith31Spec() {
695732

696733
schema = openAPI.getComponents().getSchemas().get("JustDescription");
697734
assertFalse(ModelUtils.isNullTypeSchema(openAPI, schema));
735+
736+
// In 3.1, {type: object, nullable: true} is NOT a null type — it's a real
737+
// nullable object. Nullability in 3.1 is expressed via type: ["object", "null"].
738+
// whether normalizer rule `LOOSE_NULL_DEFINITIONS` is enabled or not
739+
schema = openAPI.getComponents().getSchemas().get("BareNullableObject");
740+
assertFalse(ModelUtils.isNullTypeSchema(openAPI, schema));
698741
}
699742

700743
@Test

modules/openapi-generator/src/test/resources/3_0/null_schema_test.yaml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,18 @@ components:
104104
- $ref: '#/components/schemas/IntegerRef'
105105
- $ref: '#/components/schemas/StringRef'
106106
JustDescription:
107-
description: A schema with just description
107+
description: A schema with just description
108+
BareNullableObject:
109+
type: object
110+
nullable: true
111+
NullableObjectWithProperties:
112+
type: object
113+
nullable: true
114+
properties:
115+
name:
116+
type: string
117+
NullableObjectMap:
118+
type: object
119+
nullable: true
120+
additionalProperties:
121+
type: string

modules/openapi-generator/src/test/resources/3_1/null_schema_test.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,6 @@ components:
105105
- $ref: '#/components/schemas/StringRef'
106106
JustDescription:
107107
description: A schema with just description
108+
BareNullableObject:
109+
type: object
110+
nullable: true
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
openapi: 3.0.3
2+
info:
3+
title: anyOf bare nullable object test
4+
description: >
5+
Tests that anyOf with a $ref and {type: object, nullable: true} (no properties)
6+
is simplified to a typed nullable field, not Object.
7+
This pattern is produced by apispec 6.7.1+ for OpenAPI 3.0.x specs.
8+
version: 1.0.0
9+
paths:
10+
/orders/{orderId}:
11+
get:
12+
operationId: getOrder
13+
parameters:
14+
- name: orderId
15+
in: path
16+
required: true
17+
schema:
18+
type: string
19+
responses:
20+
'200':
21+
description: OK
22+
content:
23+
application/json:
24+
schema:
25+
$ref: '#/components/schemas/Order'
26+
components:
27+
schemas:
28+
Order:
29+
type: object
30+
properties:
31+
id:
32+
type: string
33+
shippingAddress:
34+
anyOf:
35+
- $ref: '#/components/schemas/Address'
36+
- type: object
37+
nullable: true
38+
Address:
39+
type: object
40+
properties:
41+
street:
42+
type: string
43+
city:
44+
type: string

0 commit comments

Comments
 (0)