From 7c63d49c043940be3794163d2a407d909f580fd1 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Sun, 12 Jul 2026 15:47:01 -0700 Subject: [PATCH] Add types.Duration for the RFC 3339 duration format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes: #66 The OpenAPI "duration" format references the RFC 3339 appendix A duration grammar, which the Go standard library does not implement, and the available third-party parsers are unmaintained or parse-only. types.Duration stores the parsed components (years, months, weeks, days, hours, minutes, seconds), so any spec-valid duration round-trips losslessly through ParseDuration and String — calendar components have no fixed length in wall-clock time and cannot be represented by a time.Duration. The parser follows the RFC grammar strictly (component order and uniqueness, weeks exclusive, no negatives), with one documented interoperability deviation: fractional seconds are accepted (period or comma) and emitted when present. TimeDuration and FromTimeDuration bridge to time.Duration where the conversion is well-defined: years and months error, weeks and days convert at the fixed 7-day/24-hour convention. JSON and text marshaling plus a Binder implementation make Duration a scalar binding target for parameters, like types.Date. Co-Authored-By: Claude Fable 5 --- bindparam_test.go | 25 ++++ types/duration.go | 286 +++++++++++++++++++++++++++++++++++++++++ types/duration_test.go | 165 ++++++++++++++++++++++++ 3 files changed, 476 insertions(+) create mode 100644 types/duration.go create mode 100644 types/duration_test.go diff --git a/bindparam_test.go b/bindparam_test.go index bc7a2ea..eb75eec 100644 --- a/bindparam_test.go +++ b/bindparam_test.go @@ -587,6 +587,31 @@ func TestBindQueryParameter(t *testing.T) { assert.Equal(t, expectedDate, *date) }) + // types.Duration is a scalar binding target like types.Date: it must + // bind directly via its Binder implementation rather than being + // decomposed as a struct of key-value pairs. + // See https://github.com/oapi-codegen/runtime/issues/66 + t.Run("duration_form_explode_required", func(t *testing.T) { + var duration types.Duration + queryParams := url.Values{ + "retry": {"P1DT2H30M"}, + } + err := BindQueryParameter("form", true, true, "retry", queryParams, &duration) + assert.NoError(t, err) + assert.Equal(t, types.Duration{Days: 1, Hours: 2, Minutes: 30}, duration) + }) + + t.Run("duration_form_no_explode_optional", func(t *testing.T) { + var duration *types.Duration + queryParams := url.Values{ + "retry": {"PT36H"}, + } + err := BindQueryParameter("form", false, false, "retry", queryParams, &duration) + assert.NoError(t, err) + require.NotNil(t, duration) + assert.Equal(t, types.Duration{Hours: 36}, *duration) + }) + // Regression test: primitive string with explode=false should not be // split on commas. Per the OpenAPI specification, explode has no effect // on primitive types — the value must be bound as-is. diff --git a/types/duration.go b/types/duration.go new file mode 100644 index 0000000..2ef80c8 --- /dev/null +++ b/types/duration.go @@ -0,0 +1,286 @@ +package types + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +// Duration represents an RFC 3339 duration (the ISO 8601 duration grammar +// referenced by the OpenAPI "duration" format), e.g. "P3Y6M4DT12H30M5S". +// See https://spec.openapis.org/registry/format/duration +// +// Calendar components (years, months, weeks, days) have no fixed length in +// wall-clock time, so the components are stored as parsed instead of being +// converted to a time.Duration; any spec-valid duration round-trips +// losslessly through ParseDuration and String. Use TimeDuration and +// FromTimeDuration to bridge to time.Duration where the conversion is +// well-defined. +// +// The RFC 3339 grammar makes weeks exclusive ("P2W" cannot be combined with +// other components); ParseDuration enforces that, but the struct cannot. A +// hand-built value mixing Weeks with other components serializes with the +// week between the months and days, which is outside the strict grammar. +type Duration struct { + Years int + Months int + Weeks int + Days int + + Hours int + Minutes int + // Seconds may carry a fraction (e.g. "PT0.5S"). Strictly, RFC 3339 + // allows only integer components; ISO 8601 permits a fraction on the + // smallest component, and real-world payloads use it, so fractional + // seconds are accepted when parsing and emitted when present. + Seconds float64 +} + +// designator ranks enforce component order and uniqueness within each part +// of the grammar: Y then M then D in the date part, H then M then S in the +// time part. +var ( + dateDesignators = map[byte]int{'Y': 0, 'M': 1, 'D': 2} + timeDesignators = map[byte]int{'H': 0, 'M': 1, 'S': 2} +) + +// ParseDuration parses an RFC 3339 duration string per the grammar in RFC +// 3339 appendix A: "P" followed by ordered date components ("1Y2M3D"), a +// time part introduced by "T" ("T4H5M6S"), or a standalone week component +// ("2W"). At least one component must be present. As an interoperability +// extension beyond the strict grammar, the seconds component may carry a +// fraction, with either a period or a comma ("PT0.5S", "PT0,5S"). +func ParseDuration(s string) (Duration, error) { + fail := func(msg string) (Duration, error) { + return Duration{}, fmt.Errorf("invalid RFC 3339 duration %q: %s", s, msg) + } + + if !strings.HasPrefix(s, "P") { + return fail("must start with 'P'") + } + rest := s[1:] + if rest == "" { + return fail("must contain at least one component") + } + + var d Duration + inTime := false + lastRank := -1 + components := 0 + + for len(rest) > 0 { + if rest[0] == 'T' { + if inTime { + return fail("repeated 'T'") + } + inTime = true + lastRank = -1 + rest = rest[1:] + if rest == "" { + return fail("'T' must be followed by a time component") + } + continue + } + + // Scan the integer part of the number. + intLen := 0 + for intLen < len(rest) && rest[intLen] >= '0' && rest[intLen] <= '9' { + intLen++ + } + if intLen == 0 { + return fail(fmt.Sprintf("expected a number before %q", rest)) + } + // Scan an optional fraction; only valid on the seconds component, + // which is checked once the designator is known. + numLen := intLen + hasFraction := false + if numLen < len(rest) && (rest[numLen] == '.' || rest[numLen] == ',') { + hasFraction = true + fracStart := numLen + 1 + numLen = fracStart + for numLen < len(rest) && rest[numLen] >= '0' && rest[numLen] <= '9' { + numLen++ + } + if numLen == fracStart { + return fail("fraction must contain digits") + } + } + if numLen >= len(rest) { + return fail("number must be followed by a designator") + } + number := rest[:numLen] + designator := rest[numLen] + rest = rest[numLen+1:] + components++ + + if designator == 'W' { + if inTime { + return fail("'W' is not valid in the time part") + } + if components != 1 || rest != "" { + return fail("a week component cannot be combined with other components") + } + if hasFraction { + return fail("only the seconds component may carry a fraction") + } + weeks, err := strconv.Atoi(number) + if err != nil { + return fail(fmt.Sprintf("invalid number %q: %v", number, err)) + } + d.Weeks = weeks + return d, nil + } + + designators := dateDesignators + if inTime { + designators = timeDesignators + } + rank, ok := designators[designator] + if !ok { + return fail(fmt.Sprintf("unexpected designator %q", string(designator))) + } + if rank <= lastRank { + return fail(fmt.Sprintf("component %q is out of order or repeated", string(designator))) + } + lastRank = rank + + if inTime && designator == 'S' { + seconds, err := strconv.ParseFloat(strings.Replace(number, ",", ".", 1), 64) + if err != nil { + return fail(fmt.Sprintf("invalid number %q: %v", number, err)) + } + d.Seconds = seconds + continue + } + if hasFraction { + return fail("only the seconds component may carry a fraction") + } + value, err := strconv.Atoi(number) + if err != nil { + return fail(fmt.Sprintf("invalid number %q: %v", number, err)) + } + switch { + case !inTime && designator == 'Y': + d.Years = value + case !inTime && designator == 'M': + d.Months = value + case !inTime && designator == 'D': + d.Days = value + case inTime && designator == 'H': + d.Hours = value + case inTime && designator == 'M': + d.Minutes = value + } + } + + return d, nil +} + +// String serializes the duration in RFC 3339 form, omitting zero components. +// The all-zero duration serializes as "PT0S". +func (d Duration) String() string { + var b strings.Builder + b.WriteByte('P') + writeComponent := func(value int, designator byte) { + if value != 0 { + b.WriteString(strconv.Itoa(value)) + b.WriteByte(designator) + } + } + writeComponent(d.Years, 'Y') + writeComponent(d.Months, 'M') + writeComponent(d.Weeks, 'W') + writeComponent(d.Days, 'D') + if d.Hours != 0 || d.Minutes != 0 || d.Seconds != 0 { + b.WriteByte('T') + writeComponent(d.Hours, 'H') + writeComponent(d.Minutes, 'M') + if d.Seconds != 0 { + b.WriteString(strconv.FormatFloat(d.Seconds, 'f', -1, 64)) + b.WriteByte('S') + } + } + if b.Len() == 1 { + return "PT0S" + } + return b.String() +} + +// TimeDuration converts to a time.Duration. Years and months have no fixed +// length, so their presence is an error; weeks and days are converted with +// the common fixed convention of 7-day weeks and 24-hour days, which ignores +// calendar effects such as DST transitions. +func (d Duration) TimeDuration() (time.Duration, error) { + if d.Years != 0 || d.Months != 0 { + return 0, errors.New("duration contains years or months, which have no fixed length") + } + return time.Duration(d.Weeks)*7*24*time.Hour + + time.Duration(d.Days)*24*time.Hour + + time.Duration(d.Hours)*time.Hour + + time.Duration(d.Minutes)*time.Minute + + time.Duration(d.Seconds*float64(time.Second)), nil +} + +// FromTimeDuration decomposes a time.Duration into hours, minutes and +// seconds. RFC 3339 durations cannot be negative, so a negative input is an +// error. +func FromTimeDuration(td time.Duration) (Duration, error) { + if td < 0 { + return Duration{}, errors.New("RFC 3339 durations cannot be negative") + } + var d Duration + d.Hours = int(td / time.Hour) + td -= time.Duration(d.Hours) * time.Hour + d.Minutes = int(td / time.Minute) + td -= time.Duration(d.Minutes) * time.Minute + d.Seconds = td.Seconds() + return d, nil +} + +func (d Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(d.String()) +} + +func (d *Duration) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + parsed, err := ParseDuration(s) + if err != nil { + return err + } + *d = parsed + return nil +} + +func (d Duration) MarshalText() ([]byte, error) { + return []byte(d.String()), nil +} + +func (d *Duration) UnmarshalText(data []byte) error { + parsed, err := ParseDuration(string(data)) + if err != nil { + return err + } + *d = parsed + return nil +} + +// Bind implements the runtime.Binder interface so that Duration is treated +// as a scalar value when binding parameters rather than being decomposed as +// a struct with key-value pairs. +func (d *Duration) Bind(src string) error { + if src == "" { + return nil + } + parsed, err := ParseDuration(src) + if err != nil { + return err + } + *d = parsed + return nil +} diff --git a/types/duration_test.go b/types/duration_test.go new file mode 100644 index 0000000..b8ad9b8 --- /dev/null +++ b/types/duration_test.go @@ -0,0 +1,165 @@ +package types + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseDuration(t *testing.T) { + valid := map[string]Duration{ + "P1Y": {Years: 1}, + "P3Y6M4DT12H30M5S": {Years: 3, Months: 6, Days: 4, Hours: 12, Minutes: 30, Seconds: 5}, + "P1M": {Months: 1}, + "PT1M": {Minutes: 1}, + "P0D": {}, + "PT0S": {}, + "PT36H": {Hours: 36}, + "P2W": {Weeks: 2}, + "PT0.5S": {Seconds: 0.5}, + "PT0,5S": {Seconds: 0.5}, + "PT1H30M2.25S": {Hours: 1, Minutes: 30, Seconds: 2.25}, + "P10Y10M10D": {Years: 10, Months: 10, Days: 10}, + } + for input, want := range valid { + t.Run(input, func(t *testing.T) { + got, err := ParseDuration(input) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + } + + invalid := []string{ + "", // empty + "P", // no components + "PT", // T without time components + "1Y", // missing P + "P1S", // seconds without T + "PT1D", // date designator in time part + "P1M1Y", // out of order + "PT1S1H", // out of order + "P1Y1Y", // repeated + "P1W2D", // week combined with other components + "P1D2W", // week combined with other components + "PT1W", // week in time part + "P-1D", // negative + "P1.5Y", // fraction outside seconds + "PT1.5H", // fraction outside seconds + "PT1.S", // fraction without digits + "P1YT", // trailing T + "P1", // number without designator + "PT5X", // unknown designator + "p1y", // lowercase + " P1Y", // leading junk + "P1Y ", // trailing junk + "PT1H30M5", + } + for _, input := range invalid { + t.Run("invalid/"+input, func(t *testing.T) { + _, err := ParseDuration(input) + require.Error(t, err) + }) + } +} + +func TestDurationString(t *testing.T) { + // Every parseable value serializes back to its canonical form. + for _, canonical := range []string{ + "P1Y", "P3Y6M4DT12H30M5S", "PT1M", "PT36H", "P2W", "PT0.5S", + "PT1H30M2.25S", "P10Y10M10D", "PT0S", + } { + d, err := ParseDuration(canonical) + require.NoError(t, err) + assert.Equal(t, canonical, d.String()) + } + + // Non-canonical spellings normalize. + d, err := ParseDuration("PT0,5S") + require.NoError(t, err) + assert.Equal(t, "PT0.5S", d.String()) + d, err = ParseDuration("P0D") + require.NoError(t, err) + assert.Equal(t, "PT0S", d.String()) + + // The zero value is the zero duration. + assert.Equal(t, "PT0S", Duration{}.String()) +} + +func TestDurationTimeDuration(t *testing.T) { + d, err := ParseDuration("P1DT2H30M1.5S") + require.NoError(t, err) + td, err := d.TimeDuration() + require.NoError(t, err) + assert.Equal(t, 24*time.Hour+2*time.Hour+30*time.Minute+1500*time.Millisecond, td) + + d, err = ParseDuration("P2W") + require.NoError(t, err) + td, err = d.TimeDuration() + require.NoError(t, err) + assert.Equal(t, 14*24*time.Hour, td) + + // Years and months have no fixed length. + for _, input := range []string{"P1Y", "P1M"} { + d, err := ParseDuration(input) + require.NoError(t, err) + _, err = d.TimeDuration() + require.Error(t, err) + } +} + +func TestFromTimeDuration(t *testing.T) { + d, err := FromTimeDuration(90 * time.Minute) + require.NoError(t, err) + assert.Equal(t, "PT1H30M", d.String()) + + d, err = FromTimeDuration(25*time.Hour + 500*time.Millisecond) + require.NoError(t, err) + assert.Equal(t, "PT25H0.5S", d.String()) + + d, err = FromTimeDuration(0) + require.NoError(t, err) + assert.Equal(t, "PT0S", d.String()) + + _, err = FromTimeDuration(-time.Second) + require.Error(t, err) +} + +func TestDurationJSON(t *testing.T) { + type payload struct { + Retry Duration `json:"retry"` + } + + marshaled, err := json.Marshal(payload{Retry: Duration{Hours: 1, Minutes: 30}}) + require.NoError(t, err) + assert.JSONEq(t, `{"retry":"PT1H30M"}`, string(marshaled)) + + var decoded payload + require.NoError(t, json.Unmarshal([]byte(`{"retry":"P3Y6M4DT12H30M5S"}`), &decoded)) + assert.Equal(t, Duration{Years: 3, Months: 6, Days: 4, Hours: 12, Minutes: 30, Seconds: 5}, decoded.Retry) + + require.Error(t, json.Unmarshal([]byte(`{"retry":"one hour"}`), &decoded)) + require.Error(t, json.Unmarshal([]byte(`{"retry":42}`), &decoded)) +} + +func TestDurationTextAndBind(t *testing.T) { + var d Duration + require.NoError(t, d.UnmarshalText([]byte("PT15M"))) + assert.Equal(t, Duration{Minutes: 15}, d) + + text, err := d.MarshalText() + require.NoError(t, err) + assert.Equal(t, "PT15M", string(text)) + + var bound Duration + require.NoError(t, bound.Bind("P1DT12H")) + assert.Equal(t, Duration{Days: 1, Hours: 12}, bound) + + // Empty input leaves the value untouched, like the other scalar types. + require.NoError(t, bound.Bind("")) + assert.Equal(t, Duration{Days: 1, Hours: 12}, bound) + + require.Error(t, bound.Bind("garbage")) +}