feat(nullable): nullable.go for OpenAPI 3.1 compatibility
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-typeexists inconfiguration-schema.json, defaultfalse) - 3.1: Considers
type(including union with'null'),oneOf/anyOf/allOf,enum(null present), andnot(blocks null if it accepts null) - 3.0: Falls back to
schema.Value.Nullablefornullable: truecompatibility - a single file keeps logic consistent and avoids scattered special cases throughout the codebase.
- Exports
- Tests in
pkg/codegen/nullability_test.go- Validates consistency against patterns from the JSON Schema Test Suite.
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-typeconfig- When
output-options.nullable-typeis enabled, usenullable.Value[T]fromgithub.com/oapi-codegen/nullable(previouslynullable.Nullable[T]), non-blocking PR: #2067 - Otherwise retain existing behaviour for 3.0 users
- When
- [ ] 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
- Detect spec version; enable 3.1 paths only when
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 tidyis run locally - [x]
make testis run locally - [ ] ~
make generaterun is run locally and outputs committed (if generated code is affected)~ N/A - [x]
make lintis run locally
Additional context
- oapi-codegen Project Board for 3.1 support
- json-schema-org anyOf JSON Schema Test Suite
- https://github.com/OAI/OpenAPI-Specification/issues/3148
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 |
Kusari Analysis Results:
✅ 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!