jsonduration

package module
v0.2.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 9, 2026 License: BSD-3-Clause Imports: 12 Imported by: 0

README

jsonduration

CI Go Reference Coverage OpenSSF Scorecard

JSON formats for time.Duration in Go's encoding/json/v2.

encoding/json/v2 can't encode a time.Duration on its own: it has no default representation (golang/go#71631), and the format struct tag that used to pick one was removed in Go 1.27 (golang/go#79071). This package lets you choose one.

go get github.com/pscheid92/jsonduration

It requires Go 1.27 or later, has no dependencies outside the standard library, and supports encoding/json/v2 only. Until v1.0.0 the API may still change.

Usage

There are two ways to choose a format:

  • One option per call, if you call json.Marshal and json.Unmarshal yourself. It covers every time.Duration in the call, and your types stay as they are.
  • One wrapper type per field, if you can't pass options, for example because another library encodes your types, or if fields need different formats.
One option per call
package main

import (
	"encoding/json/v2"
	"fmt"
	"log"
	"time"

	"github.com/pscheid92/jsonduration"
)

type Config struct {
	Timeout time.Duration   `json:"timeout"`
	Retries []time.Duration `json:"retries"`
}

func main() {
	cfg := Config{
		Timeout: 90 * time.Second,
		Retries: []time.Duration{time.Second, 5 * time.Second},
	}

	opts := jsonduration.With(jsonduration.ISO8601)

	b, err := json.Marshal(cfg, opts)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(b)) // {"timeout":"PT1M30S","retries":["PT1S","PT5S"]}

	var readConfig Config
	if err := json.Unmarshal([]byte(`{"timeout":"PT2M"}`), &readConfig, opts); err != nil {
		log.Fatal(err)
	}
	fmt.Println(readConfig.Timeout) // 2m0s
}

The format applies to fields, pointers, slices, arrays, map keys and values, and values in an any when marshaling. Create the options once and share them: they are safe for concurrent use.

One wrapper type per field
package main

import (
	"encoding/json/v2"
	"fmt"
	"log"
	"time"

	"github.com/pscheid92/jsonduration"
)

type Config struct {
	Timeout jsonduration.ISO8601Duration `json:"timeout"`
	Backoff jsonduration.SecDuration     `json:"backoff"`
}

func main() {
	cfg := Config{
		Timeout: jsonduration.ISO8601Duration{Duration: 90 * time.Second},
		Backoff: jsonduration.SecDuration{Duration: 1500 * time.Millisecond},
	}

	b, err := json.Marshal(cfg)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(b)) // {"timeout":"PT1M30S","backoff":1.5}
}

Wrapper types embed time.Duration, so its methods keep working, as in cfg.Timeout.Minutes() or time.Sleep(cfg.Backoff.Duration).

Formats

Format Name Wrapper type JSON type 1h30m0.5s becomes
Units units UnitsDuration string "1h30m0.5s"
Sec sec SecDuration number 5400.5
Milli milli MilliDuration number 5400500
Micro micro MicroDuration number 5400500000
Nano nano NanoDuration number 5400500000000
ISO8601 iso8601 ISO8601Duration string "PT1H30M0.5S"
  • These are the six formats the format tag offered before Go 1.27, and they write the same JSON as they did then.
  • Nano writes what encoding/json (v1) writes.
  • Numeric formats are exact, with no floating-point rounding.
  • Decoders that read JSON numbers as float64, such as JavaScript's JSON.parse, can lose the nanoseconds of long durations in every numeric format, in Nano beyond about 104 days. So can the jsontext.CanonicalizeRawInts and CanonicalizeRawFloats options.

As a rule of thumb, use ISO8601 for APIs that other languages read, Units for configuration that people write, Sec or Milli where a number is expected, and Nano for data shared with encoding/json (v1).

Common tasks

Replacing the format tag

Since Go 1.27, encoding/json/v2 rejects a struct that has a format tag option on a time.Duration field, so every json.Marshal and json.Unmarshal of it fails. Remove the option, and choose the format in one of the two ways above:

// Before Go 1.27
Timeout time.Duration `json:"timeout,format:iso8601"`

// With jsonduration.With(jsonduration.ISO8601) passed to json.Marshal and json.Unmarshal
Timeout time.Duration `json:"timeout"`

// Or without options
Timeout jsonduration.ISO8601Duration `json:"timeout"`

Stored data stays readable, since each format writes what the tag option of the same name wrote. A test checks this against the tag option itself, whose code the standard library still contains and which go-json-experiment's ExperimentalSupportFormatTag turns back on. Decoding differs only where jsonduration is stricter or exact:

  • It reads -0 as zero, where the tag option reports it as out of range.
  • It rejects a fraction that is not all digits, such as "PT1.e5H", which the tag option reads as one hour.
  • It truncates fractions of hours and minutes exactly, where the tag option rounds them through float64, so the last nanosecond can differ, as for "PT0.99999999999999999999H".
  • The string tag option has no effect on Units and ISO8601, where the tag option rejects it.
  • Its errors name their cause, such as strconv.ErrSyntax or ErrCalendarUnits.
Migrating between formats

To write one format but also read others:

// Stored data holds encoding/json (v1) nanoseconds; new data is ISO 8601.
opts := jsonduration.With(jsonduration.ISO8601, jsonduration.AlsoAccept(jsonduration.Nano))

Both {"timeout":90000000000} and {"timeout":"PT1M30S"} decode to 1m30s, and everything is written as ISO 8601. You can accept several string formats, but only one numeric format, because a number such as 90 could be seconds or nanoseconds.

ISO 8601 days

ISO8601 rejects days, weeks, months, and years, because a calendar day is not always 24 hours long. If the other side means exactly 24 hours by a day, add AcceptDays. Python's Pydantic, and so FastAPI, writes a timedelta of 36 hours as "P1DT12H", and .NET's XmlConvert does the same for a TimeSpan:

opts := jsonduration.With(jsonduration.ISO8601, jsonduration.AcceptDays())
// "P1DT12H" decodes to 36h

Months and years are still rejected, and output never uses days.

Choosing the format at run time

Format works with flag.TextVar and in JSON configuration. Names must match the table exactly (iso8601, not ISO8601), and anything else is an error that lists them:

var format jsonduration.Format
flag.TextVar(&format, "duration-format", jsonduration.ISO8601, "how durations are written")
flag.Parse()
opts := jsonduration.With(format)

With panics on an invalid Format. A Format converted from a plain string, as in jsonduration.Format(os.Getenv("DURATION_FORMAT")), is not checked, so check it with Valid() first. The empty Format is invalid too, and decoding leaves it for a missing name or null, so give it a default:

opts := jsonduration.With(cmp.Or(cfg.DurationFormat, jsonduration.ISO8601))
Without JSON
jsonduration.ISO8601.Format(90 * time.Second) // "PT1M30S"
d, err := jsonduration.Sec.Parse("5400.5")    // 1h30m0.5s

Good to know

  • Decoding accepts only the formats you configured.
  • To tell a bad duration in the input from other errors, as for a 400 Bad Request, check errors.Is(err, jsonduration.ErrInvalidDuration). The json.SemanticError around it says where in the input it is.
  • JSON null sets a duration to zero, and a *time.Duration to nil.
  • Named types such as type TTL time.Duration are not time.Duration, so With does not affect them, and they are written as plain integers of nanoseconds.
  • ISO8601 takes a sign only in front, as in -PT1H30M, so it rejects negative durations as Java writes them, such as PT-1H-30M.
  • To leave out zero durations, use the omitzero tag option. In v2, omitempty keeps them.
  • If you pass your own json.WithMarshalers, combine it with jsonduration.Marshalers through json.JoinMarshalers, and likewise for unmarshalers. A second WithMarshalers option replaces the first one.
  • If nanoseconds are all you need, an option of encoding/json is enough: import it as jsonv1 "encoding/json" and pass jsonv1.FormatDurationAsNano(true) to the v2 functions.

Future

Go may add its own way to choose a format for a type, see golang/go#71664 and golang/go#81013. If that happens, prefer it. A final release of this package will then point to it.

How it is tested

Examples alone can't show that a parser is right, so the tests also compare each codec with an independent implementation of the same thing:

  • The removed format tag option. The internal/compat module runs the tag option's own code, which the standard library still contains, next to jsonduration. Encoding must be byte-identical in every format, and decoding must agree except where the list above says otherwise.
  • Reference parsers. A second parser for every format, built from regular expressions and math/big and sharing no code with the package, must agree on every input: whether it is accepted, its value, and the cause of an error.
  • time.ParseDuration. Units must read exactly what it reads, with the same value.
  • Real producers. Tests hold what Pydantic and .NET's XmlConvert write, which AcceptDays must read, and where ISO8601 differs from Java's Duration on purpose.
  • Fuzzing. Every comparison but the one with real producers is also a fuzz target. CI runs each target for 45 seconds on every push and for 10 minutes every night, and keeps the inputs it finds between runs.
  • Mutation testing. Every night, gremlins changes the code in small ways, such as < into <= or + into -, one change at a time. CI fails if too few of the changes make a test fail.
  • Coverage. CI fails if any statement is not covered.

The tests run with the race detector, on Linux and macOS, on 32-bit, and with both the oldest supported and the latest Go. govulncheck checks for known vulnerabilities on every push and every night.

Development

Needs just and golangci-lint. Run just to list all recipes.

just check        # go fix, gofmt, go mod tidy, vet, lint, actionlint, tests with -race, compat, govulncheck
just fuzz         # every fuzz target for 30s
just compat-fuzz  # the fuzz targets of the comparison with the format tag option, for 30s each
just mutate       # mutation testing with gremlins, which takes a few minutes

License

BSD 3-Clause, see LICENSE. Parts of the codecs are adapted from the Go standard library, whose notice is included there.

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.

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.

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. Only a JSON value of the wrong kind, such as a number for ISO8601, has a cause of its own: "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

Examples

Constants

This section is empty.

Variables

View Source
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.

View Source
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

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, 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

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

func (f Format) Format(d time.Duration) string

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

func (f Format) MarshalText() ([]byte, error)

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

func (f Format) Parse(s string) (time.Duration, error)

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

func (f *Format) UnmarshalText(b []byte) error

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>

func (Format) Valid

func (f Format) Valid() bool

Valid reports whether f is one of the formats. Names are lowercase, so Format("ISO8601") is not valid.

type ISO8601Duration

type ISO8601Duration struct{ time.Duration }

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

type MicroDuration struct{ time.Duration }

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

type MilliDuration struct{ time.Duration }

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

type NanoDuration struct{ time.Duration }

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

type SecDuration struct{ time.Duration }

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

type UnitsDuration struct{ time.Duration }

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL