Browse Source

tstime: add GoDuration which JSON serializes with time.Duration.String (#15726)

The encoding/json/v2 effort may end up changing
the default represention of time.Duration in JSON.
See https://go.dev/issue/71631

The GoDuration type allows us to explicitly use
the time.Duration.String representation regardless of
whether we serialize with v1 or v2 of encoding/json.

Updates tailscale/corp#27502

Signed-off-by: Joe Tsai <[email protected]>
Joe Tsai 10 months ago
parent
commit
aff8f1b358
2 changed files with 55 additions and 0 deletions
  1. 38 0
      tstime/tstime.go
  2. 17 0
      tstime/tstime_test.go

+ 38 - 0
tstime/tstime.go

@@ -6,6 +6,7 @@ package tstime
 
 import (
 	"context"
+	"encoding"
 	"strconv"
 	"strings"
 	"time"
@@ -183,3 +184,40 @@ func (StdClock) AfterFunc(d time.Duration, f func()) TimerController {
 func (StdClock) Since(t time.Time) time.Duration {
 	return time.Since(t)
 }
+
+// GoDuration is a [time.Duration] but JSON serializes with [time.Duration.String].
+//
+// Note that this format is specific to Go and non-standard,
+// but excels in being most humanly readable compared to alternatives.
+// The wider industry still lacks consensus for the representation
+// of a time duration in humanly-readable text.
+// See https://go.dev/issue/71631 for more discussion.
+//
+// Regardless of how the industry evolves into the future,
+// this type explicitly uses the Go format.
+type GoDuration struct{ time.Duration }
+
+var (
+	_ encoding.TextAppender    = (*GoDuration)(nil)
+	_ encoding.TextMarshaler   = (*GoDuration)(nil)
+	_ encoding.TextUnmarshaler = (*GoDuration)(nil)
+)
+
+func (d GoDuration) AppendText(b []byte) ([]byte, error) {
+	// The String method is inlineable (see https://go.dev/cl/520602),
+	// so this may not allocate since the string does not escape.
+	return append(b, d.String()...), nil
+}
+
+func (d GoDuration) MarshalText() ([]byte, error) {
+	return []byte(d.String()), nil
+}
+
+func (d *GoDuration) UnmarshalText(b []byte) error {
+	d2, err := time.ParseDuration(string(b))
+	if err != nil {
+		return err
+	}
+	d.Duration = d2
+	return nil
+}

+ 17 - 0
tstime/tstime_test.go

@@ -4,8 +4,11 @@
 package tstime
 
 import (
+	"encoding/json"
 	"testing"
 	"time"
+
+	"tailscale.com/util/must"
 )
 
 func TestParseDuration(t *testing.T) {
@@ -34,3 +37,17 @@ func TestParseDuration(t *testing.T) {
 		}
 	}
 }
+
+func TestGoDuration(t *testing.T) {
+	wantDur := GoDuration{time.Hour + time.Minute + time.Second + time.Millisecond + time.Microsecond + time.Nanosecond}
+	gotJSON := string(must.Get(json.Marshal(wantDur)))
+	wantJSON := `"1h1m1.001001001s"`
+	if gotJSON != wantJSON {
+		t.Errorf("json.Marshal(%v) = %s, want %s", wantDur, gotJSON, wantJSON)
+	}
+	var gotDur GoDuration
+	must.Do(json.Unmarshal([]byte(wantJSON), &gotDur))
+	if gotDur != wantDur {
+		t.Errorf("json.Unmarshal(%s) = %v, want %v", wantJSON, gotDur, wantDur)
+	}
+}