Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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:

Expand Down
3 changes: 2 additions & 1 deletion docs/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions examples/unions/anyof/gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions internal/gen/models/templates/union.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
5 changes: 5 additions & 0 deletions internal/gen/models/testdata/unions.golden
Original file line number Diff line number Diff line change
Expand Up @@ -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:"-"`
Expand Down
4 changes: 4 additions & 0 deletions internal/gen/models/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -96,6 +97,7 @@ type UnionView struct {
IsGrouped bool
IsDescribed bool
IsText bool
IsNullable bool
HasUnion bool
Variants []VariantView
Args []string
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions pkg/runtime/httpclient/httpclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
36 changes: 36 additions & 0 deletions pkg/runtime/httpclient/httpclient_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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) {
Expand Down
21 changes: 19 additions & 2 deletions pkg/runtime/value_nullable.go
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down
39 changes: 39 additions & 0 deletions pkg/runtime/value_nullable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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()

Expand Down
Loading