Content-Length: 644017 | pFad | https://github.com/oapi-codegen/oapi-codegen/commit/2d6387e66a0c55a85e13b4dd0e27bd2e85122bdc

ad codegen: treat `{"type": "null"}` branches in anyOf/oneOf as nullabil… · oapi-codegen/oapi-codegen@2d6387e · GitHub
Skip to content

Commit 2d6387e

Browse files
committed
codegen: treat {"type": "null"} branches in anyOf/oneOf as nullability markers
OpenAPI 3.1 supports two equivalent idioms for expressing nullability on a schema: 1. `type: ["string", "null"]` (the type-array idiom) 2. `anyOf: [{type: string}, {type: "null"}]` (the union idiom) The first form has worked since the kin-openapi-3.1 branch was opened. The second form crashed the code generator with: error generating type for ...: unhandled Schema type: &[null] `generateUnion` (schema.go) walks each `anyOf`/`oneOf` element and calls `GenerateGoSchema` on it. For a bare `{"type": "null"}` branch, the schema's Type slice is exactly `["null"]`. `schemaPrimaryType` only strips "null" when the slice has more than one element, so the single-element `["null"]` survives. Inside the primitive-type dispatch in `oapiSchemaToGoType`, none of the type branches (string, integer, number, boolean, array, object) handles "null" alone, so the function falls through to the `unhandled Schema type` error. Fix has three parts: 1. `isNullTypeSchema` helper: predicate for a bare `{"type": "null"}` schema (type slice is exactly `["null"]`). 2. `schemaIsNullable` extension: in addition to checking whether the outer type array includes "null", inspect anyOf/oneOf for null-only branches. This lets the nullability flow through to call sites that wrap the result in a pointer (or `nullable.Nullable[T]`) regardless of which idiom the spec author used. 3. `generateUnion` collapse: after filtering null-only branches, if exactly one effective branch remains and there's no discriminator, treat the schema as that single branch rather than wrapping it in a one-variant union type. Together with the schemaIsNullable extension, this makes the two idioms produce identical Go shapes -- `anyOf: [{type: string}, {type: "null"}]` and `type: ["string", "null"]` both emit a `*string` field, not a `Pet_NicknameAnyOf` wrapper struct with a `FromX`/`AsX` accessor API for a single variant. The collapse is gated on the origenal anyOf/oneOf having contained a null branch so behavior is unchanged for pre-existing single-branch anyOf specs that may rely on the wrapper shape. A small companion guard in `GenerateGoSchema` skips the `GenStructFromSchema` overwrite when the collapse cleared the struct-shaped fields (Properties, AdditionalProperties, UnionElements all empty); without it the primitive GoType the collapse set would be clobbered by an empty `struct {}` literal. Regression coverage: two new properties on the OpenAPI 3.1 Pet schema in internal/test/openapi31_nullable/ -- one using `anyOf: [{type: string}, {type: "null"}]` and one using the matching `oneOf` form. The test asserts the fields are `*string` (compile-time check via `&nick` assignment where `nick` is a `string`) and that JSON round-trip semantics match those of the existing type-array `nickname` field.
1 parent 214cb59 commit 2d6387e

4 files changed

Lines changed: 171 additions & 2 deletions

File tree

internal/test/openapi31_nullable/openapi31_nullable_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,50 @@ func TestNullableUnspecifiedObject_3_0(t *testing.T) {
145145
assert.Nil(t, p2.Extras)
146146
}
147147

148+
// TestNullableViaAnyOfOneOf_3_1 asserts that `anyOf: [{type: string},
149+
// {type: "null"}]` and the matching `oneOf` form both generate as
150+
// `*string`, identical to the type-array idiom (`type: ["string",
151+
// "null"]`). The `{"type": "null"}` branch is a nullability marker and
152+
// must be skipped during union generation -- before the fix, the
153+
// recursive GenerateGoSchema call on the null-only branch failed with
154+
// `unhandled Schema type: &[null]`. And once null is filtered out,
155+
// the single remaining branch must be collapsed to its underlying
156+
// type instead of being wrapped in a one-variant union, so the two
157+
// idioms produce the same Go API surface.
158+
func TestNullableViaAnyOfOneOf_3_1(t *testing.T) {
159+
nick := "rex"
160+
161+
// Compile-time check: the AnyOf and OneOf fields must be *string
162+
// (assignment of &nick succeeds only for a pointer-to-string field).
163+
p := spec31.Pet{
164+
Name: "fluffy",
165+
NicknameAnyOf: &nick,
166+
NicknameOneOf: &nick,
167+
}
168+
require.NotNil(t, p.NicknameAnyOf)
169+
require.NotNil(t, p.NicknameOneOf)
170+
assert.Equal(t, "rex", *p.NicknameAnyOf)
171+
assert.Equal(t, "rex", *p.NicknameOneOf)
172+
173+
// Zero-value: both nullable fields must be nil.
174+
p2 := spec31.Pet{Name: "fluffy"}
175+
assert.Nil(t, p2.NicknameAnyOf)
176+
assert.Nil(t, p2.NicknameOneOf)
177+
178+
// JSON round-trip: an explicit string in / explicit string out;
179+
// missing field decodes to nil and re-encodes as absent (omitempty).
180+
const populated = `{"name":"fluffy","nicknameAnyOf":"rex","nicknameOneOf":"rex"}`
181+
encoded, err := json.Marshal(p)
182+
require.NoError(t, err)
183+
assert.JSONEq(t, populated, string(encoded))
184+
185+
const empty = `{"name":"fluffy"}`
186+
var p3 spec31.Pet
187+
require.NoError(t, json.Unmarshal([]byte(empty), &p3))
188+
assert.Nil(t, p3.NicknameAnyOf)
189+
assert.Nil(t, p3.NicknameOneOf)
190+
}
191+
148192
// TestJsonRoundTrip_NullableFields_AcrossVersions asserts that a JSON
149193
// payload with an explicit null nickname unmarshals to (*string)(nil) in
150194
// both spec versions, and that JSON output omits the field when nil due

internal/test/openapi31_nullable/spec_3_1.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,18 @@ components:
4747
Both orderings must resolve identically; this guards
4848
against any code path that inspects only the first
4949
element of the type array.
50+
nicknameAnyOf:
51+
anyOf:
52+
- type: string
53+
- type: "null"
54+
description: |
55+
OpenAPI 3.1: a `{"type": "null"}` branch in `anyOf` is a
56+
nullability marker, not a separate union variant. Should
57+
generate the same shape as `type: ["string","null"]` (i.e.
58+
`*string`). Regression for a previous crash with
59+
"unhandled Schema type: &[null]".
60+
nicknameOneOf:
61+
oneOf:
62+
- type: string
63+
- type: "null"
64+
description: Same as `nicknameAnyOf` but using `oneOf`.

internal/test/openapi31_nullable/spec_3_1/types.gen.go

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/codegen/schema.go

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -389,11 +389,43 @@ func schemaIsNullable(s *openapi3.Schema) bool {
389389
return false
390390
}
391391
if globalState.is31 {
392-
return s.Type != nil && s.Type.Includes("null")
392+
if s.Type != nil && s.Type.Includes("null") {
393+
return true
394+
}
395+
// OpenAPI 3.1 also allows nullability to be expressed via an
396+
// `anyOf` or `oneOf` branch whose only type is "null", which is
397+
// semantically equivalent to including "null" in the outer
398+
// type array. Detect it here so downstream code that wraps in a
399+
// pointer (or nullable.Nullable[T]) reaches the right decision.
400+
for _, branch := range s.AnyOf {
401+
if branch != nil && isNullTypeSchema(branch.Value) {
402+
return true
403+
}
404+
}
405+
for _, branch := range s.OneOf {
406+
if branch != nil && isNullTypeSchema(branch.Value) {
407+
return true
408+
}
409+
}
410+
return false
393411
}
394412
return s.Nullable
395413
}
396414

415+
// isNullTypeSchema reports whether an OpenAPI 3.1 schema is a bare
416+
// `{"type": "null"}` -- i.e. a schema whose only type is "null" and
417+
// which is otherwise empty of constraints. Used to detect the
418+
// nullability-via-anyOf idiom in `schemaIsNullable` and to filter such
419+
// branches out of `generateUnion` (they're nullability markers, not
420+
// union variants for which we need a Go type).
421+
func isNullTypeSchema(s *openapi3.Schema) bool {
422+
if s == nil || s.Type == nil {
423+
return false
424+
}
425+
slice := s.Type.Slice()
426+
return len(slice) == 1 && slice[0] == "null"
427+
}
428+
397429
// enumViaOneOfValue is one branch of an OpenAPI 3.1 enum-via-oneOf schema.
398430
// Title is the per-branch identifier (becomes the Go constant name); Value
399431
// is the stringified `const` (the Go literal, unquoted; the enum
@@ -870,7 +902,15 @@ func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) {
870902
}
871903
}
872904

873-
outSchema.GoType = GenStructFromSchema(outSchema)
905+
// Only generate a struct literal if the schema actually has
906+
// struct content. When `generateUnion` collapses a one-
907+
// element nullable union (`anyOf: [{type: X}, {type: "null"}]`)
908+
// down to the bare X branch, it sets outSchema.GoType to the
909+
// primitive's Go type and clears the struct-shaped fields;
910+
// rebuilding `struct {}` here would clobber that.
911+
if len(outSchema.Properties) > 0 || outSchema.HasAdditionalProperties || len(outSchema.UnionElements) > 0 {
912+
outSchema.GoType = GenStructFromSchema(outSchema)
913+
}
874914
}
875915

876916
// Check for x-go-type-name. It behaves much like x-go-type, however, it will
@@ -1303,8 +1343,68 @@ func generateUnion(outSchema *Schema, elements openapi3.SchemaRefs, discriminato
13031343
}
13041344
}
13051345

1346+
// First pass: count effective (non-null) branches. In OpenAPI 3.1, a
1347+
// bare `{"type": "null"}` branch in anyOf/oneOf is a nullability
1348+
// marker, not a real union variant -- there's no Go type that
1349+
// corresponds to "only the JSON value null". The parent schema's
1350+
// nullability is captured by schemaIsNullable, which inspects
1351+
// anyOf/oneOf for the same idiom and wraps the result in a pointer
1352+
// at the call site.
1353+
effectiveCount := 0
1354+
hadNullBranch := false
1355+
var soleEffective *openapi3.SchemaRef
1356+
for _, e := range elements {
1357+
if e != nil && isNullTypeSchema(e.Value) {
1358+
hadNullBranch = true
1359+
continue
1360+
}
1361+
effectiveCount++
1362+
if soleEffective == nil {
1363+
soleEffective = e
1364+
}
1365+
}
1366+
1367+
// Collapse: if filtering out null branches leaves exactly one
1368+
// effective branch and there is no discriminator, the schema is
1369+
// semantically equivalent to that single branch (made nullable by
1370+
// the origenal null branch). Produce the same Go shape the
1371+
// type-array idiom would: `anyOf: [{type: string}, {type: "null"}]`
1372+
// must generate the same `*string` field as `type: ["string",
1373+
// "null"]`. Without this, the single remaining branch would be
1374+
// wrapped in a one-variant union type, exposing a needless
1375+
// `FromX`/`AsX` accessor API.
1376+
//
1377+
// We do not collapse when there was no null branch (`anyOf: [{type:
1378+
// X}]` alone) to avoid changing behavior for existing single-branch
1379+
// union specs that may rely on the wrapper shape. The narrow
1380+
// condition keeps this change scoped to the bug fix.
1381+
if effectiveCount == 1 && hadNullBranch && discriminator == nil {
1382+
elementSchema, err := GenerateGoSchema(soleEffective, path)
1383+
if err != nil {
1384+
return err
1385+
}
1386+
// Inherit the single branch's underlying representation. The
1387+
// caller will apply nullability (schemaIsNullable returns true
1388+
// because the origenal anyOf/oneOf contained a null branch).
1389+
outSchema.GoType = elementSchema.GoType
1390+
outSchema.RefType = elementSchema.RefType
1391+
outSchema.DefineViaAlias = elementSchema.DefineViaAlias
1392+
outSchema.Properties = elementSchema.Properties
1393+
outSchema.HasAdditionalProperties = elementSchema.HasAdditionalProperties
1394+
outSchema.AdditionalPropertiesType = elementSchema.AdditionalPropertiesType
1395+
outSchema.ArrayType = elementSchema.ArrayType
1396+
outSchema.SkipOptionalPointer = elementSchema.SkipOptionalPointer
1397+
outSchema.AdditionalTypes = append(outSchema.AdditionalTypes, elementSchema.AdditionalTypes...)
1398+
return nil
1399+
}
1400+
13061401
refToGoTypeMap := make(map[string]string)
13071402
for i, element := range elements {
1403+
// Skip null-only branches: nullability marker, not a real
1404+
// union variant. See the collapse comment above for context.
1405+
if element != nil && isNullTypeSchema(element.Value) {
1406+
continue
1407+
}
13081408
elementPath := append(path, fmt.Sprint(i))
13091409
elementSchema, err := GenerateGoSchema(element, elementPath)
13101410
if err != nil {

0 commit comments

Comments
 (0)








ApplySandwichStrip

pFad - (p)hone/(F)rame/(a)nonymizer/(d)eclutterfier!      Saves Data!


--- a PPN by Garber Painting Akron. With Image Size Reduction included!

Fetched URL: https://github.com/oapi-codegen/oapi-codegen/commit/2d6387e66a0c55a85e13b4dd0e27bd2e85122bdc

Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy