go-ipfs-config: feat: add an OptionalDuration type (#148)
* feat: make it possible to define optional durations * test: empty/default optional durations does not crash if user restores default value and sets it to empty string "" * refactor: use null in JSON * refactor(duration): use JSON null as the default Rationale: https://github.com/ipfs/go-ipfs-config/pull/148#discussion_r736975879 * refactor: Duration → OptionalDuration This makes it possible to use OptionalDuration with `json:",omitempty"` so the null is not serialized to JSON, and get working WithDefault as well. Co-authored-by: Marcin Rataj <lidel@lidel.org>
Marten Seemann committed
Oct 27, 2021 at 18:23 UTC
2cf170d5fae00da6625bb0831beb5c67a4f71ee1
3 files changed
+160
-43
config/autonat.go
+1
-1
@@ -77,5 +77,5 @@ type AutoNATThrottleConfig struct {
77
// global/peer dialback limits.
78
//
79
// When unset, this defaults to 1 minute.
80
- Interval Duration `json:",omitempty"`
80
+ Interval OptionalDuration `json:",omitempty"`
81
}
config/types.go
+43
-14
@@ -1,9 +1,9 @@
1
package config
2
3
import (
4
- "encoding"
4
"encoding/json"
5
"fmt"
6
+ "strings"
7
"time"
8
)
9
@@ -211,27 +211,56 @@ func (p Priority) String() string {
211
var _ json.Unmarshaler = (*Priority)(nil)
212
var _ json.Marshaler = (*Priority)(nil)
213
214
-// Duration wraps time.Duration to provide json serialization and deserialization.
214
+// OptionalDuration wraps time.Duration to provide json serialization and deserialization.
215
//
216
-// NOTE: the zero value encodes to an empty string.
217
-type Duration time.Duration
216
+// NOTE: the zero value encodes to JSON nill
217
+type OptionalDuration struct {
218
+ value *time.Duration
219
+}
220
219
-func (d *Duration) UnmarshalText(text []byte) error {
220
- dur, err := time.ParseDuration(string(text))
221
- *d = Duration(dur)
222
- return err
221
+func (d *OptionalDuration) UnmarshalJSON(input []byte) error {
222
+ switch string(input) {
223
+ case "null", "undefined", "\"null\"", "", "default", "\"\"", "\"default\"":
224
+ *d = OptionalDuration{}
225
+ return nil
226
+ default:
227
+ text := strings.Trim(string(input), "\"")
228
+ value, err := time.ParseDuration(text)
229
+ if err != nil {
230
+ return err
231
+ }
232
+ *d = OptionalDuration{value: &value}
233
+ return nil
234
+ }
235
}
236
225
-func (d Duration) MarshalText() ([]byte, error) {
226
- return []byte(time.Duration(d).String()), nil
237
+func (d *OptionalDuration) IsDefault() bool {
238
+ return d == nil || d.value == nil
239
}
240
229
-func (d Duration) String() string {
230
- return time.Duration(d).String()
241
+func (d *OptionalDuration) WithDefault(defaultValue time.Duration) time.Duration {
242
+ if d == nil || d.value == nil {
243
+ return defaultValue
244
+ }
245
+ return *d.value
246
+}
247
+
248
+func (d OptionalDuration) MarshalJSON() ([]byte, error) {
249
+ if d.value == nil {
250
+ return json.Marshal(nil)
251
+ }
252
+ return json.Marshal(d.value.String())
253
+}
254
+
255
+func (d OptionalDuration) String() string {
256
+ if d.value == nil {
257
+ return "default"
258
+ }
259
+ return d.value.String()
260
}
261
233
-var _ encoding.TextUnmarshaler = (*Duration)(nil)
234
-var _ encoding.TextMarshaler = (*Duration)(nil)
262
+var _ json.Unmarshaler = (*OptionalDuration)(nil)
263
+var _ json.Marshaler = (*OptionalDuration)(nil)
264
265
// OptionalInteger represents an integer that has a default value
266
//
config/types_test.go
+116
-28
@@ -1,40 +1,128 @@
1
package config
2
3
import (
4
+ "bytes"
5
"encoding/json"
6
"testing"
7
"time"
8
)
9
9
-func TestDuration(t *testing.T) {
10
- out, err := json.Marshal(Duration(time.Second))
11
- if err != nil {
12
- t.Fatal(err)
10
+func TestOptionalDuration(t *testing.T) {
11
+ makeDurationPointer := func(d time.Duration) *time.Duration { return &d }
12
14
- }
15
- expected := "\"1s\""
16
- if string(out) != expected {
17
- t.Fatalf("expected %s, got %s", expected, string(out))
18
- }
19
- var d Duration
20
- err = json.Unmarshal(out, &d)
21
- if err != nil {
22
- t.Fatal(err)
23
- }
24
- if time.Duration(d) != time.Second {
25
- t.Fatal("expected a second")
26
- }
27
- type Foo struct {
28
- D Duration `json:",omitempty"`
29
- }
30
- out, err = json.Marshal(new(Foo))
31
- if err != nil {
32
- t.Fatal(err)
33
- }
34
- expected = "{}"
35
- if string(out) != expected {
36
- t.Fatal("expected omitempty to omit the duration")
37
- }
13
+ t.Run("marshalling and unmarshalling", func(t *testing.T) {
14
+ out, err := json.Marshal(OptionalDuration{value: makeDurationPointer(time.Second)})
15
+ if err != nil {
16
+ t.Fatal(err)
17
+ }
18
+ expected := "\"1s\""
19
+ if string(out) != expected {
20
+ t.Fatalf("expected %s, got %s", expected, string(out))
21
+ }
22
+ var d OptionalDuration
23
+
24
+ if err := json.Unmarshal(out, &d); err != nil {
25
+ t.Fatal(err)
26
+ }
27
+ if *d.value != time.Second {
28
+ t.Fatal("expected a second")
29
+ }
30
+ })
31
+
32
+ t.Run("default value", func(t *testing.T) {
33
+ for _, jsonStr := range []string{"null", "\"null\"", "\"\"", "\"default\""} {
34
+ var d OptionalDuration
35
+ if !d.IsDefault() {
36
+ t.Fatal("expected value to be the default initially")
37
+ }
38
+ if err := json.Unmarshal([]byte(jsonStr), &d); err != nil {
39
+ t.Fatalf("%s failed to unmarshall with %s", jsonStr, err)
40
+ }
41
+ if dur := d.WithDefault(time.Hour); dur != time.Hour {
42
+ t.Fatalf("expected default value to be used, got %s", dur)
43
+ }
44
+ if !d.IsDefault() {
45
+ t.Fatal("expected value to be the default")
46
+ }
47
+ }
48
+ })
49
+
50
+ t.Run("omitempty with default value", func(t *testing.T) {
51
+ type Foo struct {
52
+ D *OptionalDuration `json:",omitempty"`
53
+ }
54
+ // marshall to JSON without empty field
55
+ out, err := json.Marshal(new(Foo))
56
+ if err != nil {
57
+ t.Fatal(err)
58
+ }
59
+ if string(out) != "{}" {
60
+ t.Fatalf("expected omitempty to omit the duration, got %s", out)
61
+ }
62
+ // unmarshall missing value and get the default
63
+ var foo2 Foo
64
+ if err := json.Unmarshal(out, &foo2); err != nil {
65
+ t.Fatalf("%s failed to unmarshall with %s", string(out), err)
66
+ }
67
+ if dur := foo2.D.WithDefault(time.Hour); dur != time.Hour {
68
+ t.Fatalf("expected default value to be used, got %s", dur)
69
+ }
70
+ if !foo2.D.IsDefault() {
71
+ t.Fatal("expected value to be the default")
72
+ }
73
+ })
74
+
75
+ t.Run("roundtrip including the default values", func(t *testing.T) {
76
+ for jsonStr, goValue := range map[string]OptionalDuration{
77
+ // there are various footguns user can hit, normalize them to the canonical default
78
+ "null": {}, // JSON null → default value
79
+ "\"null\"": {}, // JSON string "null" sent/set by "ipfs config" cli → default value
80
+ "\"default\"": {}, // explicit "default" as string
81
+ "\"\"": {}, // user removed custom value, empty string should also parse as default
82
+ "\"1s\"": {value: makeDurationPointer(time.Second)},
83
+ "\"42h1m3s\"": {value: makeDurationPointer(42*time.Hour + 1*time.Minute + 3*time.Second)},
84
+ } {
85
+ var d OptionalDuration
86
+ err := json.Unmarshal([]byte(jsonStr), &d)
87
+ if err != nil {
88
+ t.Fatal(err)
89
+ }
90
+
91
+ if goValue.value == nil && d.value == nil {
92
+ } else if goValue.value == nil && d.value != nil {
93
+ t.Errorf("expected nil for %s, got %s", jsonStr, d)
94
+ } else if *d.value != *goValue.value {
95
+ t.Fatalf("expected %s for %s, got %s", goValue, jsonStr, d)
96
+ }
97
+
98
+ // Test Reverse
99
+ out, err := json.Marshal(goValue)
100
+ if err != nil {
101
+ t.Fatal(err)
102
+ }
103
+ if goValue.value == nil {
104
+ if !bytes.Equal(out, []byte("null")) {
105
+ t.Fatalf("expected JSON null for %s, got %s", jsonStr, string(out))
106
+ }
107
+ continue
108
+ }
109
+ if string(out) != jsonStr {
110
+ t.Fatalf("expected %s, got %s", jsonStr, string(out))
111
+ }
112
+ }
113
+ })
114
+
115
+ t.Run("invalid duration values", func(t *testing.T) {
116
+ for _, invalid := range []string{
117
+ "\"s\"", "\"1ę\"", "\"-1\"", "\"1H\"", "\"day\"",
118
+ } {
119
+ var d OptionalDuration
120
+ err := json.Unmarshal([]byte(invalid), &d)
121
+ if err == nil {
122
+ t.Errorf("expected to fail to decode %s as an OptionalDuration, got %s instead", invalid, d)
123
+ }
124
+ }
125
+ })
126
}
127
128
func TestOneStrings(t *testing.T) {