From 18b4a9d4452d27f1f2b98cb0bdd1f280818b7748 Mon Sep 17 00:00:00 2001 From: ivmorski Date: Fri, 16 Oct 2026 20:47:29 +0200 Subject: [PATCH] Leave null parameters out of requests --- docs/client.md | 5 +-- docs/types.md | 3 +- examples/unions/anyof/gen.go | 5 +++ internal/gen/models/templates/union.tmpl | 7 ++++ internal/gen/models/testdata/unions.golden | 5 +++ internal/gen/models/view.go | 4 +++ pkg/runtime/httpclient/httpclient.go | 6 ++-- pkg/runtime/httpclient/httpclient_test.go | 36 ++++++++++++++++++++ pkg/runtime/value_nullable.go | 21 ++++++++++-- pkg/runtime/value_nullable_test.go | 39 ++++++++++++++++++++++ 10 files changed, 123 insertions(+), 8 deletions(-) diff --git a/docs/client.md b/docs/client.md index b74c2293..79b2396b 100644 --- a/docs/client.md +++ b/docs/client.md @@ -233,6 +233,7 @@ runs. The client never calls it for you. |---|---| | a whole group, such as `Query` | none of its parameters are sent | | a required parameter | `runtime.ErrParamMissing`, before anything is sent | +| `null` in a parameter, such as a nullable union with no variant set | the same as nil: not sent, or `runtime.ErrParamMissing` when required. A URL or header has no `null` | | a path parameter | `runtime.ErrParamMissing`, even when the spec marks it optional | | an optional parameter without a pointer (`x-go-type-skip-optional-pointer`) | it is not sent while it holds its zero value | @@ -248,8 +249,8 @@ These fail before anything is sent: - A cookie value with a byte a cookie cannot hold, such as `;` or `"`: `runtime.ErrParamValue`. net/http would drop the byte and only log it. -An object leaves out a property that is nil, an empty list or map, and a zero value tagged -`omitempty`. +An object leaves out a property that is nil or `null`, an empty list or map, and a zero value +tagged `omitempty`. A list or object inside an object: diff --git a/docs/types.md b/docs/types.md index e3f522c6..21a7464b 100644 --- a/docs/types.md +++ b/docs/types.md @@ -463,7 +463,8 @@ The methods follow the kind: - A `oneOf` with more than one variant set is an error. - An `anyOf` with several set merges objects, a later key replacing an earlier one. Otherwise it writes the first set variant. -- Nothing set writes `null`. +- Nothing set writes `null`. A nullable union without shared properties has `IsNull`, which + reports that. With a discriminator, `MarshalJSON` also writes the discriminator value. diff --git a/examples/unions/anyof/gen.go b/examples/unions/anyof/gen.go index 900430ac..d0be82a6 100644 --- a/examples/unions/anyof/gen.go +++ b/examples/unions/anyof/gen.go @@ -134,6 +134,11 @@ func (s *Stamp) UnmarshalText(text []byte) error { return runtime.UnmarshalUnionText(text, s.UnmarshalJSON) } +// IsNull reports whether no variant is set, which is null. +func (s Stamp) IsNull() bool { + return s.Time == nil && s.String == nil +} + // Code is any of Time or String. type Code struct { Time *time.Time `json:"-"` diff --git a/internal/gen/models/templates/union.tmpl b/internal/gen/models/templates/union.tmpl index 71665b96..0ab75d43 100644 --- a/internal/gen/models/templates/union.tmpl +++ b/internal/gen/models/templates/union.tmpl @@ -82,4 +82,11 @@ func ({{.Receiver}} *{{$.Name}}) UnmarshalText(text []byte) error { return {{.Runtime}}.UnmarshalUnionText(text, {{.Receiver}}.UnmarshalJSON) } {{- end}} +{{- if .IsNullable}} + +// IsNull reports whether no variant is set, which is null. +func ({{.Receiver}} {{$.Name}}) IsNull() bool { + return {{range $i, $v := .Variants}}{{if $i}} && {{end}}{{$.Union.Receiver}}.{{$v.Name}} == nil{{end}} +} +{{- end}} {{- end}} \ No newline at end of file diff --git a/internal/gen/models/testdata/unions.golden b/internal/gen/models/testdata/unions.golden index 65b364d5..0e6e6a8e 100644 --- a/internal/gen/models/testdata/unions.golden +++ b/internal/gen/models/testdata/unions.golden @@ -126,6 +126,11 @@ func (s *Stamp) UnmarshalJSON(data []byte) error { }) } +// IsNull reports whether no variant is set, which is null. +func (s Stamp) IsNull() bool { + return s.Time == nil && s.Any == nil && s.Loop == nil +} + // ID is one of Int or String. type ID struct { Int *int `json:"-"` diff --git a/internal/gen/models/view.go b/internal/gen/models/view.go index e853398c..41721a9c 100644 --- a/internal/gen/models/view.go +++ b/internal/gen/models/view.go @@ -85,6 +85,7 @@ type ConstView struct { // the packages are imported under, JSON only when shared fields are decoded. IsText adds // MarshalText and UnmarshalText; Form, the form type, UnmarshalForm. Which is what decoding sets // in doc lines, Marshal the runtime func that writes the variants. IsDescribed uses Args instead. +// IsNullable adds IsNull to a nullable union without shared fields, which writes null when unset. type UnionView struct { Receiver string Runtime string @@ -96,6 +97,7 @@ type UnionView struct { IsGrouped bool IsDescribed bool IsText bool + IsNullable bool HasUnion bool Variants []VariantView Args []string @@ -249,6 +251,8 @@ func unionView(d *gomodel.Decl, s *gocode.Scope) *UnionView { if len(d.Struct.Fields) > 0 { v.JSON = s.Import(gomodel.Import{Path: "encoding/json"}) } + isNotNullable := func(g *gomodel.Group) bool { return !g.IsNullable } + v.IsNullable = len(d.Struct.Fields) == 0 && !slices.ContainsFunc(u.Groups, isNotNullable) var shared []string for _, f := range d.Struct.Fields { diff --git a/pkg/runtime/httpclient/httpclient.go b/pkg/runtime/httpclient/httpclient.go index ca8bf04e..df73582e 100644 --- a/pkg/runtime/httpclient/httpclient.go +++ b/pkg/runtime/httpclient/httpclient.go @@ -83,7 +83,7 @@ func (b *RequestBuilder) PathParam(v any, p runtime.Param) { } // QueryParam adds v to the query as p, see runtime.EncodeQuery. An optional p is left out when v -// is nil or zero, a required one is an error when v is nil. +// is nil, null or zero, a required one is an error when v is nil or null. func (b *RequestBuilder) QueryParam(v any, p runtime.Param) { if b.err != nil || b.skip(v, p) { return @@ -258,9 +258,9 @@ func (b *RequestBuilder) Build(ctx context.Context, base *url.URL) (*http.Reques return req, nil } -// skip reports a value left out: nil, an error when p is required, or zero when p is optional. +// skip reports a value left out: nil or null, an error when required, or zero when optional. func (b *RequestBuilder) skip(v any, p runtime.Param) bool { - if !isNil(v) { + if !isNil(v) && runtime.HasValue(v) { return !p.IsRequired && validation.IsZero(v) } if p.IsRequired { diff --git a/pkg/runtime/httpclient/httpclient_test.go b/pkg/runtime/httpclient/httpclient_test.go index 296c027c..7d71079d 100644 --- a/pkg/runtime/httpclient/httpclient_test.go +++ b/pkg/runtime/httpclient/httpclient_test.go @@ -42,6 +42,24 @@ type filter struct { Tags []string `json:"tags,omitempty"` } +// orNull is a nullable union of one string, null while nothing is set. +type orNull struct { + String *string +} + +func (o orNull) IsNull() bool { + return o.String == nil +} + +func (o orNull) MarshalText() ([]byte, error) { + return []byte(*o.String), nil +} + +func (o *orNull) UnmarshalText(text []byte) error { + o.String = new(string(text)) + return nil +} + type upload struct { Title string `json:"title"` File runtime.File `json:"file"` @@ -128,6 +146,24 @@ func TestRequestBuilder(t *testing.T) { }, wantErr: runtime.ErrParamMissing, }, + { + name: "A null value is left out when optional", + build: func(b *RequestBuilder) { + b.QueryParam(&orNull{}, runtime.Param{Name: "u", Style: runtime.StyleForm, IsExplode: true}) + b.QueryParam(orNull{String: new("a")}, runtime.Param{Name: "v", Style: runtime.StyleForm, IsExplode: true}) + b.HeaderParam(&orNull{}, runtime.Param{Name: "X-U", Style: runtime.StyleSimple}) + b.CookieParam(runtime.Null[int](), runtime.Param{Name: "u", Style: runtime.StyleForm}) + }, + wantURL: "http://api.test/v1/pets?v=a", + wantHeader: http.Header{"X-U": nil, "Cookie": nil}, + }, + { + name: "A required query parameter that is null", + build: func(b *RequestBuilder) { + b.QueryParam(&orNull{}, runtime.Param{Name: "needed", IsRequired: true}) + }, + wantErr: runtime.ErrParamMissing, + }, { name: "A zero value is left out when optional and sent when required or behind a pointer", build: func(b *RequestBuilder) { diff --git a/pkg/runtime/value_nullable.go b/pkg/runtime/value_nullable.go index fe74eb2b..6346077a 100644 --- a/pkg/runtime/value_nullable.go +++ b/pkg/runtime/value_nullable.go @@ -28,7 +28,15 @@ type nullableTarget interface { target() reflect.Value } -var nullableType = reflect.TypeFor[nullable]() +// nuller is a value that can say it is null, such as a nullable union with no variant set. +type nuller interface { + IsNull() bool +} + +var ( + nullableType = reflect.TypeFor[nullable]() + nullerType = reflect.TypeFor[nuller]() +) // Some returns a Nullable that holds v. func Some[T any](v T) Nullable[T] { @@ -113,6 +121,12 @@ func (n *Nullable[T]) target() reflect.Value { return reflect.ValueOf(n.value).Elem() } +// HasValue reports whether v holds a value: not nil, not absent and not null. +func HasValue(v any) bool { + _, ok := held(reflect.ValueOf(v)) + return ok +} + // valueType is the type t holds behind pointers and Nullables. func valueType(t reflect.Type) reflect.Type { for { @@ -128,7 +142,7 @@ func valueType(t reflect.Type) reflect.Type { } } -// held is what v holds behind pointers, interfaces and Nullables, and false when that is nothing. +// held is what v holds behind pointers, interfaces and Nullables, false when nothing or null. func held(v reflect.Value) (reflect.Value, bool) { for { switch { @@ -146,6 +160,9 @@ func held(v reflect.Value) (reflect.Value, bool) { return v, false } v = inner + case v.CanInterface() && v.Type().Implements(nullerType): + n, _ := v.Interface().(nuller) + return v, !n.IsNull() default: return v, true } diff --git a/pkg/runtime/value_nullable_test.go b/pkg/runtime/value_nullable_test.go index 9a941a7b..f398dbe8 100644 --- a/pkg/runtime/value_nullable_test.go +++ b/pkg/runtime/value_nullable_test.go @@ -25,6 +25,24 @@ type node struct { Parent Nullable[node] `json:"parent,omitzero"` } +// orNull is a nullable union of one string, null while nothing is set. +type orNull struct { + String *string +} + +func (o orNull) IsNull() bool { + return o.String == nil +} + +func (o orNull) MarshalText() ([]byte, error) { + return []byte(*o.String), nil +} + +func (o *orNull) UnmarshalText(text []byte) error { + o.String = new(string(text)) + return nil +} + // patch is a body with a Nullable of each kind a form or a query carries. type patch struct { Name Nullable[string] `json:"name,omitzero"` @@ -144,6 +162,27 @@ func TestNullableParams(t *testing.T) { assert.Equal(t, http.Header{"X-Rate": {"7"}}, h) } +func TestNullValueIsLeftOut(t *testing.T) { + t.Parallel() + + assert.False(t, HasValue(nil)) + assert.False(t, HasValue(Null[int]())) + assert.False(t, HasValue(&orNull{})) + assert.True(t, HasValue(orNull{String: new("a")})) + + p := Param{Name: "u", Style: StyleForm, IsExplode: true} + null, err := EncodeQuery(&orNull{}, p) + require.NoError(t, err) + set, err := EncodeQuery(orNull{String: new("a")}, p) + require.NoError(t, err) + inObject, err := EncodeQuery(struct { + U orNull `json:"u"` + V string `json:"v"` + }{V: "b"}, Param{Name: "o", Style: StyleForm, IsExplode: true}) + require.NoError(t, err) + assert.Equal(t, []string{"", "u=a", "v=b"}, []string{null, set, inObject}) +} + func TestNullableForm(t *testing.T) { t.Parallel()