Documentation
¶
Overview ¶
Package jsonduration gives time.Duration a JSON representation in encoding/json/v2.
encoding/json/v2 cannot encode a time.Duration on its own. It has no default representation (https://go.dev/issue/71631), and the `format` struct tag that used to select one was removed in Go 1.27 (https://go.dev/issue/79071). This package fills that gap, for encoding/json/v2 only, in two ways.
One option per call ¶
With returns options that apply one format to every time.Duration in a call: in struct fields, pointers, slices, arrays, map keys and values, and, when marshaling, in values held in an any. Your types stay as they are.
opts := jsonduration.With(jsonduration.ISO8601)
b, err := json.Marshal(v, opts) // {"ttl":"PT1H30M"}
err = json.Unmarshal(b, &v, opts)
One wrapper type per field ¶
Each format also has a wrapper type, such as ISO8601Duration or SecDuration. It embeds a time.Duration, encodes itself without options, and writes and reads only its own format: With, AlsoAccept, and AcceptDays do not change it. If a field needs one of them, write a wrapper type of your own, as the example of AcceptDays shows.
type Config struct {
Timeout jsonduration.ISO8601Duration `json:"timeout"`
Backoff jsonduration.SecDuration `json:"backoff"`
}
Formats ¶
The six formats are those that encoding/json/v2 offered through `format`, with the same names and output: Units, Sec, Milli, Micro, Nano, and ISO8601. Nano writes what encoding/json (v1) writes. A tag option such as `format:sec` is now an error: remove it, and pass With(Sec) or make the field a SecDuration.
In every numeric format, decoders that read JSON numbers as float64, such as JavaScript's JSON.parse, can lose the nanoseconds of long durations. So can encoding with jsontext.CanonicalizeRawInts or CanonicalizeRawFloats, which round numbers through float64.
A Format can come from a JSON configuration or a flag, since it implements encoding.TextUnmarshaler and accepts only the exact names. Give it a default for a configuration that names none, and check a Format converted from a plain string with Format.Valid.
Strictness ¶
Decoding accepts only the configured format. To also read other formats, for example while migrating, name them with AlsoAccept.
ISO8601 rejects years, months, weeks, and days with ErrCalendarUnits, since their length varies. Where a producer means 24 hours by a day, AcceptDays reads days and weeks. A count of zero, as in "P0D", is always read: it is no time at all, whatever the length of the unit.
The numeric formats read JSON numbers without an exponent, such as 5400.5 but not 5.4005e3. Every format reads decimals exactly, without floating-point rounding, and drops digits below one nanosecond.
Errors ¶
An error means bad input, and a panic means code that uses this package wrongly.
Every error about a duration that could not be read, from Format.Parse or from JSON decoding, matches ErrInvalidDuration. It names the input, the accepted formats, and the cause:
invalid duration "90s" in format iso8601: invalid syntax
The cause is strconv.ErrSyntax, strconv.ErrRange, or ErrCalendarUnits, which the error wraps. With formats from AlsoAccept, it is a syntax error only if every format rejects the syntax. A JSON value of the wrong type, such as a number for ISO8601, has a cause of its own, which matches ErrWrongType and reads "want a JSON string". JSON decoding returns the error inside a json.SemanticError, as encoding/json/v2 does for other types. Its JSONPointer tells where in the input the duration is. A format name that Format.UnmarshalText does not know is an error that lists the valid names.
An invalid Format makes With, Marshalers, Unmarshalers, Format.Format, and Format.Parse panic, also when it was passed to AlsoAccept. So does accepting two numeric formats, as in With(Sec, AlsoAccept(Nano)). Only Format.MarshalText returns an error instead, as encoding.TextMarshaler requires.
Good to know ¶
Named types such as `type TTL time.Duration` are distinct types, so With does not affect them, and encoding/json/v2 writes them as plain integers of nanoseconds. To omit zero durations, use the `omitzero` tag option, since `omitempty` only omits values that encode as empty JSON. The `string` tag option quotes numeric formats, like json.StringifyNumbers, and has no effect on string formats. An explicit json.StringifyNumbers(false) turns it off for durations, unlike for other numbers, since json.GetOption then does not report the tag option.
Example ¶
package main
import (
"encoding/json/v2"
"fmt"
"time"
"github.com/pscheid92/jsonduration"
)
func main() {
type Config struct {
Timeout time.Duration `json:"timeout"`
Retry []time.Duration `json:"retry"`
Grace *time.Duration `json:"grace,omitzero"`
}
opts := jsonduration.With(jsonduration.ISO8601)
cfg := Config{
Timeout: 90 * time.Second,
Retry: []time.Duration{time.Second, 2500 * time.Millisecond},
}
b, err := json.Marshal(cfg, opts)
fmt.Println(string(b), err)
var c Config
err = json.Unmarshal([]byte(`{"timeout":"PT2M","retry":["PT0.5S"]}`), &c, opts)
fmt.Println(c.Timeout, c.Retry, err)
}
Output: {"timeout":"PT1M30S","retry":["PT1S","PT2.5S"]} <nil> 2m0s [500ms] <nil>
Index ¶
- Variables
- func Marshalers(f Format) *json.Marshalers
- func Unmarshalers(f Format, opts ...DecodeOption) *json.Unmarshalers
- func With(f Format, opts ...DecodeOption) json.Options
- type DecodeOption
- type Format
- type ISO8601Duration
- type MicroDuration
- type MilliDuration
- type NanoDuration
- type SecDuration
- type UnitsDuration
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ErrCalendarUnits = errors.New("calendar units have no fixed length")
ErrCalendarUnits is wrapped by the error for an ISO 8601 duration in years, months, weeks, or days, such as "P1D" or "P0.5D". Their length varies, a month from 28 to 31 days and a day from 23 to 25 hours, so they have no exact time.Duration. Where a day means 24 hours, AcceptDays reads days and weeks.
A count of zero is not an error, since it is no time at all whatever the length of the unit: "P0D" and "P0Y0M0DT1H" are read in every mode. Some producers write zero as "P0D", and some write every component.
var ErrInvalidDuration = errors.New("invalid duration")
ErrInvalidDuration is matched by every error about a duration that could not be read, from Format.Parse and from JSON decoding. Use it with errors.Is to tell bad input from other errors, for example to answer a request with 400 Bad Request. Such an error reads like this:
invalid duration "90s" in format iso8601: invalid syntax
var ErrWrongType = errors.New("wrong JSON type")
ErrWrongType is matched by the error for a JSON value of a type that no accepted format reads, such as a number for ISO8601 or a string for Sec. The error says which type it wants, as in "want a JSON string". Use it to tell a client that sent the wrong type from one that sent a malformed duration.
Functions ¶
func Marshalers ¶
func Marshalers(f Format) *json.Marshalers
Marshalers returns the writing half of With, to join with your own marshalers through json.JoinMarshalers: a second json.WithMarshalers option would replace the first. It panics if f is not valid.
Example ¶
Combine the duration marshaler with your own through json.JoinMarshalers, because a second json.WithMarshalers option would replace the first.
package main
import (
"encoding/json/jsontext"
"encoding/json/v2"
"fmt"
"time"
"github.com/pscheid92/jsonduration"
)
func main() {
type UserID int
type Session struct {
ID UserID
TTL time.Duration
}
writeUserID := func(enc *jsontext.Encoder, id UserID) error {
name := fmt.Sprintf("user-%d", id)
token := jsontext.String(name)
return enc.WriteToken(token)
}
ids := json.MarshalToFunc(writeUserID)
durations := jsonduration.Marshalers(jsonduration.Units)
opts := json.WithMarshalers(json.JoinMarshalers(ids, durations))
session := Session{ID: 42, TTL: 90 * time.Minute}
b, err := json.Marshal(session, opts)
fmt.Println(string(b), err)
}
Output: {"ID":"user-42","TTL":"1h30m0s"} <nil>
func Unmarshalers ¶
func Unmarshalers(f Format, opts ...DecodeOption) *json.Unmarshalers
Unmarshalers returns the reading half of With, to join with your own unmarshalers through json.JoinUnmarshalers: a second json.WithUnmarshalers option would replace the first. It panics as With does.
func With ¶
func With(f Format, opts ...DecodeOption) json.Options
With returns options that make encoding/json/v2 write and read every time.Duration in format f: in struct fields, pointers, slices, arrays, map keys and values, and, when marshaling, in values held in an any. It panics if a format is not valid or two numeric formats are accepted.
Decoding is strict. It accepts only f, unless AlsoAccept adds formats, and reads a numeric format from a JSON string only where it is written as one: in object names and with json.StringifyNumbers. JSON null sets the duration to zero.
The options are safe for concurrent use, so create them once and share them:
var jsonOpts = jsonduration.With(jsonduration.ISO8601) b, err := json.Marshal(v, jsonOpts) err = json.Unmarshal(b, &v, jsonOpts)
To combine them with your own marshalers or unmarshalers, use Marshalers and Unmarshalers.
Example (NumbersAsStrings) ¶
Numeric formats are written as JSON strings in object names, as JSON requires, and with json.StringifyNumbers.
package main
import (
"encoding/json/v2"
"fmt"
"time"
"github.com/pscheid92/jsonduration"
)
func main() {
m := map[time.Duration]time.Duration{time.Minute: time.Hour}
opts := jsonduration.With(jsonduration.Sec)
stringify := json.StringifyNumbers(true)
b, _ := json.Marshal(m, opts)
fmt.Println(string(b))
b, _ = json.Marshal(m, opts, stringify)
fmt.Println(string(b))
}
Output: {"60":3600} {"60":"3600"}
Types ¶
type DecodeOption ¶
type DecodeOption interface {
// contains filtered or unexported methods
}
A DecodeOption changes how With and Unmarshalers read durations: AlsoAccept adds formats, and AcceptDays reads ISO 8601 days. Writing always uses the one format given.
func AcceptDays ¶
func AcceptDays() DecodeOption
AcceptDays makes ISO8601 decoding read a day as exactly 24 hours and a week as 7 days: "P1DT12H" is 36h. Python's Pydantic and .NET's XmlConvert write every duration of a day or more with days. Years and months are still rejected unless their count is zero, and days and weeks take no fraction. Only JSON decoding is affected: encoding never writes days, and Format.Parse and ISO8601Duration stay strict.
Use it only where the producer means 24 hours by a day: across a daylight saving change, a calendar day has 23 or 25 hours.
Example ¶
Read what Python's Pydantic writes for a timedelta of 36 hours, where a day is exactly 24 hours.
package main
import (
"encoding/json/v2"
"errors"
"fmt"
"time"
"github.com/pscheid92/jsonduration"
)
func main() {
opts := jsonduration.With(jsonduration.ISO8601, jsonduration.AcceptDays())
var d time.Duration
err := json.Unmarshal([]byte(`"P1DT12H"`), &d, opts)
fmt.Println(d, err)
// Months still have no fixed length.
err = json.Unmarshal([]byte(`"P1M"`), &d, opts)
fmt.Println(errors.Is(err, jsonduration.ErrCalendarUnits))
}
Output: 36h0m0s <nil> true
Example (OwnWrapper) ¶
The wrapper types of the package are strict and take no options. If a field needs jsonduration.AcceptDays or jsonduration.AlsoAccept, and you cannot pass options to the call, a wrapper type of your own takes a few lines.
package main
import (
"encoding/json/jsontext"
"encoding/json/v2"
"fmt"
"time"
"github.com/pscheid92/jsonduration"
)
// daysOptions holds what the wrapper types of the package do not offer: a decode option.
var daysOptions = jsonduration.With(jsonduration.ISO8601, jsonduration.AcceptDays())
// DaysDuration is a wrapper type of your own. It encodes itself, as [jsonduration.ISO8601Duration] does, but with
// options of your choice.
type DaysDuration struct{ time.Duration }
func (d DaysDuration) MarshalJSONTo(enc *jsontext.Encoder) error {
return json.MarshalEncode(enc, d.Duration, daysOptions)
}
func (d *DaysDuration) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
return json.UnmarshalDecode(dec, &d.Duration, daysOptions)
}
// The wrapper types of the package are strict and take no options. If a field needs [jsonduration.AcceptDays] or
// [jsonduration.AlsoAccept], and you cannot pass options to the call, a wrapper type of your own takes a few lines.
func main() {
type Job struct {
Timeout DaysDuration `json:"timeout"`
}
var job Job
if err := json.Unmarshal([]byte(`{"timeout":"P1DT12H"}`), &job); err != nil {
fmt.Println(err)
return
}
fmt.Println(job.Timeout)
b, err := json.Marshal(job)
fmt.Println(string(b), err)
}
Output: 36h0m0s {"timeout":"PT36H"} <nil>
func AlsoAccept ¶
func AlsoAccept(formats ...Format) DecodeOption
AlsoAccept makes decoding accept the given formats besides the one that is written, for example to read old data while writing a new format:
// Stored data holds v1 nanoseconds; new data is written as ISO 8601. opts := jsonduration.With(jsonduration.ISO8601, jsonduration.AlsoAccept(jsonduration.Nano))
String formats never conflict, but numeric ones do: a JSON number such as 90 could be seconds or nanoseconds, so at most one numeric format may be accepted.
Accept only the formats you need, since data that two systems read differently is a common source of security bugs.
Example ¶
Read data that encoding/json (v1) wrote as nanoseconds, while writing ISO 8601 from now on.
package main
import (
"encoding/json/v2"
"fmt"
"time"
"github.com/pscheid92/jsonduration"
)
func main() {
type Job struct {
Timeout time.Duration `json:"timeout"`
}
opts := jsonduration.With(jsonduration.ISO8601, jsonduration.AlsoAccept(jsonduration.Nano))
inputs := []string{`{"timeout":90000000000}`, `{"timeout":"PT1M30S"}`}
for _, stored := range inputs {
var j Job
err := json.Unmarshal([]byte(stored), &j, opts)
fmt.Println(j.Timeout, err)
}
job := Job{Timeout: 90 * time.Second}
b, err := json.Marshal(job, opts)
fmt.Println(string(b), err)
}
Output: 1m30s <nil> 1m30s <nil> {"timeout":"PT1M30S"} <nil>
type Format ¶
type Format string
Format is a way to write a time.Duration in JSON, such as ISO8601 for "PT1H30M". Use it with With, or convert single durations with Format.Format and Format.Parse.
A flag (flag.TextVar) or a JSON configuration can name a format, and both reject unknown names. With panics on an invalid Format, such as the empty one that decoding leaves for a missing name or null, so give it a default, as in cmp.Or(cfg.DurationFormat, jsonduration.ISO8601). A Format converted from a string, as in Format(os.Getenv("DURATION_FORMAT")), is not checked, so check it with Format.Valid first.
const ( // Units writes a JSON string as [time.Duration.String] does, such as "1h30m0.5s". // It reads everything [time.ParseDuration] reads, such as "90s" or "1.5h". Units Format = "units" // Sec writes a JSON number of seconds, such as 5400.5, with decimals down to nanoseconds. // It does not read exponents such as 1e-05, which some encoders write for small numbers. Sec Format = "sec" // Milli writes a JSON number of milliseconds, such as 5400500, or 1.5 for 1500µs. Milli Format = "milli" // Micro writes a JSON number of microseconds, such as 5400500000, or 1.5 for 1500ns. Micro Format = "micro" // Nano writes a JSON number of nanoseconds, such as 5400500000000, as encoding/json (v1) does. // Beyond about 104 days, JavaScript and other decoders that read numbers as float64 can no longer hold it exactly. Nano Format = "nano" // ISO8601 writes a JSON string in ISO 8601, such as "PT1H30M0.5S", in hours, minutes, and seconds. // It also reads forms such as "PT90M" and "PT1.5H", but rejects years, months, weeks, and days, // whose length varies; see [AcceptDays]. // Only a sign in front is allowed, as in "-PT1H30M", so Java's "PT-1H-30M" is rejected. ISO8601 Format = "iso8601" )
func Formats ¶
func Formats() []Format
Formats returns all formats, for example to list them in a flag's usage.
func (Format) Format ¶
Format returns the duration as text in format f, without JSON quotes, such as "PT1M30S" for 90 seconds in ISO8601 or "90" in Sec. Use it to write a duration outside JSON the same way, such as in a log line. It panics if f is not valid.
Example ¶
package main
import (
"fmt"
"time"
"github.com/pscheid92/jsonduration"
)
func main() {
d := 90*time.Minute + 500*time.Millisecond
for _, f := range jsonduration.Formats() {
fmt.Printf("%-9s %s\n", f, f.Format(d))
}
}
Output: units 1h30m0.5s sec 5400.5 milli 5400500 micro 5400500000 nano 5400500000000 iso8601 PT1H30M0.5S
func (Format) MarshalText ¶
MarshalText returns the name of f, such as "iso8601", so that a configuration holds a Format by its name. It returns an error if f is not valid.
func (Format) Parse ¶
Parse reads a duration in format f, without JSON quotes, such as "5400.5" in Sec or "PT1H30M" in ISO8601. It is the inverse of Format.Format and reads only format f, since AlsoAccept and AcceptDays apply to JSON decoding only. Digits below one nanosecond are truncated. An error matches ErrInvalidDuration. It panics if f is not valid.
Example ¶
package main
import (
"errors"
"fmt"
"github.com/pscheid92/jsonduration"
)
func main() {
d, err := jsonduration.ISO8601.Parse("PT1H30M")
fmt.Println(d, err)
// Days have no fixed length.
_, err = jsonduration.ISO8601.Parse("P1D")
fmt.Println(errors.Is(err, jsonduration.ErrCalendarUnits))
// Only the format itself is accepted.
_, err = jsonduration.ISO8601.Parse("1h30m")
fmt.Println(err)
}
Output: 1h30m0s <nil> true invalid duration "1h30m" in format iso8601: invalid syntax
func (*Format) UnmarshalText ¶
UnmarshalText sets f from a name such as "iso8601", so that a flag (flag.TextVar) or a JSON configuration can name a format. Any other name is an error that lists the valid ones and leaves f unchanged.
Example ¶
Choose the format at run time, from a flag. A format in a JSON configuration works the same way.
package main
import (
"encoding/json/v2"
"flag"
"fmt"
"io"
"time"
"github.com/pscheid92/jsonduration"
)
func main() {
fs := flag.NewFlagSet("server", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var format jsonduration.Format
fs.TextVar(&format, "duration-format", jsonduration.ISO8601, "how durations are written")
args := []string{"-duration-format=sec"}
_ = fs.Parse(args)
opts := jsonduration.With(format)
timeout := 90 * time.Second
b, err := json.Marshal(timeout, opts)
fmt.Println(string(b), err)
// Unknown names are rejected.
args = []string{"-duration-format=weeks"}
err = fs.Parse(args)
fmt.Println(err != nil)
}
Output: 90 <nil> true
Example (Configuration) ¶
Read the format from a JSON configuration. A missing name or null leaves the Format empty, which is not valid, so give it a default with cmp.Or.
package main
import (
"cmp"
"encoding/json/v2"
"fmt"
"github.com/pscheid92/jsonduration"
)
func main() {
type Settings struct {
DurationFormat jsonduration.Format `json:"durationFormat"`
}
inputs := []string{`{"durationFormat":"sec"}`, `{}`, `{"durationFormat":null}`}
for _, in := range inputs {
var settings Settings
err := json.Unmarshal([]byte(in), &settings)
format := cmp.Or(settings.DurationFormat, jsonduration.ISO8601)
fmt.Println(format, err)
}
}
Output: sec <nil> iso8601 <nil> iso8601 <nil>
type ISO8601Duration ¶
ISO8601Duration holds a time.Duration and encodes it in format ISO8601, such as "PT1H30M0.5S", without options.
Example ¶
Wrapper types choose the format per field and need no options. The methods of time.Duration stay available.
package main
import (
"encoding/json/v2"
"fmt"
"time"
"github.com/pscheid92/jsonduration"
)
func main() {
type Config struct {
Timeout jsonduration.ISO8601Duration `json:"timeout"`
Backoff jsonduration.SecDuration `json:"backoff"`
}
cfg := Config{
Timeout: jsonduration.ISO8601Duration{Duration: 90 * time.Second},
Backoff: jsonduration.SecDuration{Duration: 1500 * time.Millisecond},
}
b, err := json.Marshal(cfg)
fmt.Println(string(b), err)
var c Config
err = json.Unmarshal([]byte(`{"timeout":"PT2M","backoff":0.25}`), &c)
fmt.Println(c.Timeout.Minutes(), c.Backoff, err)
}
Output: {"timeout":"PT1M30S","backoff":1.5} <nil> 2 250ms <nil>
func (ISO8601Duration) MarshalJSONTo ¶
func (d ISO8601Duration) MarshalJSONTo(enc *jsontext.Encoder) error
MarshalJSONTo implements encoding/json/v2.MarshalerTo.
func (*ISO8601Duration) UnmarshalJSONFrom ¶
func (d *ISO8601Duration) UnmarshalJSONFrom(dec *jsontext.Decoder) error
UnmarshalJSONFrom implements encoding/json/v2.UnmarshalerFrom.
type MicroDuration ¶
MicroDuration holds a time.Duration and encodes it in format Micro, such as 5400500000, without options.
func (MicroDuration) MarshalJSONTo ¶
func (d MicroDuration) MarshalJSONTo(enc *jsontext.Encoder) error
MarshalJSONTo implements encoding/json/v2.MarshalerTo.
func (*MicroDuration) UnmarshalJSONFrom ¶
func (d *MicroDuration) UnmarshalJSONFrom(dec *jsontext.Decoder) error
UnmarshalJSONFrom implements encoding/json/v2.UnmarshalerFrom.
type MilliDuration ¶
MilliDuration holds a time.Duration and encodes it in format Milli, such as 5400500, without options.
func (MilliDuration) MarshalJSONTo ¶
func (d MilliDuration) MarshalJSONTo(enc *jsontext.Encoder) error
MarshalJSONTo implements encoding/json/v2.MarshalerTo.
func (*MilliDuration) UnmarshalJSONFrom ¶
func (d *MilliDuration) UnmarshalJSONFrom(dec *jsontext.Decoder) error
UnmarshalJSONFrom implements encoding/json/v2.UnmarshalerFrom.
type NanoDuration ¶
NanoDuration holds a time.Duration and encodes it in format Nano, such as 5400500000000, without options.
func (NanoDuration) MarshalJSONTo ¶
func (d NanoDuration) MarshalJSONTo(enc *jsontext.Encoder) error
MarshalJSONTo implements encoding/json/v2.MarshalerTo.
func (*NanoDuration) UnmarshalJSONFrom ¶
func (d *NanoDuration) UnmarshalJSONFrom(dec *jsontext.Decoder) error
UnmarshalJSONFrom implements encoding/json/v2.UnmarshalerFrom.
type SecDuration ¶
SecDuration holds a time.Duration and encodes it in format Sec, such as 5400.5, without options.
func (SecDuration) MarshalJSONTo ¶
func (d SecDuration) MarshalJSONTo(enc *jsontext.Encoder) error
MarshalJSONTo implements encoding/json/v2.MarshalerTo.
func (*SecDuration) UnmarshalJSONFrom ¶
func (d *SecDuration) UnmarshalJSONFrom(dec *jsontext.Decoder) error
UnmarshalJSONFrom implements encoding/json/v2.UnmarshalerFrom.
type UnitsDuration ¶
UnitsDuration holds a time.Duration and encodes it in format Units, such as "1h30m0.5s", without options.
func (UnitsDuration) MarshalJSONTo ¶
func (d UnitsDuration) MarshalJSONTo(enc *jsontext.Encoder) error
MarshalJSONTo implements encoding/json/v2.MarshalerTo.
func (*UnitsDuration) UnmarshalJSONFrom ¶
func (d *UnitsDuration) UnmarshalJSONFrom(dec *jsontext.Decoder) error
UnmarshalJSONFrom implements encoding/json/v2.UnmarshalerFrom.