Skip to content

Commit f651452

Browse files
eric-driggsclaude
andcommitted
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>
1 parent 6be478d commit f651452

6 files changed

Lines changed: 113 additions & 1 deletion

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2363,6 +2363,14 @@ public static boolean isNullTypeSchema(OpenAPI openAPI, Schema schema) {
23632363
return false;
23642364
}
23652365

2366+
// OpenAPI 3.0.x: empty nullable object is a null-type schema
2367+
if (!(schema instanceof JsonSchema) // 3.0.x only
2368+
&& "object".equals(schema.getType())
2369+
&& Boolean.TRUE.equals(schema.getNullable())
2370+
&& schema.get$ref() == null) {
2371+
return true;
2372+
}
2373+
23662374
// convert referenced enum of null only to `nullable:true`
23672375
if (schema.getEnum() != null && schema.getEnum().size() == 1) {
23682376
if ("null".equals(String.valueOf(schema.getEnum().get(0)))) {

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4325,4 +4325,18 @@ public void testJspecify(String library, boolean useSpringBoot4, boolean hasJspe
43254325
.fileContains("@org.jspecify.annotations.NullMarked");
43264326

43274327
}
4328+
4329+
@Test(description = "anyOf with $ref and {type: object, nullable: true} should resolve to typed nullable field, not Object")
4330+
public void testAnyOfNullableObjectSentinelResolvesToTypedField() {
4331+
Map<String, File> files = generateFromContract(
4332+
"src/test/resources/bugs/issue_anyof_nullable_object_sentinel.yaml",
4333+
JavaClientCodegen.JERSEY3);
4334+
4335+
JavaFileAssert.assertThat(files.get("Order.java"))
4336+
.fileContains("Address")
4337+
.fileDoesNotContain("OrderShippingAddress", "Object getShippingAddress");
4338+
4339+
Assert.assertNull(files.get("OrderShippingAddress.java"),
4340+
"Should not generate synthetic anyOf wrapper; the anyOf should simplify to Address");
4341+
}
43284342
}

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,35 @@ 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 is a null sentinel (apispec 6.7.1+)
659+
schema = openAPI.getComponents().getSchemas().get("NullableObjectSentinel");
660+
assertTrue(ModelUtils.isNullTypeSchema(openAPI, schema));
661+
662+
// {type: "object", nullable: true} WITH properties is a real object, not a null sentinel
663+
schema = openAPI.getComponents().getSchemas().get("NullableObjectWithProperties");
664+
assertFalse(ModelUtils.isNullTypeSchema(openAPI, schema));
665+
}
666+
667+
@Test
668+
public void isNullTypeSchemaInlineAnyOfSentinelTest() {
669+
OpenAPI openAPI = TestUtils.parseSpec(
670+
"src/test/resources/bugs/issue_anyof_nullable_object_sentinel.yaml");
671+
Schema order = (Schema) openAPI.getComponents().getSchemas().get("Order");
672+
Schema shippingProp = (Schema) order.getProperties().get("shippingAddress");
673+
assertNotNull(shippingProp.getAnyOf(), "shippingAddress should have anyOf");
674+
675+
List<Schema> anyOf = shippingProp.getAnyOf();
676+
assertEquals(anyOf.size(), 2);
677+
678+
// first sub-schema is the $ref to Address
679+
Schema refSchema = anyOf.get(0);
680+
assertFalse(ModelUtils.isNullTypeSchema(openAPI, refSchema));
681+
682+
// second sub-schema is {type: object, nullable: true} — the sentinel
683+
Schema sentinel = anyOf.get(1);
684+
assertTrue(ModelUtils.isNullTypeSchema(openAPI, sentinel),
685+
"Should recognize {type: object, nullable: true} as null sentinel");
657686
}
658687

659688
@Test
@@ -695,6 +724,11 @@ public void isNullTypeSchemaTestWith31Spec() {
695724

696725
schema = openAPI.getComponents().getSchemas().get("JustDescription");
697726
assertFalse(ModelUtils.isNullTypeSchema(openAPI, schema));
727+
728+
// In 3.1, {type: object, nullable: true} is NOT a null sentinel — it's a real
729+
// nullable object. Nullability in 3.1 is expressed via type: ["object", "null"].
730+
schema = openAPI.getComponents().getSchemas().get("NullableObjectSentinel");
731+
assertFalse(ModelUtils.isNullTypeSchema(openAPI, schema));
698732
}
699733

700734
@Test

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,13 @@ 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+
NullableObjectSentinel:
109+
type: object
110+
nullable: true
111+
NullableObjectWithProperties:
112+
type: object
113+
nullable: true
114+
properties:
115+
name:
116+
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+
NullableObjectSentinel:
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 nullable object sentinel 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)