oapi-codegen icon indicating copy to clipboard operation
oapi-codegen copied to clipboard

feat(nullable): nullable.go for OpenAPI 3.1 compatibility

Open frenchi opened this issue 4 months ago • 1 comments

Summary

Hello maintainers 👋

New contributor to this project, so feedback very much welcome & appreciated!

We just upgraded our internal OAS Spec to version 3.1, before running in to #373 3.1 compatibility issues. Instead of downgrading, I have some cycles to spend on hopefully progressing some of the tasks under https://github.com/orgs/oapi-codegen/projects/4.

This is the first in a small series of PRs to hopefully make progress towards the changed nullable behaviour in OAS 3.1, while ensuring backwards compatibility.

Open Questions up-front:

  • Is this the right approach?
  • If not, should this live elsewhere?
    • https://github.com/oapi-codegen/nullable ?
    • or upstream in https://github.com/getkin/kin-openapi?
  • Is the assumption of "Empty schemas being treated as non-nullable to avoid over-detection in codegen" a valid one?

This PR

nullable.go handles null in line with JSON schema core 2020-12 spec for OAS 3.1, with one explicit generator policy: Empty schemas {} are treated as non-nullable to avoid over-detection in codegen.

What's included:

  • [x] Centralised 3.1 null detector in pkg/codegen/nullable.go (this PR) but not wired up yet.
    • Exports IsSchemaNullable(specVersion string, schema *openapi3.SchemaRef) bool
    • Keeps default behaviour unchanged (i.e. output-options.nullable-type exists in configuration-schema.json, default false)
    • 3.1: Considers type (including union with 'null'), oneOf/anyOf/allOf, enum (null present), and not (blocks null if it accepts null)
    • 3.0: Falls back to schema.Value.Nullable for nullable: true compatibility
    • a single file keeps logic consistent and avoids scattered special cases throughout the codebase.
  • Tests in pkg/codegen/nullability_test.go

Note: The detector intentionally diverges from raw JSON Schema for {} as explained above.

The logic & test coverage of oneOf/anyOf/allOf got a little hairy, so I found I understood it better once I sketched it out.

  • General rule (where k is “how many subschemas explicitly allow null”):
    • anyOf: k ≥ 1
    • oneOf: k = 1
    • allOf: k = total number of subschemas

e.g.

  • [ {'type':'string'} ] is zero subschemas, k=0
  • [ {'type':'null'} ] is one subschema, k=1
  • [ {'type':'null'}, {'type':'null'} ] k=2, etc...
k anyOf(null) oneOf(null) allOf(null)
0
1
2+ ✅ (only if all explicitly allow null)

appendix below with full test coverage matrix.

Future Work

  • [ ] If above PR is accepted, integrate IsSchemaNullable() into the broader codebase,
  • [ ] Feature-flag: Wire up existing output-options.nullable-type config
    • When output-options.nullable-type is enabled, use nullable.Value[T] from github.com/oapi-codegen/nullable (previously nullable.Nullable[T]), non-blocking PR: #2067
    • Otherwise retain existing behaviour for 3.0 users
  • [ ] Version gating and safety
    • Detect spec version; enable 3.1 paths only when openapi: 3.1.*
    • Provide unit-test coverage for detector behavior on both 3.0 and 3.1 inputs

Delivery plan

rough PR split

  • [ ] PR1: detector (unused) + tests <- this PR
  • [ ] PR2: Explicit feature-flag & initial schema integration,
  • [ ] PR3: Version gating & safety,
  • [ ] PR4?: Cleanup, docs touch-ups, internal refactors if needed

Checklist

  • [x] Tests added/updated (for new features or bug fixes)
  • [ ] ~Documentation updated (README/CONTRIBUTING/examples as needed)~ N/A
  • [x] make tidy is run locally
  • [x] make test is run locally
  • [ ] ~make generate run is run locally and outputs committed (if generated code is affected)~ N/A
  • [x] make lint is run locally

Additional context

Test Coverage of all cases:

Case Description Combinator Example k assert Covered by test
Type-only null type type: 'null' 1 TestIsSchemaNullable_31_TypeOnlyNull
Type-only string type type: 'string' 0 TestIsSchemaNullable_31_TypeOnlyString
Type union includes null type type: ['string','null'] 1 TestIsSchemaNullable_31_TypeUnionIncludesNull
Type union, no null type type: ['string','integer'] 0 TestIsSchemaNullable_31_NonNullable
oneOf with single null-only arm oneOf [ {type:'string'}, {type:'null'} ] 1 TestIsSchemaNullable_31_OneOfWithNullOnly
oneOf with two null-only arms oneOf [ {type:'null'}, {type:'null'} ] 2 TestIsSchemaNullable_31_OneOf_AllNullArms
oneOf mixed, multiple null-allowing arms oneOf [ {type:['string','null']}, {type:'null'}, {type:'integer'} ] 2 TestIsSchemaNullable_31_OneOf_MixedMultipleNullAllowingArms
anyOf with single null-only arm anyOf [ {type:'integer'}, {type:'null'} ] 1 TestIsSchemaNullable_31_AnyOfWithNullOnly
anyOf with two null-only arms anyOf [ {type:'null'}, {type:'null'} ] 2 TestIsSchemaNullable_31_AnyOf_AllNullArms
allOf with one null-only arm allOf [ {type:'null'}, {type:'string'} ] 1 TestIsSchemaNullable_31_AllOf_OneNullArm
allOf with all null-only arms allOf [ {type:'null'}, {type:'null'} ] 2 TestIsSchemaNullable_31_AllOf_AllNullArms
Base type blocks null in anyOf anyOf + type type:'string', anyOf: [ {type:'null'}, {minLength:1} ] 1 TestIsSchemaNullable_31_BaseTypeBlocksNull_InAnyOf
enum includes null (no type) enum enum: [null, 'x'] 1 TestIsSchemaNullable_31_EnumAllowsNull_NoType
enum includes null but base type excludes enum + base type:'string', enum: [null, 'x'] 1 TestIsSchemaNullable_31_EnumAllowsNull_WithTypeString_Disallowed
not blocks null not type: ['string','null'], not: {type:'null'} 1 TestIsSchemaNullable_31_NotBlocksNull
empty schema (policy) {} TestIsSchemaNullable_31_EmptySchema_NotNullableByPolicy
OAS 3.0 nullable true 3.0 {type:'string', nullable:true} TestIsSchemaNullable_30_FallbackTrue
OAS 3.0 default false 3.0 {type:'string'} TestIsSchemaNullable_30_FallbackFalse

frenchi avatar Aug 29 '25 05:08 frenchi

Kusari Inspector

Kusari Analysis Results:

Proceed with these changes
✅ No Flagged Issues Detected All values appear to be within acceptable risk parameters.

No pinned version dependency changes, code issues or exposed secrets detected!

@kusari-inspector rerun - Trigger a re-analysis of this PR @kusari-inspector feedback [your message] - Send feedback to our AI and team See Kusari's documentation for setup and configuration. Commit: 752211e796ee4a91616a2d034944939b42e64ad0, performed at: 2025-08-29T05:10:44Z

Found this helpful? Give it a 👍 or 👎 reaction!

kusari-inspector[bot] avatar Aug 29 '25 05:08 kusari-inspector[bot]