Skip to content

fix: recognize {type: object, nullable: true} as null-type schema in OpenAPI 3.0.x#23621

Open
ericdriggs wants to merge 3 commits intoOpenAPITools:masterfrom
ericdriggs:fix/3.0-nullable-object-null-type-detection
Open

fix: recognize {type: object, nullable: true} as null-type schema in OpenAPI 3.0.x#23621
ericdriggs wants to merge 3 commits intoOpenAPITools:masterfrom
ericdriggs:fix/3.0-nullable-object-null-type-detection

Conversation

@ericdriggs
Copy link
Copy Markdown

@ericdriggs ericdriggs commented Apr 24, 2026

Summary

isNullTypeSchema() does not recognize {type: "object", nullable: true} (with no properties) as a null-type schema. This causes the SIMPLIFY_ONEOF_ANYOF normalizer to fail to simplify anyOf schemas where this pattern is used as the nullable branch, producing Object or synthetic wrapper classes instead of typed nullable fields.

This 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 (PR #953) and potentially other spec generators.

Root cause

isNullTypeSchema() checks for null-type schemas by looking for:

  1. No type field at all
  2. type: "null" (OpenAPI 3.1)
  3. A single-value null enum

It did not check for {type: "object", nullable: true} with no properties and no $ref — an empty nullable object whose only purpose is to express "this value can be null" in OpenAPI 3.0.x.

Fix

Add a check to isNullTypeSchema() for empty nullable objects, scoped to OpenAPI 3.0.x only via !(schema instanceof JsonSchema). This follows the same 3.0/3.1 discrimination pattern used elsewhere in the same method.

Once isNullTypeSchema() recognizes this pattern, the existing SIMPLIFY_ONEOF_ANYOF logic in simplifyOneOfAnyOfWithOnlyOneNonNullSubSchema correctly identifies the anyOf as containing one real schema and one null sentinel, and simplifies it to a typed nullable field.

Before (input spec)

shippingAddress:
  anyOf:
    - $ref: '#/components/schemas/Address'
    - type: object
      nullable: true

Before (generated Java)

private OrderShippingAddress shippingAddress; // synthetic wrapper class

After (generated Java)

private JsonNullable<Address> shippingAddress; // typed nullable field

Why this pattern is spec-correct

The OpenAPI 3.0.3 spec states that nullable: true only takes effect when type is "explicitly defined within the same Schema Object." Since $ref resolves to a different Schema Object, nullable: true alongside allOf: [{$ref: ...}] is technically ineffective. The anyOf approach with a nullable empty object is the correct OpenAPI 3.0.x idiom — see analysis by Dan Ott (cited by apispec maintainers).

Test coverage

Test What it verifies
isNullTypeSchemaTest 3.0 sentinel → true; sentinel with properties → false
isNullTypeSchemaTestWith31Spec 3.1 sentinel → false (not a null sentinel in 3.1)
isNullTypeSchemaInlineAnyOfSentinelTest Inline anyOf sub-schema sentinel recognized
testAnyOfNullableObjectSentinelResolvesToTypedField End-to-end Java codegen: Address field, no OrderShippingAddress wrapper

Impact

Affects any OpenAPI 3.0.x spec using anyOf/oneOf with {type: "object", nullable: true} as the nullable branch, including all specs generated by apispec >= 6.7.1 (current latest: 6.10.0). All language targets that rely on SIMPLIFY_ONEOF_ANYOF should benefit.

Related issues

Test plan

  • ModelUtilsTest#isNullTypeSchemaTest passes
  • ModelUtilsTest#isNullTypeSchemaTestWith31Spec passes (no 3.1 regression)
  • ModelUtilsTest#isNullTypeSchemaInlineAnyOfSentinelTest passes
  • JavaClientCodegenTest#testAnyOfNullableObjectSentinelResolvesToTypedField passes

🤖 Generated with Claude Code


Summary by cubic

Fixes null-type detection for OpenAPI 3.0.x when anyOf/oneOf uses {type: object, nullable: true} as the null branch. Generators now produce typed nullable fields instead of Object or synthetic wrappers.

  • Bug Fixes
    • Update ModelUtils.isNullTypeSchema() to treat a 3.0.x nullable empty object with no properties, no additionalProperties, and no $ref as a null sentinel; objects with properties or maps are not treated as null. This does not apply to 3.1.
    • Add tests for 3.0/3.1 (including properties/maps cases) and an end-to-end Java test using bugs/issue_anyof_bare_nullable_object.yaml validating $ref + sentinel collapses to a single nullable typed field with no wrapper.

Written for commit a54ffbc. Summary will update on new commits. Review in cubic

…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>
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 6 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java">

<violation number="1" location="modules/openapi-generator/src/main/java/org/openapitools/codegen/utils/ModelUtils.java:2368">
P2: `isNullTypeSchema` over-broadly treats nullable object schemas as null sentinels, which can remove valid object branches during oneOf/anyOf simplification.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

@Mattias-Sehlstedt
Copy link
Copy Markdown
Contributor

As far as I know the established way of expressing nullable object refs in OAS 3.0 is

myField:
  nullable: true
  allOf:
    - $ref: '#/components/schemas/MySchema'

I would argue that the suggested schema is not preferable since it states "Address or anything (including null)". So if possible another way of expressing it is preferred if the intention is to say "Address or null".

@wing328
Copy link
Copy Markdown
Member

wing328 commented Apr 27, 2026

{type: "object", nullable: true}

isn't it just any type which is also nullable?

ref: https://swagger.io/docs/specification/v3_0/data-models/data-types/#any-type

@ericdriggs
Copy link
Copy Markdown
Author

@wing328

{type: "object", nullable: true} — isn't it just any type which is also nullable?

Per OAS 3.0 §4.4, "any type" is an untyped schema ({}). {type: object, nullable: true} means "object or null" — broader than just null, but not "any type."

You're right that it's not a precise null representation. But OAS 3.0 has no type: null, and OAS 3.0.3 makes the commonly recommended nullable: true, allOf: [$ref] pattern inert:

A true value adds "null" to the allowed type specified by the type keyword, only if type is explicitly defined within the same Schema Object.

The allOf + nullable pattern has no type in the outer Schema Object — the ref's type: object is in a different Schema Object — so nullable doesn't activate. The Clarify Nullable proposal that led to this change is explicit:

nullable: true operates within a single Schema Object. It does not "override" or otherwise compete with supertype or subtype schemas defined with allOf or other applicators. It cannot be directly "inherited" through those applicators, and it cannot be applied to an inherited type constraint.

The 3.0.3-compliant alternative is an anyOf union: one branch for the typed $ref, one branch that allows null. OAS 3.0 defers anyOf semantics to JSON Schema ("valid against at least one"), so a null value matches the nullable branch and a typed value matches the $ref branch.

The problem is expressing the null branch — OAS 3.0 has no type: null, so {type: object, nullable: true} is the closest available. It's imprecise (accepts any object too), but in context its purpose is to allow null. apispec/marshmallow hit this exact problem when openapi-core rejected the allOf pattern, and switched to this anyOf approach in #953 (v6.7.1).

This PR recognizes that pattern pragmatically: a nullable object schema with no properties, no additionalProperties, and no $ref is expressing nullability, not defining a meaningful object type.

@ericdriggs
Copy link
Copy Markdown
Author

@Mattias-Sehlstedt
The response above covers why allOf + nullable is inert under 3.0.3. To your point about preferring another way — we agree, but OAS 3.0 has no type: null, so there isn't a more precise 3.0.3-compliant way to express "typed ref or null." This PR doesn't recommend the pattern; it recognizes what 3.0.3-compliant generators like apispec are forced to produce.

@Mattias-Sehlstedt
Copy link
Copy Markdown
Contributor

Mattias-Sehlstedt commented Apr 27, 2026

If the intent is to follow the standard to the strongest degree possible, shouldn't the schema be:

myNullableField:
  allOf:
    - $ref: '#/components/schemas/MySchema'
myField:
  not: 
    enum: [null]
  allOf:
    - $ref: '#/components/schemas/MySchema'
MySchema:
  type: object
  nullable: true
  properties:
        property:
          type: string

As per the suggestion from the proposal.

What would be the reason for apispec not expressing it as:

shippingAddress:
  anyOf:
    - $ref: '#/components/schemas/Address'
    - type: object
      nullable: true <- or even emit this entirely?
      enum: [null]

to strictly capture that the second object is null? To my understand that schema is still valid and expresses a strict validation rule for either null or nothing?

@ericdriggs
Copy link
Copy Markdown
Author

ericdriggs commented Apr 27, 2026

Both suggestions (not: {enum: [null]} and enum: [null]) may be better patterns for generators to produce — but that's a concern for spec authors and generators like apispec, not for this PR.

This PR sits on the consumer side of Postel's Law: "be conservative in what you send, be liberal in what you accept." We can't dictate to every team and generator what form their output takes — only whether it conforms to the spec. {type: object, nullable: true} in an anyOf is valid OAS 3.0.3. Generators produce it. openapi-generator should handle it.

@Mattias-Sehlstedt
Copy link
Copy Markdown
Contributor

Note/clarification: I am only a contributor, all opinions are my own and does not affect the review/PR in any way.


I disagree with Postel's Law applying completely for this scenario, as per "..., but programs that receive messages should accept non-conformant input as long as the meaning is clear".

There are a lot of possible way of trying to express nullability of a ref in OAS 3.0 as we have seen, and I would argue that the one apispec has can be improved. So why would one introduce logic to handle less clear cases in the openapi-generator, rather than ensuring that apispec uses a better representation?

If the generator does not take the opportunity to forward knowledge of how things are best structured in OAS, then I think one misses an opportunity to work towards a consensus and shared understanding. When I work in this repo I usually find that there are many occasions where the same thing is done in different ways, when it would have been very preferable to ensure that the internal understanding was the same everywhere (e.g., when different language Codegens does oneOf interpretation slightly differently everywhere).

And if someone is stuck with a representation that does not fit directly into how the repo interprets specifications, then it offers plenty of opportunity to customize the behavior so that the client can create a bridge from their specification to an "openapi-generator-specification" (e.g., with a custom normalizer).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants