From e21d8768f91db78e53e9c575f5c4bb433fcfaba1 Mon Sep 17 00:00:00 2001 From: Nick Khyl Date: Mon, 8 Jul 2024 10:32:37 -0500 Subject: [PATCH] types/opt: add generic Value[T any] for optional values of any types Updates #12736 Signed-off-by: Nick Khyl --- cmd/derper/depaware.txt | 6 + cmd/k8s-operator/depaware.txt | 9 +- cmd/stund/depaware.txt | 7 + cmd/tailscale/depaware.txt | 6 + cmd/tailscaled/depaware.txt | 7 +- types/opt/value.go | 122 ++++++++++++++ types/opt/value_test.go | 296 ++++++++++++++++++++++++++++++++++ 7 files changed, 446 insertions(+), 7 deletions(-) create mode 100644 types/opt/value.go create mode 100644 types/opt/value_test.go diff --git a/cmd/derper/depaware.txt b/cmd/derper/depaware.txt index cf4f36458..825b33fac 100644 --- a/cmd/derper/depaware.txt +++ b/cmd/derper/depaware.txt @@ -10,6 +10,12 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa L github.com/coreos/go-iptables/iptables from tailscale.com/util/linuxfw W 💣 github.com/dblohm7/wingoes from tailscale.com/util/winutil github.com/fxamacker/cbor/v2 from tailscale.com/tka + github.com/go-json-experiment/json from tailscale.com/types/opt + github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json+ + github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json+ + github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+ + github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+ + github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+ github.com/golang/groupcache/lru from tailscale.com/net/dnscache L github.com/google/nftables from tailscale.com/util/linuxfw L 💣 github.com/google/nftables/alignedbuff from github.com/google/nftables/xt diff --git a/cmd/k8s-operator/depaware.txt b/cmd/k8s-operator/depaware.txt index bda03a386..2d38e4916 100644 --- a/cmd/k8s-operator/depaware.txt +++ b/cmd/k8s-operator/depaware.txt @@ -98,11 +98,12 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ 💣 github.com/fsnotify/fsnotify from sigs.k8s.io/controller-runtime/pkg/certwatcher github.com/fxamacker/cbor/v2 from tailscale.com/tka github.com/gaissmai/bart from tailscale.com/net/ipset+ + github.com/go-json-experiment/json from tailscale.com/types/opt github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json/internal/jsonflags+ github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json/internal/jsonopts+ - github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext - github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json/jsontext - github.com/go-json-experiment/json/jsontext from tailscale.com/logtail + github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext+ + github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json/jsontext+ + github.com/go-json-experiment/json/jsontext from tailscale.com/logtail+ github.com/go-logr/logr from github.com/go-logr/logr/slogr+ github.com/go-logr/logr/slogr from github.com/go-logr/zapr github.com/go-logr/zapr from sigs.k8s.io/controller-runtime/pkg/log/zap+ @@ -957,7 +958,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ math/big from crypto/dsa+ math/bits from compress/flate+ math/rand from github.com/google/go-cmp/cmp+ - math/rand/v2 from database/sql+ + math/rand/v2 from tailscale.com/derp+ mime from github.com/prometheus/common/expfmt+ mime/multipart from github.com/go-openapi/swag+ mime/quotedprintable from mime/multipart diff --git a/cmd/stund/depaware.txt b/cmd/stund/depaware.txt index d1528c7ac..84fbe69b7 100644 --- a/cmd/stund/depaware.txt +++ b/cmd/stund/depaware.txt @@ -2,6 +2,12 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar github.com/beorn7/perks/quantile from github.com/prometheus/client_golang/prometheus 💣 github.com/cespare/xxhash/v2 from github.com/prometheus/client_golang/prometheus + github.com/go-json-experiment/json from tailscale.com/types/opt + github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json+ + github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json+ + github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+ + github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+ + github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+ github.com/google/uuid from tailscale.com/util/fastuuid 💣 github.com/prometheus/client_golang/prometheus from tailscale.com/tsweb/promvarz github.com/prometheus/client_golang/prometheus/internal from github.com/prometheus/client_golang/prometheus @@ -128,6 +134,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar embed from crypto/internal/nistec+ encoding from encoding/json+ encoding/asn1 from crypto/x509+ + encoding/base32 from github.com/go-json-experiment/json encoding/base64 from encoding/json+ encoding/binary from compress/gzip+ encoding/hex from crypto/x509+ diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index 7b3d09af8..80b011d04 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -9,6 +9,12 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep W 💣 github.com/dblohm7/wingoes from github.com/dblohm7/wingoes/pe+ W 💣 github.com/dblohm7/wingoes/pe from tailscale.com/util/winutil/authenticode github.com/fxamacker/cbor/v2 from tailscale.com/tka + github.com/go-json-experiment/json from tailscale.com/types/opt + github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json+ + github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json+ + github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json+ + github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json+ + github.com/go-json-experiment/json/jsontext from github.com/go-json-experiment/json+ github.com/golang/groupcache/lru from tailscale.com/net/dnscache L github.com/google/nftables from tailscale.com/util/linuxfw L 💣 github.com/google/nftables/alignedbuff from github.com/google/nftables/xt diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 6a8f53494..ee0bf1469 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -90,11 +90,12 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de 💣 github.com/djherbis/times from tailscale.com/drive/driveimpl github.com/fxamacker/cbor/v2 from tailscale.com/tka github.com/gaissmai/bart from tailscale.com/net/tstun+ + github.com/go-json-experiment/json from tailscale.com/types/opt github.com/go-json-experiment/json/internal from github.com/go-json-experiment/json/internal/jsonflags+ github.com/go-json-experiment/json/internal/jsonflags from github.com/go-json-experiment/json/internal/jsonopts+ - github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext - github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json/jsontext - github.com/go-json-experiment/json/jsontext from tailscale.com/logtail + github.com/go-json-experiment/json/internal/jsonopts from github.com/go-json-experiment/json/jsontext+ + github.com/go-json-experiment/json/internal/jsonwire from github.com/go-json-experiment/json/jsontext+ + github.com/go-json-experiment/json/jsontext from tailscale.com/logtail+ W 💣 github.com/go-ole/go-ole from github.com/go-ole/go-ole/oleutil+ W 💣 github.com/go-ole/go-ole/oleutil from tailscale.com/wgengine/winnet L 💣 github.com/godbus/dbus/v5 from tailscale.com/net/dns+ diff --git a/types/opt/value.go b/types/opt/value.go new file mode 100644 index 000000000..1781731a4 --- /dev/null +++ b/types/opt/value.go @@ -0,0 +1,122 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package opt + +import ( + "fmt" + "reflect" + + jsonv2 "github.com/go-json-experiment/json" + "github.com/go-json-experiment/json/jsontext" +) + +// Value is an optional value to be JSON-encoded. +// With [encoding/json], a zero Value is marshaled as a JSON null. +// With [github.com/go-json-experiment/json], a zero Value is omitted from the +// JSON object if the Go struct field specified with omitzero. +// The omitempty tag option should never be used with Value fields. +type Value[T any] struct { + value T + set bool +} + +// Equal reports whether the receiver and the other value are equal. +// If the template type T in Value[T] implements an Equal method, it will be used +// instead of the == operator for comparing values. +type equatable[T any] interface { + // Equal reports whether the receiver and the other values are equal. + Equal(other T) bool +} + +// ValueOf returns an optional Value containing the specified value. +// It treats nil slices and maps as empty slices and maps. +func ValueOf[T any](v T) Value[T] { + return Value[T]{value: v, set: true} +} + +// String implements [fmt.Stringer]. +func (o *Value[T]) String() string { + if !o.set { + return fmt.Sprintf("(empty[%T])", o.value) + } + return fmt.Sprint(o.value) +} + +// Set assigns the specified value to the optional value o. +func (o *Value[T]) Set(v T) { + *o = ValueOf(v) +} + +// Clear resets o to an empty state. +func (o *Value[T]) Clear() { + *o = Value[T]{} +} + +// IsSet reports whether o has a value set. +func (o *Value[T]) IsSet() bool { + return o.set +} + +// Get returns the value of o. +// If a value hasn't been set, a zero value of T will be returned. +func (o Value[T]) Get() T { + return o.value +} + +// Get returns the value and a flag indicating whether the value is set. +func (o Value[T]) GetOk() (v T, ok bool) { + return o.value, o.set +} + +// Equal reports whether o is equal to v. +// Two optional values are equal if both are empty, +// or if both are set and the underlying values are equal. +// If the template type T implements an Equal(T) bool method, it will be used +// instead of the == operator for value comparison. +// If T is not comparable, it returns false. +func (o Value[T]) Equal(v Value[T]) bool { + if o.set != v.set { + return false + } + if !o.set { + return true + } + ov := any(o.value) + if eq, ok := ov.(equatable[T]); ok { + return eq.Equal(v.value) + } + if reflect.TypeFor[T]().Comparable() { + return ov == any(v.value) + } + return false +} + +// MarshalJSONV2 implements [jsonv2.MarshalerV2]. +func (o Value[T]) MarshalJSONV2(enc *jsontext.Encoder, opts jsonv2.Options) error { + if !o.set { + return enc.WriteToken(jsontext.Null) + } + return jsonv2.MarshalEncode(enc, &o.value, opts) +} + +// UnmarshalJSONV2 implements [jsonv2.UnmarshalerV2]. +func (o *Value[T]) UnmarshalJSONV2(dec *jsontext.Decoder, opts jsonv2.Options) error { + if dec.PeekKind() == 'n' { + *o = Value[T]{} + _, err := dec.ReadToken() // read null + return err + } + o.set = true + return jsonv2.UnmarshalDecode(dec, &o.value, opts) +} + +// MarshalJSON implements [json.Marshaler]. +func (o Value[T]) MarshalJSON() ([]byte, error) { + return jsonv2.Marshal(o) // uses MarshalJSONV2 +} + +// UnmarshalJSON implements [json.Unmarshaler]. +func (o *Value[T]) UnmarshalJSON(b []byte) error { + return jsonv2.Unmarshal(b, o) // uses UnmarshalJSONV2 +} diff --git a/types/opt/value_test.go b/types/opt/value_test.go new file mode 100644 index 000000000..93d935e27 --- /dev/null +++ b/types/opt/value_test.go @@ -0,0 +1,296 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package opt + +import ( + "encoding/json" + "reflect" + "testing" + + jsonv2 "github.com/go-json-experiment/json" +) + +type testStruct struct { + Int int `json:",omitempty,omitzero"` + Str string `json:",omitempty"` +} + +func TestValue(t *testing.T) { + tests := []struct { + name string + in any + jsonv2 bool + want string // JSON + wantBack any + }{ + { + name: "null_for_unset", + in: struct { + True Value[bool] + False Value[bool] + Unset Value[bool] + ExplicitUnset Value[bool] + }{ + True: ValueOf(true), + False: ValueOf(false), + ExplicitUnset: Value[bool]{}, + }, + want: `{"True":true,"False":false,"Unset":null,"ExplicitUnset":null}`, + wantBack: struct { + True Value[bool] + False Value[bool] + Unset Value[bool] + ExplicitUnset Value[bool] + }{ + True: ValueOf(true), + False: ValueOf(false), + Unset: Value[bool]{}, + ExplicitUnset: Value[bool]{}, + }, + }, + { + name: "null_for_unset_jsonv2", + in: struct { + True Value[bool] + False Value[bool] + Unset Value[bool] + ExplicitUnset Value[bool] + }{ + True: ValueOf(true), + False: ValueOf(false), + ExplicitUnset: Value[bool]{}, + }, + jsonv2: true, + want: `{"True":true,"False":false,"Unset":null,"ExplicitUnset":null}`, + wantBack: struct { + True Value[bool] + False Value[bool] + Unset Value[bool] + ExplicitUnset Value[bool] + }{ + True: ValueOf(true), + False: ValueOf(false), + Unset: Value[bool]{}, + ExplicitUnset: Value[bool]{}, + }, + }, + { + name: "null_for_unset_omitzero", + in: struct { + True Value[bool] `json:",omitzero"` + False Value[bool] `json:",omitzero"` + Unset Value[bool] `json:",omitzero"` + ExplicitUnset Value[bool] `json:",omitzero"` + }{ + True: ValueOf(true), + False: ValueOf(false), + ExplicitUnset: Value[bool]{}, + }, + want: `{"True":true,"False":false,"Unset":null,"ExplicitUnset":null}`, + wantBack: struct { + True Value[bool] `json:",omitzero"` + False Value[bool] `json:",omitzero"` + Unset Value[bool] `json:",omitzero"` + ExplicitUnset Value[bool] `json:",omitzero"` + }{ + True: ValueOf(true), + False: ValueOf(false), + Unset: Value[bool]{}, + ExplicitUnset: Value[bool]{}, + }, + }, + { + name: "null_for_unset_omitzero_jsonv2", + in: struct { + True Value[bool] `json:",omitzero"` + False Value[bool] `json:",omitzero"` + Unset Value[bool] `json:",omitzero"` + ExplicitUnset Value[bool] `json:",omitzero"` + }{ + True: ValueOf(true), + False: ValueOf(false), + ExplicitUnset: Value[bool]{}, + }, + jsonv2: true, + want: `{"True":true,"False":false}`, + wantBack: struct { + True Value[bool] `json:",omitzero"` + False Value[bool] `json:",omitzero"` + Unset Value[bool] `json:",omitzero"` + ExplicitUnset Value[bool] `json:",omitzero"` + }{ + True: ValueOf(true), + False: ValueOf(false), + Unset: Value[bool]{}, + ExplicitUnset: Value[bool]{}, + }, + }, + { + name: "string", + in: struct { + EmptyString Value[string] + NonEmpty Value[string] + Unset Value[string] + }{ + EmptyString: ValueOf(""), + NonEmpty: ValueOf("value"), + Unset: Value[string]{}, + }, + want: `{"EmptyString":"","NonEmpty":"value","Unset":null}`, + wantBack: struct { + EmptyString Value[string] + NonEmpty Value[string] + Unset Value[string] + }{ValueOf(""), ValueOf("value"), Value[string]{}}, + }, + { + name: "integer", + in: struct { + Zero Value[int] + NonZero Value[int] + Unset Value[int] + }{ + Zero: ValueOf(0), + NonZero: ValueOf(42), + Unset: Value[int]{}, + }, + want: `{"Zero":0,"NonZero":42,"Unset":null}`, + wantBack: struct { + Zero Value[int] + NonZero Value[int] + Unset Value[int] + }{ValueOf(0), ValueOf(42), Value[int]{}}, + }, + { + name: "struct", + in: struct { + Zero Value[testStruct] + NonZero Value[testStruct] + Unset Value[testStruct] + }{ + Zero: ValueOf(testStruct{}), + NonZero: ValueOf(testStruct{Int: 42, Str: "String"}), + Unset: Value[testStruct]{}, + }, + want: `{"Zero":{},"NonZero":{"Int":42,"Str":"String"},"Unset":null}`, + wantBack: struct { + Zero Value[testStruct] + NonZero Value[testStruct] + Unset Value[testStruct] + }{ValueOf(testStruct{}), ValueOf(testStruct{Int: 42, Str: "String"}), Value[testStruct]{}}, + }, + { + name: "struct_ptr", + in: struct { + Zero Value[*testStruct] + NonZero Value[*testStruct] + Unset Value[*testStruct] + }{ + Zero: ValueOf(&testStruct{}), + NonZero: ValueOf(&testStruct{Int: 42, Str: "String"}), + Unset: Value[*testStruct]{}, + }, + want: `{"Zero":{},"NonZero":{"Int":42,"Str":"String"},"Unset":null}`, + wantBack: struct { + Zero Value[*testStruct] + NonZero Value[*testStruct] + Unset Value[*testStruct] + }{ValueOf(&testStruct{}), ValueOf(&testStruct{Int: 42, Str: "String"}), Value[*testStruct]{}}, + }, + { + name: "nil-slice-and-map", + in: struct { + Slice Value[[]int] + Map Value[map[string]int] + }{ + Slice: ValueOf[[]int](nil), // marshalled as [] + Map: ValueOf[map[string]int](nil), // marshalled as {} + }, + want: `{"Slice":[],"Map":{}}`, + wantBack: struct { + Slice Value[[]int] + Map Value[map[string]int] + }{ValueOf([]int{}), ValueOf(map[string]int{})}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var j []byte + var err error + if tt.jsonv2 { + j, err = jsonv2.Marshal(tt.in) + } else { + j, err = json.Marshal(tt.in) + } + if err != nil { + t.Fatal(err) + } + if string(j) != tt.want { + t.Errorf("wrong JSON:\n got: %s\nwant: %s\n", j, tt.want) + } + + wantBack := tt.in + if tt.wantBack != nil { + wantBack = tt.wantBack + } + // And back again: + newVal := reflect.New(reflect.TypeOf(tt.in)) + out := newVal.Interface() + if tt.jsonv2 { + err = jsonv2.Unmarshal(j, out) + } else { + err = json.Unmarshal(j, out) + } + if err != nil { + t.Fatalf("Unmarshal %#q: %v", j, err) + } + got := newVal.Elem().Interface() + if !reflect.DeepEqual(got, wantBack) { + t.Errorf("value mismatch\n got: %+v\nwant: %+v\n", got, wantBack) + } + }) + } +} + +func TestValueEqual(t *testing.T) { + tests := []struct { + o Value[bool] + v Value[bool] + want bool + }{ + {ValueOf(true), ValueOf(true), true}, + {ValueOf(true), ValueOf(false), false}, + {ValueOf(true), Value[bool]{}, false}, + {ValueOf(false), ValueOf(false), true}, + {ValueOf(false), ValueOf(true), false}, + {ValueOf(false), Value[bool]{}, false}, + {Value[bool]{}, Value[bool]{}, true}, + {Value[bool]{}, ValueOf(true), false}, + {Value[bool]{}, ValueOf(false), false}, + } + for _, tt := range tests { + if got := tt.o.Equal(tt.v); got != tt.want { + t.Errorf("(%v).Equals(%v) = %v; want %v", tt.o, tt.v, got, tt.want) + } + } +} + +func TestIncomparableValueEqual(t *testing.T) { + tests := []struct { + o Value[[]bool] + v Value[[]bool] + want bool + }{ + {ValueOf([]bool{}), ValueOf([]bool{}), false}, + {ValueOf([]bool{true}), ValueOf([]bool{true}), false}, + {Value[[]bool]{}, ValueOf([]bool{}), false}, + {ValueOf([]bool{}), Value[[]bool]{}, false}, + {Value[[]bool]{}, Value[[]bool]{}, true}, + } + for _, tt := range tests { + if got := tt.o.Equal(tt.v); got != tt.want { + t.Errorf("(%v).Equals(%v) = %v; want %v", tt.o, tt.v, got, tt.want) + } + } +}