Skip to content
This repository was archived by the owner on Mar 9, 2022. It is now read-only.

feat: add an OptionalDuration type #148

Merged
merged 5 commits into from
Oct 27, 2021
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion autonat.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,5 +77,5 @@ type AutoNATThrottleConfig struct {
// global/peer dialback limits.
//
// When unset, this defaults to 1 minute.
Interval Duration `json:",omitempty"`
Interval Duration
Copy link
Member

@lidel lidel Oct 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small UX issue after testing it in go-ipfs's optional-duration branch

Setting works as expected via ipfs config AutoNAT.Throttle.Interval 1s, but to unset I had to execute ipfs config AutoNAT.Throttle.Interval null which produced an awkward JSON with "null" (a string), not an actual null (without quotes):

 "AutoNAT": {
    "Throttle": {
      "GlobalLimit": 0,
      "Interval": "null",
      "PeerLimit": 0
    }
  },

Not the best way of indicating "default" value.

I've added more tests and support for "" (empty) string in c9b7984 so users don't break their nodes when they remove custom value by hand, but when done via CLI getting the "null" is awkward.

Is there a way to get "" or null or "default" instead of "null" ?
The "default" is the most user-friendly and would be preferable in my mind.

I'll look into this and push more tests. We should be liberal with values that we interpret as "use default" + serialize "default" to something more intuitive than "null" (with quotes).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we change the logic for marshaling the default in MarshalText:

func (d Duration) MarshalText() ([]byte, error) {
	if d.value != nil {
		return []byte(d.value.String()), nil
	}
	return []byte("default"), nil
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps switching to (Un)MarshalJSON rather than text would help you out here.

@Stebalien any context for why you went with Text rather than JSON originally?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I switched to *JSON in 16af6f8 – *Text was probably used to avoid handling double quotes, but now that we want explicit string as the default (instead of null) *JSON is easier.

Copy link
Member

@lidel lidel Oct 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anyway, the mentioned change removed some paper cuts.

Took it for a spin and now, no matter how user tries to restore default and ruin their day..

$ ipfs config AutoNAT.Throttle.Interval ""
$ ipfs config AutoNAT.Throttle.Interval null
$ ipfs config --json AutoNAT.Throttle.Interval '"null"'
$ ipfs config AutoNAT.Throttle.Interval default

..for all the above (done via CLI or a manual edit of JSON config) the Duration will correctly serialize to:

 "AutoNAT": {
    "Throttle": {
      "Interval": "default",

Copy link
Member

@lidel lidel Oct 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh.. so I've played with this a bit while testing Swarm.RelayService config from ipfs/kubo#8522 and we will have Field: null all over the place due to OptionalInteger and Flag anyway.
We can't have "default" string for those, so I think its better to unify and switch Duration to use null as well. At least it will be consistent and easy to document.
It is also what OptionalString from #149 will do.

The default will now look like this:

 "AutoNAT": {
   "Throttle": {
     "Interval": null,

}
53 changes: 41 additions & 12 deletions types.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package config

import (
"encoding"
"encoding/json"
"fmt"
"strings"
"time"
)

Expand Down Expand Up @@ -213,25 +213,54 @@ var _ json.Marshaler = (*Priority)(nil)

// Duration wraps time.Duration to provide json serialization and deserialization.
//
// NOTE: the zero value encodes to an empty string.
type Duration time.Duration
// NOTE: the zero value encodes to "default" string.
type Duration struct {
value *time.Duration
}

func (d *Duration) UnmarshalJSON(input []byte) error {
switch string(input) {
case "null", "undefined", "\"null\"", "", "default", "\"\"", "\"default\"":
*d = Duration{}
return nil
default:
text := strings.Trim(string(input), "\"")
value, err := time.ParseDuration(text)
if err != nil {
return err
}
*d = Duration{value: &value}
return nil
}
}

func (d *Duration) IsDefault() bool {
return d.value == nil
}

func (d *Duration) UnmarshalText(text []byte) error {
dur, err := time.ParseDuration(string(text))
*d = Duration(dur)
return err
func (d *Duration) WithDefault(defaultValue time.Duration) time.Duration {
if d.value == nil {
return defaultValue
}
return *d.value
}

func (d Duration) MarshalText() ([]byte, error) {
return []byte(time.Duration(d).String()), nil
func (d Duration) MarshalJSON() ([]byte, error) {
if d.value == nil {
return json.Marshal("default")
}
return json.Marshal(d.value.String())
}

func (d Duration) String() string {
return time.Duration(d).String()
if d.value == nil {
return "default"
}
return d.value.String()
}

var _ encoding.TextUnmarshaler = (*Duration)(nil)
var _ encoding.TextMarshaler = (*Duration)(nil)
var _ json.Unmarshaler = (*Duration)(nil)
var _ json.Marshaler = (*Duration)(nil)

// OptionalInteger represents an integer that has a default value
//
Expand Down
118 changes: 91 additions & 27 deletions types_test.go
Original file line number Diff line number Diff line change
@@ -1,40 +1,104 @@
package config

import (
"bytes"
"encoding/json"
"testing"
"time"
)

func TestDuration(t *testing.T) {
out, err := json.Marshal(Duration(time.Second))
if err != nil {
t.Fatal(err)
makeDurationPointer := func(d time.Duration) *time.Duration { return &d }

}
expected := "\"1s\""
if string(out) != expected {
t.Fatalf("expected %s, got %s", expected, string(out))
}
var d Duration
err = json.Unmarshal(out, &d)
if err != nil {
t.Fatal(err)
}
if time.Duration(d) != time.Second {
t.Fatal("expected a second")
}
type Foo struct {
D Duration `json:",omitempty"`
}
out, err = json.Marshal(new(Foo))
if err != nil {
t.Fatal(err)
}
expected = "{}"
if string(out) != expected {
t.Fatal("expected omitempty to omit the duration")
}
t.Run("marshalling and unmarshalling", func(t *testing.T) {
out, err := json.Marshal(Duration{value: makeDurationPointer(time.Second)})
if err != nil {
t.Fatal(err)
}
expected := "\"1s\""
if string(out) != expected {
t.Fatalf("expected %s, got %s", expected, string(out))
}
var d Duration

if err := json.Unmarshal(out, &d); err != nil {
t.Fatal(err)
}
if *d.value != time.Second {
t.Fatal("expected a second")
}
})

t.Run("default value", func(t *testing.T) {
for _, jsonStr := range []string{"null", "\"\"", "\"default\""} {
var d Duration
if !d.IsDefault() {
t.Fatal("expected value to be the default initially")
}
if err := json.Unmarshal([]byte(jsonStr), &d); err != nil {
t.Fatalf("%s failed to unmarshall with %s", jsonStr, err)
}
if dur := d.WithDefault(time.Hour); dur != time.Hour {
t.Fatalf("expected default value to be used, got %s", dur)
}
if !d.IsDefault() {
t.Fatal("expected value to be the default")
}
}
})

t.Run("omitempty", func(t *testing.T) {
type Foo struct {
D *Duration `json:",omitempty"`
}
out, err := json.Marshal(new(Foo))
if err != nil {
t.Fatal(err)
}
if string(out) != "{}" {
t.Fatalf("expected omitempty to omit the duration, got %s", out)
}
})

t.Run("roundtrip including the default values", func(t *testing.T) {
for jsonStr, goValue := range map[string]Duration{
// there are various footguns user can hit, normalize them to the canonical default
"null": {}, // JSON null → default value
"\"null\"": {}, // JSON string "null" sent/set by "ipfs config" cli → default value
"\"default\"": {}, // explicit "default" as string
"\"\"": {}, // user removed custom value, empty string should also parse as default
"\"1s\"": {value: makeDurationPointer(time.Second)},
"\"42h1m3s\"": {value: makeDurationPointer(42*time.Hour + 1*time.Minute + 3*time.Second)},
} {
var d Duration
err := json.Unmarshal([]byte(jsonStr), &d)
if err != nil {
t.Fatal(err)
}

if goValue.value == nil && d.value == nil {
} else if goValue.value == nil && d.value != nil {
t.Errorf("expected nil for %s, got %s", jsonStr, d)
} else if *d.value != *goValue.value {
t.Fatalf("expected %s for %s, got %s", goValue, jsonStr, d)
}

// Test Reverse
out, err := json.Marshal(goValue)
if err != nil {
t.Fatal(err)
}
if goValue.value == nil {
if !bytes.Equal(out, []byte("\"default\"")) {
t.Fatalf("expected default string for %s, got %s", jsonStr, string(out))
}
continue
}
if string(out) != jsonStr {
t.Fatalf("expected %s, got %s", jsonStr, string(out))
}
}
})
}

func TestOneStrings(t *testing.T) {
Expand Down