jsonduration

package module
v0.3.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: 13 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. With encoding/json (v1), use v2 or a field type of your own; this package is not tested with v1. Until v1.0.0 the API may still change.

Usage

Without this package, json.Marshal of a time.Duration fails with no default representation. Create one option with With and pass it to each json.Marshal and json.Unmarshal. It applies one format to every time.Duration in that call: in fields, pointers, slices, arrays, and map keys and values.

package main

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

	"github.com/pscheid92/jsonduration"
)

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

// Options are safe for concurrent use, so create them once.
var opts = jsonduration.With(jsonduration.ISO8601)

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

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

	err = json.Unmarshal([]byte(`{"timeout":"PT2M"}`), &cfg, opts)
	fmt.Println(cfg.Timeout, err) // 2m0s <nil>
}

encoding/json/v2 is part of the standard library since Go 1.27, with no GOEXPERIMENT setting. Creating the options costs about a dozen allocations, so create them once rather than per call.

If you already pass json.WithMarshalers or json.WithUnmarshalers, don't add With next to them: a second WithMarshalers option replaces the first, without an error. Join yours with the package's instead:

opts := json.JoinOptions(
	json.WithMarshalers(json.JoinMarshalers(yours, jsonduration.Marshalers(jsonduration.Units))),
	json.WithUnmarshalers(json.JoinUnmarshalers(yoursToo, jsonduration.Unmarshalers(jsonduration.Units))),
)

If you can't pass options, for example because other code encodes your types, or if fields need different formats, give each field a wrapper type. The wrapper types are listed in the table below:

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

cfg := Config{Timeout: jsonduration.ISO8601Duration{Duration: 90 * time.Second}}

A wrapper type embeds time.Duration, so cfg.Timeout.Minutes() keeps working, but where a time.Duration is expected you write cfg.Timeout.Duration. To keep plain time.Duration fields in a struct that others encode, see Your own types.

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"

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), which writes the same.

Numeric formats are exact and write fractions where needed, so Milli writes 1.5ms as 1.5. A decoder that reads JSON numbers as float64, such as JavaScript's JSON.parse, can lose the nanoseconds of long durations.

Common tasks

Replacing the format tag

Remove the tag 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: each format writes what the tag option of the same name wrote, and a test checks this against the tag option's own code. Decoding differs in these ways:

Input The tag option jsonduration
-0 in a numeric format out of range zero
A calendar unit with a count of zero, as in "P0D" rejected read
A fraction that is not all digits, as in "PT1.e5H" one hour rejected
Fractions in Units, and of hours and minutes in ISO8601 rounded through float64 exact, to the nanosecond
The string tag option on Units and ISO8601 rejected no effect
Reading more than one format
// Stored data holds encoding/json (v1) nanoseconds; new data is ISO 8601.
opts := jsonduration.With(jsonduration.ISO8601, jsonduration.AlsoAccept(jsonduration.Nano))

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

When several services share the data, let every reader accept the new format before any writer switches to it. A service still on encoding/json (v1) cannot read ISO 8601 at all.

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, as Python's Pydantic and .NET's XmlConvert do, add AcceptDays:

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

Without AcceptDays, "P1D" fails with calendar units have no fixed length. Months and years stay rejected, and output never uses days. A count of zero, as in "P0D" or "P0Y0M0DT1H30M0S", is always read.

Your own types

The wrapper types take no options, so ISO8601Duration rejects "P1DT12H". If a field needs AcceptDays or AlsoAccept, write your own wrapper type with the imports encoding/json/v2 and encoding/json/jsontext:

var daysOptions = jsonduration.With(jsonduration.ISO8601, jsonduration.AcceptDays())

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 same two methods give a named type such as type TTL time.Duration a format, since With does not reach it:

var unitsOptions = jsonduration.With(jsonduration.Units)

type TTL time.Duration

func (t TTL) MarshalJSONTo(enc *jsontext.Encoder) error {
	return json.MarshalEncode(enc, time.Duration(t), unitsOptions)
}

func (t *TTL) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
	return json.UnmarshalDecode(dec, (*time.Duration)(t), unitsOptions)
}

A struct that others encode without options can keep plain time.Duration fields with the same two methods on the struct itself, as RetryPolicy in the examples shows. Others must then use it as a named field, Retry RetryPolicy, and not embed it: an embedded struct passes its methods on, and the outer struct then encodes as the inner one alone, without its other fields.

Choosing the format at run time

Format works with flag.TextVar and in JSON configuration. Names must match the table exactly, in lower case, so ISO8601 is not a name:

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, which includes the empty one. Check a Format from a plain string first:

format := jsonduration.Format(os.Getenv("DURATION_FORMAT"))
if !format.Valid() {
	log.Fatalf("DURATION_FORMAT=%q, want one of %v", format, jsonduration.Formats())
}
Without JSON
jsonduration.ISO8601.Format(90 * time.Second) // "PT1M30S"
d, err := jsonduration.Sec.Parse("5400.5")    // 1h30m0.5s

Good to know

  • Every error about a bad duration matches jsonduration.ErrInvalidDuration, and its cause: strconv.ErrSyntax, strconv.ErrRange, ErrCalendarUnits, or ErrWrongType. The json.SemanticError around it says where it is, in its JSONPointer. Malformed JSON is a jsontext.SyntacticError instead.
  • JSON null sets a duration to zero, and a *time.Duration to nil.
  • To leave out zero durations, use omitzero. In v2, omitempty keeps them.
  • Decoding checks the format, not the value: "-1s" and "0s" are read. Check ranges after decoding.
  • Numeric formats reject exponents, as in 1e-05, which some encoders such as Swift's write for small numbers.
  • Digits below a nanosecond are truncated, not rounded. No format rounds to whole units: Milli writes 1.234567.
  • The largest duration is about 292 years, PT2562047H47M16.854775807S; anything larger is strconv.ErrRange.
  • ISO8601 takes one sign, - or +, only in front, as in -PT30S. It rejects Java's negative durations, such as PT-30S. It reads designators in either case and a decimal comma, so pt1,5s is 1.5s. A count of zero must have no fraction: "P0D" is read, "P0.0D" is not. Output is always the shortest form, so "P0DT2H" comes back as "PT2H".
  • Units reads what time.ParseDuration reads. So it reads protobuf's "3600s" but writes "1h0m0s", and it does not read Rust humantime's "1h 30m" or PostgreSQL's "01:30:00". PostgreSQL writes ISO 8601 with intervalstyle = iso_8601.
  • Decoding into any gives the JSON as it is, such as the string "PT1S", since the type is unknown there.
  • Maps are written in random order, as for every map in encoding/json/v2, unless you pass json.Deterministic(true).
  • If nanoseconds are all you need, encoding/json has an option for the v2 functions: jsonv1.FormatDurationAsNano(true).

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

Every codec is compared with an independent implementation: the removed format tag option's own code, a second parser built from regular expressions and math/big, and time.ParseDuration. Each comparison is also a fuzz target. On top of that come mutation testing every night, 100% statement coverage, and durations as real producers write them. TESTING.md has the details.

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. 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, ErrCalendarUnits, or, for a JSON value of the wrong type such as a number for ISO8601, ErrWrongType, which reads "want a JSON string". The error wraps it. With formats from AlsoAccept, it is a syntax error only if every format rejects the syntax. 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. Two short methods give them a format, as the example of With for a named type shows. 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.

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.

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
View Source
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 (NamedType)

A named type such as TTL takes two short methods to get a format.

package main

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

	"github.com/pscheid92/jsonduration"
)

var unitsOptions = jsonduration.With(jsonduration.Units)

// TTL is a named type. [jsonduration.With] does not affect it, since it is not a time.Duration, so it encodes itself.
type TTL time.Duration

func (t TTL) MarshalJSONTo(enc *jsontext.Encoder) error {
	return json.MarshalEncode(enc, time.Duration(t), unitsOptions)
}

func (t *TTL) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
	return json.UnmarshalDecode(dec, (*time.Duration)(t), unitsOptions)
}

func main() {
	type Entry struct {
		Key string `json:"key"`
		TTL TTL    `json:"ttl"`
	}

	b, err := json.Marshal(Entry{Key: "k", TTL: TTL(5 * time.Minute)})
	fmt.Println(string(b), err)

	var e Entry
	err = json.Unmarshal([]byte(`{"key":"k","ttl":"90s"}`), &e)
	fmt.Println(time.Duration(e.TTL), err)

}
Output:
{"key":"k","ttl":"5m0s"} <nil>
1m30s <nil>
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"}
Example (StructWithoutOptions)

A struct that others encode without options can choose the format of its time.Duration fields itself.

package main

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

	"github.com/pscheid92/jsonduration"
)

var secOptions = jsonduration.With(jsonduration.Sec)

// RetryPolicy belongs to a package that others encode without options. Its fields stay time.Duration, so the
// package's users can still write InitialBackoff: 250 * time.Millisecond, and the struct encodes itself.
type RetryPolicy struct {
	MaxAttempts    int           `json:"max_attempts"`
	InitialBackoff time.Duration `json:"initial_backoff"`
}

// plainRetryPolicy has the fields of RetryPolicy but not its methods, which would otherwise call themselves.
type plainRetryPolicy RetryPolicy

func (p RetryPolicy) MarshalJSONTo(enc *jsontext.Encoder) error {
	return json.MarshalEncode(enc, plainRetryPolicy(p), secOptions)
}

func (p *RetryPolicy) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
	return json.UnmarshalDecode(dec, (*plainRetryPolicy)(p), secOptions)
}

func main() {
	type Request struct {
		Name  string      `json:"name"`
		Retry RetryPolicy `json:"retry"`
	}

	req := Request{Name: "sync", Retry: RetryPolicy{MaxAttempts: 3, InitialBackoff: 250 * time.Millisecond}}

	b, err := json.Marshal(req)
	fmt.Println(string(b), err)

}
Output:
{"name":"sync","retry":{"max_attempts":3,"initial_backoff":0.25}} <nil>

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", designators in either case, and a decimal comma, as in
	// "pt1,5s". It rejects years, months, weeks, and days, whose length varies, unless their count is zero;
	// see [AcceptDays].
	// Only one sign in front is allowed, as in "-PT30S" or "+PT30S", so Java's negative durations such as "PT-30S"
	// are 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