From 33ea9532fecf113106914c709d66878011d4039c Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Mon, 13 Jul 2026 00:02:41 +0200 Subject: [PATCH] feat(duration): add ISO 8601 / RFC 3339 duration format (duration-iso8601) * closes #278 Adds first-class support for the JSON Schema draft 2020-12 "duration" format (RFC 3339 Appendix A: P1Y2M3DT4H5M6S, P2W, ...) alongside the existing human-readable Duration, without breaking it. Type & parser - New generic type ISODuration[P ISODurationPolicy] with phantom, zero-size policies DurationStrict / DurationLenient, and the strict alias DurationISO8601 = ISODuration[DurationStrict]. - Single-pass, zero-allocation parser enforcing strict RFC 3339 App. A by default (component ordering & anchoring, W-exclusivity, ASCII digits, no fraction/sign/whitespace). Per-rule leniency is opt-in on the explicit ParseISO8601Duration(s, ...opts) path only -- never the registry/struct-field decode path, which must stay unambiguous. - Overflow handled with the stdlib/duration.go discipline: bound the integer part to 1<<63 before multiplying, then check after the fractional add, so the fraction path cannot silently wrap (MaxInt64/MinInt64 are exact). Serialization - JSON, Text (policy-aware emit: a strict value errors rather than emit output a strict parser would reject; String stays lossless for display), SQL (Scan/Value as int64 nanoseconds), and BSON. - BSON is treated as a storage boundary (lossless via String + a lenient parse-back), so values a strict policy refuses to emit on the text/JSON path (a sign, sub-second precision) still round-trip through storage. Registration - Registered under the distinct handle "duration-iso8601"; "duration" keeps mapping to the human-readable Duration (non-breaking). - Registered under the distinct handle "duration-human": the "human readable" (existing duration) - Modified the existing Default Registry so that "duration-human" remains the default mapping for "duration" - Added a new pre-built Registry (JSONSchema2020Registry) for which "duration-iso8601" becomes the default mapping for "duration" Prerequisites: - Prerequisite refactor: MapStructureHookFunc now decodes via encoding.TextUnmarshaler (reflect.New + UnmarshalText), removing the hand-maintained decodeFormatFromString switch so new formats need no edits there and the json / mapstructure paths stop diverging. Other additions: - conv.DurationISO8601 / conv.DurationISO8601Value helpers. Tests & tooling - Strict parser passes all 36 cases of the vendored JSON Schema Test Suite (testdata/jsonschema-suite/duration.json); plus lenient/options/value- semantics/overflow, registry/SQL/BSON, and a fuzz corpus (temporal bucket, including overflow boundaries). - Postgres / MariaDB / Mongo (both the bsonlite and real-driver codecs) integration tests. - Benchmarks confirm zero-alloc parsing in the same order of magnitude as time.ParseDuration. Also fixes benchmarkIs to rotate its input set (b.Loop() keeps b.N constant during the loop, so the prior index pinned one element). - Add testable examples for the ISO 8601 duration format. This documents the new API surface introduced with the duration-iso8601 format as godoc-rendered runnable examples (external strfmt_test package): Signed-off-by: Frederic BIDON --- .gitignore | 1 + README.md | 18 +- bson.go | 5 - conv/duration_iso8601.go | 21 + conv/duration_iso8601_test.go | 19 + date.go | 5 - default.go | 87 --- default_test.go | 10 +- duration.go | 5 - duration_compliance_test.go | 114 ++++ duration_iso8601.go | 583 ++++++++++++++++++ duration_iso8601_bench_test.go | 107 ++++ duration_iso8601_options.go | 80 +++ duration_iso8601_options_test.go | 72 +++ duration_iso8601_registry_test.go | 78 +++ duration_iso8601_test.go | 175 ++++++ example_duration_iso8601_test.go | 152 +++++ format.go | 120 ++-- format_test.go | 19 +- fuzz_test.go | 28 +- .../testintegration/mariadb/mariadb_test.go | 19 + .../testintegration/mongotest/mongotest.go | 32 + .../postgresql/postgresql_test.go | 19 + mongo.go | 34 + register.go | 127 ++++ testdata/jsonschema-suite/duration.json | 223 +++++++ time.go | 5 - ulid.go | 5 - 28 files changed, 1961 insertions(+), 202 deletions(-) create mode 100644 conv/duration_iso8601.go create mode 100644 conv/duration_iso8601_test.go create mode 100644 duration_compliance_test.go create mode 100644 duration_iso8601.go create mode 100644 duration_iso8601_bench_test.go create mode 100644 duration_iso8601_options.go create mode 100644 duration_iso8601_options_test.go create mode 100644 duration_iso8601_registry_test.go create mode 100644 duration_iso8601_test.go create mode 100644 example_duration_iso8601_test.go create mode 100644 register.go create mode 100644 testdata/jsonschema-suite/duration.json diff --git a/.gitignore b/.gitignore index bbdffea..20c4e0f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ .env .mcp.json go.work.sum +.worktrees diff --git a/README.md b/README.md index 4afef43..2f27d71 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ It also provides convenient extensions to go-openapi users. - [x] go-openapi custom format extensions - bsonobjectid (BSON objectID) - creditcard - - duration (e.g. "3 weeks", "1ms") + - duration (e.g. "3 weeks", "1ms") (aka "duration-human") - hexcolor (e.g. "#FFFFFF") - isbn, isbn10, isbn13 - mac (e.g "01:02:03:04:05:06") @@ -76,11 +76,26 @@ It also provides convenient extensions to go-openapi users. - uuid, uuid3, uuid4, uuid5, uuid7 - cidr (e.g. "192.0.2.1/24", "2001:db8:a0b:12f0::1/32") - ulid (e.g. "00000PP9HGSBSSDZ1JTEXBJ0PW", [spec](https://github.com/ulid/spec)) +- [x] JSON-schema draft 2020 formats + - duration-iso8601 B(e.g. "P2W") > NOTE: as the name stands for, this package is intended to support string formatting only. > It does not provide validation for numerical values with swagger format extension for JSON types "number" or > "integer" (e.g. float, double, int32...). +## Durations + +We have 2 very different definitions of the "duration" format: the "human-readable" duration that used to be just "duration", +and the new "duration-iso8601". There is no "dual" parser that accepts both formats: types are specialzed. + +To clarify the situation, a new alias for the duration format is introduced "duration-human" (e.g. "1 ms"), as opposed to +"duration-iso8601". + +The `Default` format registry wires "duration-human" as the default mapping for "duration" +(preexisting behavior, no breaking change - aligned with Swagger 2.0 which did not define "duration"). + +A new `JSONSchema2020` registry wires "duration-iso8601" as the default mapping for "duration". + ### Type conversion All types defined here are stringers and may be converted to strings with `.String()`. @@ -104,6 +119,7 @@ List of defined types: - Date - DateTime - Duration +- DurationISO8601 and `ISODuration[P ISODurationPolicy]` (for optional behavior) - Email - HexColor - Hostname diff --git a/bson.go b/bson.go index 16a83f6..b0c05d8 100644 --- a/bson.go +++ b/bson.go @@ -10,11 +10,6 @@ import ( "fmt" ) -func init() { //nolint:gochecknoinits // registers bsonobjectid format in the default registry - var id ObjectId - Default.Add("bsonobjectid", &id, IsBSONObjectID) -} - // IsBSONObjectID returns true when the string is a valid BSON [ObjectId]. func IsBSONObjectID(str string) bool { _, err := objectIDFromHex(str) diff --git a/conv/duration_iso8601.go b/conv/duration_iso8601.go new file mode 100644 index 0000000..69ac533 --- /dev/null +++ b/conv/duration_iso8601.go @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package conv + +import "github.com/go-openapi/strfmt" + +// DurationISO8601 returns a pointer to of the [strfmt.DurationISO8601] value passed in. +func DurationISO8601(v strfmt.DurationISO8601) *strfmt.DurationISO8601 { + return &v +} + +// DurationISO8601Value returns the value of the [strfmt.DurationISO8601] pointer passed in or +// the default value if the pointer is nil. +func DurationISO8601Value(v *strfmt.DurationISO8601) strfmt.DurationISO8601 { + if v == nil { + return strfmt.DurationISO8601(0) + } + + return *v +} diff --git a/conv/duration_iso8601_test.go b/conv/duration_iso8601_test.go new file mode 100644 index 0000000..b94da60 --- /dev/null +++ b/conv/duration_iso8601_test.go @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package conv + +import ( + "testing" + + "github.com/go-openapi/testify/v2/assert" + + "github.com/go-openapi/strfmt" +) + +func TestDurationISO8601Value(t *testing.T) { + assert.EqualT(t, strfmt.DurationISO8601(0), DurationISO8601Value(nil)) + duration := strfmt.DurationISO8601(42) + assert.EqualT(t, duration, DurationISO8601Value(&duration)) + assert.EqualT(t, duration, *DurationISO8601(duration)) +} diff --git a/date.go b/date.go index 59ee1f1..6d0e8a0 100644 --- a/date.go +++ b/date.go @@ -10,11 +10,6 @@ import ( "time" ) -func init() { //nolint:gochecknoinits // registers date format in the default registry - d := Date{} - Default.Add("date", &d, IsDate) -} - // IsDate returns true when the string is a valid date. func IsDate(str string) bool { _, err := time.Parse(RFC3339FullDate, str) diff --git a/default.go b/default.go index f406b41..bcc57e6 100644 --- a/default.go +++ b/default.go @@ -377,93 +377,6 @@ func IsEmail(str string) bool { return e == nil && addr.Address != "" } -func init() { //nolint:gochecknoinits // registers all default string formats in the registry - // register formats in the default registry: - // - byte - // - creditcard - // - email - // - hexcolor - // - hostname - // - ipv4 - // - ipv6 - // - cidr - // - isbn - // - isbn10 - // - isbn13 - // - mac - // - password - // - rgbcolor - // - ssn - // - uri - // - uuid - // - uuid3 - // - uuid4 - // - uuid5 - // - uuid7 - u := URI("") - Default.Add("uri", &u, isRequestURI) - - eml := Email("") - Default.Add("email", &eml, IsEmail) - - hn := Hostname("") - Default.Add("hostname", &hn, IsHostname) - - ip4 := IPv4("") - Default.Add("ipv4", &ip4, isIPv4) - - ip6 := IPv6("") - Default.Add("ipv6", &ip6, isIPv6) - - cidr := CIDR("") - Default.Add("cidr", &cidr, isCIDR) - - mac := MAC("") - Default.Add("mac", &mac, isMAC) - - uid := UUID("") - Default.Add("uuid", &uid, IsUUID) - - uid3 := UUID3("") - Default.Add("uuid3", &uid3, IsUUID3) - - uid4 := UUID4("") - Default.Add("uuid4", &uid4, IsUUID4) - - uid5 := UUID5("") - Default.Add("uuid5", &uid5, IsUUID5) - - uid7 := UUID7("") - Default.Add("uuid7", &uid7, IsUUID7) - - isbn := ISBN("") - Default.Add("isbn", &isbn, func(str string) bool { return isISBN10(str) || isISBN13(str) }) - - isbn10 := ISBN10("") - Default.Add("isbn10", &isbn10, isISBN10) - - isbn13 := ISBN13("") - Default.Add("isbn13", &isbn13, isISBN13) - - cc := CreditCard("") - Default.Add("creditcard", &cc, isCreditCard) - - ssn := SSN("") - Default.Add("ssn", &ssn, isSSN) - - hc := HexColor("") - Default.Add("hexcolor", &hc, isHexcolor) - - rc := RGBColor("") - Default.Add("rgbcolor", &rc, isRGBcolor) - - b64 := Base64([]byte(nil)) - Default.Add("byte", &b64, isBase64) - - pw := Password("") - Default.Add("password", &pw, func(_ string) bool { return true }) -} - // base64Encoding is the canonical alphabet for the [Base64] format. // // OpenAPI `format: byte` means standard base64 (RFC 4648 §4, the `+/` alphabet), not base64url. diff --git a/default_test.go b/default_test.go index dd2768b..83a150b 100644 --- a/default_test.go +++ b/default_test.go @@ -1095,11 +1095,17 @@ func BenchmarkIsUUID(b *testing.B) { func benchmarkIs(input []string, fn func(string) bool) func(*testing.B) { return func(b *testing.B) { - var isTrue bool + var ( + isTrue bool + i int + ) b.ReportAllocs() b.ResetTimer() + // Rotate with a local counter: under b.Loop(), b.N is constant during the loop, so input[b.N%len] would pin a + // single element instead of averaging the set. for b.Loop() { - isTrue = fn(input[b.N%len(input)]) + isTrue = fn(input[i%len(input)]) + i++ } fmt.Fprintln(io.Discard, isTrue) } diff --git a/duration.go b/duration.go index 4725903..fc4de0d 100644 --- a/duration.go +++ b/duration.go @@ -12,11 +12,6 @@ import ( "unicode" ) -func init() { //nolint:gochecknoinits // registers duration format in the default registry - d := Duration(0) - Default.Add("duration", &d, IsDuration) -} - const ( hoursInDay = 24 daysInWeek = 7 diff --git a/duration_compliance_test.go b/duration_compliance_test.go new file mode 100644 index 0000000..bc6c9e0 --- /dev/null +++ b/duration_compliance_test.go @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package strfmt + +import ( + "encoding/json" + "iter" + "os" + "slices" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestDurationISO8601_Compliance asserts the strict parser matches the official +// JSON Schema Test Suite for the "duration" format (RFC 3339 Appendix A). +func TestDurationISO8601_Compliance(t *testing.T) { + for tc := range durationTestCases(t) { + t.Run(tc.Desc, func(t *testing.T) { + got := IsDurationISO8601(tc.Input) + assert.Equalf(t, tc.Valid, got, + "input %q: strict validity mismatch (%s)", tc.Input, tc.Desc) + }) + } +} + +// TestDurationISO8601_Lenient checks that DurationLenient accepts exactly the +// cases the strict grammar rejects for leniency reasons. +func TestDurationISO8601_Lenient(t *testing.T) { + lenient := DurationLenient{}.isoDurationConfig() + + for tc := range lenientISO8601DurationCases() { + if tc.Valid { + t.Run("lenient/"+tc.Desc, func(t *testing.T) { + assert.Falsef(t, IsDurationISO8601(tc.Input), "strict should reject %q", tc.Input) + _, err := parseISO8601Duration(tc.Input, lenient) + assert.NoErrorf(t, err, "lenient should accept %q", tc.Input) + }) + + continue + } + + // Ordering is still enforced under leniency. + t.Run("lenient-rejects/"+tc.Desc, func(t *testing.T) { + _, err := parseISO8601Duration(tc.Input, lenient) + assert.Errorf(t, err, "lenient should still reject out-of-order %q", tc.Input) + }) + } +} + +// jsonSchemaSuiteGroup mirrors the JSON Schema Test Suite file layout. +type jsonSchemaSuiteGroup struct { + Description string `json:"description"` + Tests []struct { + Description string `json:"description"` + Data json.RawMessage `json:"data"` + Valid bool `json:"valid"` + } `json:"tests"` +} + +type durationTestCase struct { + Desc string + Input string + Valid bool +} + +func durationTestCases(t *testing.T) iter.Seq[durationTestCase] { + t.Helper() + + return func(yield func(durationTestCase) bool) { + raw, err := os.ReadFile("testdata/jsonschema-suite/duration.json") + require.NoError(t, err) + + var groups []jsonSchemaSuiteGroup + require.NoError(t, json.Unmarshal(raw, &groups)) + + for _, g := range groups { + for _, tc := range g.Tests { + // The parser only ever sees string data; the format keyword ignores + // non-string JSON values. Skip explicit JSON null (decodes to ""). + if string(tc.Data) == "null" { + continue + } + var s string + if json.Unmarshal(tc.Data, &s) != nil { + continue + } + + tc := durationTestCase{tc.Description, s, tc.Valid} + if !yield(tc) { + return + } + } + } + } +} + +func lenientISO8601DurationCases() iter.Seq[durationTestCase] { + return slices.Values([]durationTestCase{ + {Input: "PT0.5S", Desc: "fraction", Valid: true}, + {Input: "P1,5D", Desc: "comma fraction", Valid: true}, + {Input: "-P1D", Desc: "sign", Valid: true}, + {Input: "+P1D", Desc: "sign", Valid: true}, + {Input: " P1D", Desc: "whitespace", Valid: true}, + {Input: "P1D ", Desc: "whitespace", Valid: true}, + {Input: "P1Y2D", Desc: "relaxed anchoring (gap)", Valid: true}, + {Input: "PT1H2S", Desc: "relaxed anchoring (gap)", Valid: true}, + {Input: "P1Y2W", Desc: "week combinable", Valid: true}, + {Input: "P2D1Y", Desc: "invalid under lenient", Valid: false}, + {Input: "PT1M2H", Desc: "invalid under lenient (2)", Valid: false}, + }) +} diff --git a/duration_iso8601.go b/duration_iso8601.go new file mode 100644 index 0000000..9fded03 --- /dev/null +++ b/duration_iso8601.go @@ -0,0 +1,583 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package strfmt + +import ( + "database/sql/driver" + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + "time" +) + +// ISO 8601 duration unit lengths, expressed in nanoseconds. +// +// Calendar units are collapsed to fixed lengths (a year is 365 days, a month is 30 days): a [time.Duration] cannot +// carry calendar context, so this conversion is inherently lossy and not calendar-correct. +// It matches the historical behaviour of ISO duration libraries in this space. +const ( + isoSecond = uint64(time.Second) + isoMinute = uint64(time.Minute) + isoHour = uint64(time.Hour) + isoDay = 24 * isoHour + isoWeek = 7 * isoDay + isoMonth = 30 * isoDay + isoYear = 365 * isoDay + + // maxDurationMagnitude is 1<<63: the magnitude of [math.MinInt64], and one more than [math.MaxInt64]. + // + // A parsed magnitude may reach exactly this value only for a negative duration (yielding [math.MinInt64]). + maxDurationMagnitude = uint64(1) << 63 +) + +// Base-10 parsing / formatting constants (decimalBase lives in default.go). +const ( + // decimalAccMax bounds a uint64 accumulator so that acc*decimalBase + 9 cannot overflow. + decimalAccMax = (math.MaxUint64 - (decimalBase - 1)) / decimalBase + // nanoDigits is the number of fractional digits in one whole second (nanosecond precision). + nanoDigits = 9 +) + +// ISODuration is an ISO 8601 / RFC 3339 duration (e.g. "P1Y2M3DT4H5M6S", "P2W"). +// +// The type parameter P selects the parsing policy at compile time (see [ISODurationPolicy]). +// Every instantiation is a distinct type that round-trips through JSON, text, SQL and BSON. +// Use the [DurationISO8601] alias for the strict, spec-compliant default. +// +// Like [time.Duration], it stores a nanosecond count; the largest representable duration is approximately 290 years. +// Calendar units are collapsed to fixed lengths (year = 365 days, month = 30 days), which is lossy by nature. +// +// This generic type carries no swagger:strfmt annotation: go-swagger binds a concrete format name to the +// [DurationISO8601] alias, not to a generic declaration. +type ISODuration[P ISODurationPolicy] time.Duration + +// DurationISO8601 is the strict, spec-compliant ISO 8601 duration: the JSON Schema draft 2020 "duration" format +// (RFC 3339 Appendix A). +// +// It binds to the explicit, unambiguous "duration-iso8601" handle. Plain "duration" is a context-dependent +// default resolved by the registry ([Default] → human, [JSONSchema2020Registry] → ISO), not by this static type. +// +// swagger:strfmt duration-iso8601. +type DurationISO8601 = ISODuration[DurationStrict] + +// ParseISO8601Duration parses an ISO 8601 / RFC 3339 duration string. +// +// With no options it enforces the strict RFC 3339 Appendix A grammar. +// Options relax individual rules for programmatic callers. +func ParseISO8601Duration(s string, opts ...ISODurationOption) (time.Duration, error) { + cfg := DurationStrict{}.isoDurationConfig() + for _, o := range opts { + o(&cfg) + } + return parseISO8601Duration(s, cfg) +} + +// --- Format / encoding methods --- + +// String renders the duration in canonical ISO 8601 form. +// +// Unlike [ISODuration.MarshalText], String is a best-effort display form: it always renders the true value (lossless), +// even under a strict policy that could not serialize it (e.g. a sign or sub-second precision). +func (d ISODuration[P]) String() string { return isoFormat(time.Duration(d)) } + +// MarshalText implements [encoding.TextMarshaler], applying the policy P: a strict policy errors on a value it cannot +// represent (see [isoEmit]). +func (d ISODuration[P]) MarshalText() ([]byte, error) { + var p P + s, err := isoEmit(time.Duration(d), p.isoDurationConfig()) + if err != nil { + return nil, err + } + return []byte(s), nil +} + +// UnmarshalText implements [encoding.TextUnmarshaler], applying the policy P. +func (d *ISODuration[P]) UnmarshalText(text []byte) error { + var p P + dd, err := parseISO8601Duration(string(text), p.isoDurationConfig()) + if err != nil { + return err + } + *d = ISODuration[P](dd) + return nil +} + +// MarshalJSON returns the duration as a JSON string, applying the policy P. +func (d ISODuration[P]) MarshalJSON() ([]byte, error) { + var p P + s, err := isoEmit(time.Duration(d), p.isoDurationConfig()) + if err != nil { + return nil, err + } + return json.Marshal(s) +} + +// UnmarshalJSON sets the duration from a JSON string, applying the policy P. +func (d *ISODuration[P]) UnmarshalJSON(data []byte) error { + if string(data) == jsonNull { + return nil + } + var str string + if err := json.Unmarshal(data, &str); err != nil { + return err + } + var p P + dd, err := parseISO8601Duration(str, p.isoDurationConfig()) + if err != nil { + return err + } + *d = ISODuration[P](dd) + return nil +} + +// Scan reads a duration (nanoseconds) from a database driver value. +func (d *ISODuration[P]) Scan(raw any) error { + switch v := raw.(type) { + case int64: + *d = ISODuration[P](v) + case float64: + *d = ISODuration[P](int64(v)) + case nil: + *d = ISODuration[P](0) + default: + return fmt.Errorf("cannot sql.Scan() strfmt.ISODuration from: %#v: %w", v, ErrFormat) + } + return nil +} + +// Value writes the duration as a nanosecond count. +func (d ISODuration[P]) Value() (driver.Value, error) { + return driver.Value(int64(d)), nil +} + +// Equal reports whether two durations are equal. +func (d ISODuration[P]) Equal(other ISODuration[P]) bool { return d == other } + +// DeepCopyInto copies the receiver into out. +func (d *ISODuration[P]) DeepCopyInto(out *ISODuration[P]) { *out = *d } + +// DeepCopy copies the receiver into a new value. +func (d *ISODuration[P]) DeepCopy() *ISODuration[P] { + if d == nil { + return nil + } + out := new(ISODuration[P]) + d.DeepCopyInto(out) + return out +} + +// IsDurationISO8601 returns true if the string is a valid strict ISO 8601 duration. +func IsDurationISO8601(s string) bool { + _, err := parseISO8601Duration(s, DurationStrict{}.isoDurationConfig()) + return err == nil +} + +func isoDurationError(input, msg string) error { + return fmt.Errorf("invalid ISO 8601 duration %q: %s: %w", input, msg, ErrFormat) +} + +// section positions used to enforce component ordering (strictly increasing). +// +//nolint:gochecknoglobals,mnd // immutable ordinal lookup tables; the integers are sequence positions, not magic constants +var ( + isoDatePos = map[byte]int{'Y': 1, 'M': 2, 'W': 3, 'D': 4} + isoTimePos = map[byte]int{'H': 1, 'M': 2, 'S': 3} +) + +// isoDateSlot maps the year/month/day designators to contiguous slots for the strict anchoring (gap) check. +// +// 'W' is deliberately excluded: in strict mode it is exclusive, so it never coexists with Y/M/D. +func isoDateSlot(des byte) (int, bool) { + switch des { + case 'Y': + return 0, true + case 'M': + return 1, true + case 'D': + return 2, true //nolint:mnd // contiguous slot index (Y=0, M=1, D=2), not a magic constant + default: + return 0, false + } +} + +//nolint:gocognit,gocyclo,cyclop // a single-pass grammar scanner; the branches mirror the ABNF. +func parseISO8601Duration(input string, cfg isoDurationConfig) (time.Duration, error) { + s := input + if cfg.allowSpace { + s = strings.TrimSpace(s) + } + + neg := false + if cfg.allowSign && s != "" && (s[0] == '+' || s[0] == '-') { + neg = s[0] == '-' + s = s[1:] + } + + if s == "" || s[0] != 'P' { + return 0, isoDurationError(input, `must start with "P"`) + } + s = s[1:] + if s == "" { + return 0, isoDurationError(input, "no components after P") + } + + var ( + total uint64 + inTime bool + seenAny bool + weekSeen bool + fractionUsed bool + lastDatePos int + lastTimePos int + datePresent [3]bool // Y, M, D — for the anchoring (gap) check + timePresent [3]bool // H, M, S + ) + + for s != "" { + c := s[0] + + if c == 'T' { + if inTime { + return 0, isoDurationError(input, `duplicate "T" separator`) + } + if weekSeen && !cfg.weekCombinable { + return 0, isoDurationError(input, `"W" cannot be combined with other components`) + } + inTime = true + s = s[1:] + if s == "" { + return 0, isoDurationError(input, `no components after "T"`) + } + continue + } + + // A fraction, if any, must be on the least significant component: nothing may follow it. + if fractionUsed { + return 0, isoDurationError(input, "a fraction is only allowed on the least significant component") + } + + // Scan the integer part (ASCII digits only). + i := 0 + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + if i == 0 { + return 0, isoDurationError(input, fmt.Sprintf("expected a digit, got %q", s[0])) + } + intPart := s[:i] + + // Optional decimal fraction. + var fracPart string + hasFrac := false + if i < len(s) && (s[i] == '.' || s[i] == ',') { + if !cfg.allowFraction { + return 0, isoDurationError(input, "decimal fraction is not allowed") + } + hasFrac = true + j := i + 1 + for j < len(s) && s[j] >= '0' && s[j] <= '9' { + j++ + } + fracPart = s[i+1 : j] + i = j + } + + if i >= len(s) { + return 0, isoDurationError(input, "value without a unit designator") + } + des := s[i] + s = s[i+1:] + + unit, err := isoResolveUnit(input, des, inTime, cfg, + &lastDatePos, &lastTimePos, &weekSeen, seenAny, &datePresent, &timePresent) + if err != nil { + return 0, err + } + seenAny = true + if hasFrac { + fractionUsed = true + } + + val, err := isoScaleValue(input, intPart, fracPart, unit) + if err != nil { + return 0, err + } + + // Accumulate with the stdlib overflow discipline: the magnitude may reach 1<<63 only for a negative duration. + if total > maxDurationMagnitude-val { + return 0, isoDurationError(input, "value out of range") + } + total += val + } + + if inTime && !timePresent[0] && !timePresent[1] && !timePresent[2] { + return 0, isoDurationError(input, `no components after "T"`) + } + if !seenAny { + return 0, isoDurationError(input, "no components") + } + + // Strict anchoring: the present Y/M/D and H/M/S components must each form a contiguous run (e.g. P1Y2D and PT1H2S are + // rejected). + if !cfg.relaxAnchoring { + if err := isoCheckContiguous(input, datePresent, "date"); err != nil { + return 0, err + } + if err := isoCheckContiguous(input, timePresent, "time"); err != nil { + return 0, err + } + } + + if total > maxDurationMagnitude-1 { + if neg && total == maxDurationMagnitude { + return math.MinInt64, nil + } + return 0, isoDurationError(input, "value out of range") + } + if neg { + return -time.Duration(total), nil + } + return time.Duration(total), nil +} + +// isoResolveUnit validates a designator in context (section, ordering, week +// exclusivity) and returns its unit length in nanoseconds. +func isoResolveUnit( + input string, des byte, inTime bool, cfg isoDurationConfig, + lastDatePos, lastTimePos *int, weekSeen *bool, seenAny bool, + datePresent, timePresent *[3]bool, +) (uint64, error) { + if inTime { + pos, ok := isoTimePos[des] + if !ok { + return 0, isoDurationError(input, fmt.Sprintf("%q is not a valid time-section designator", des)) + } + if pos <= *lastTimePos { + return 0, isoDurationError(input, fmt.Sprintf("designator %q is out of order", des)) + } + *lastTimePos = pos + timePresent[pos-1] = true + switch des { + case 'H': + return isoHour, nil + case 'M': + return isoMinute, nil + default: // 'S' + return isoSecond, nil + } + } + + pos, ok := isoDatePos[des] + if !ok { + return 0, isoDurationError(input, fmt.Sprintf("%q is not a valid date-section designator", des)) + } + if pos <= *lastDatePos { + return 0, isoDurationError(input, fmt.Sprintf("designator %q is out of order", des)) + } + + if des == 'W' { + if !cfg.weekCombinable && seenAny { + return 0, isoDurationError(input, `"W" cannot be combined with other components`) + } + *lastDatePos = pos + *weekSeen = true + return isoWeek, nil + } + + if *weekSeen && !cfg.weekCombinable { + return 0, isoDurationError(input, `"W" cannot be combined with other components`) + } + *lastDatePos = pos + if slot, ok := isoDateSlot(des); ok { + datePresent[slot] = true + } + switch des { + case 'Y': + return isoYear, nil + case 'M': + return isoMonth, nil + default: // 'D' + return isoDay, nil + } +} + +// isoCheckContiguous rejects gaps in a section's component chain. +func isoCheckContiguous(input string, present [3]bool, section string) error { + first, last := -1, -1 + for i, p := range present { + if p { + if first < 0 { + first = i + } + last = i + } + } + if first < 0 { + return nil + } + for i := first; i <= last; i++ { + if !present[i] { + return isoDurationError(input, "non-contiguous "+section+" components (a gap in the unit chain)") + } + } + return nil +} + +// isoScaleValue converts an integer (and optional fractional) component to nanoseconds, checking every overflow +// boundary. +func isoScaleValue(input, intPart, fracPart string, unit uint64) (uint64, error) { + v, ok := isoParseUint(intPart) + if !ok { + return 0, isoDurationError(input, "value out of range") + } + // Bound v*unit to the duration magnitude *before* multiplying, so the later fractional addition cannot wrap a uint64. + if v > maxDurationMagnitude/unit { + return 0, isoDurationError(input, "value out of range") + } + val := v * unit + + if fracPart != "" { + f, scale := isoParseFraction(fracPart) + if f > 0 { + // float64 is accurate enough for a sub-unit fraction (matches the standard library technique); the magnitude stays + // below the uint64 ceiling because val <= 1<<63 and the addend is < unit. + val += uint64(float64(f) * (float64(unit) / float64(scale))) + if val > maxDurationMagnitude { + return 0, isoDurationError(input, "value out of range") + } + } + } + + return val, nil +} + +// isoParseUint parses digits into a uint64, reporting overflow. +func isoParseUint(s string) (uint64, bool) { + var v uint64 + for i := range len(s) { + if v > decimalAccMax { + return 0, false + } + v = v*decimalBase + uint64(s[i]-'0') + } + + return v, true +} + +// isoParseFraction parses fractional digits into (value, scale=10^len), capping precision at 18 digits so the value +// stays exact in the subsequent float maths. +func isoParseFraction(s string) (uint64, uint64) { + const maxFracDigits = 18 + if len(s) > maxFracDigits { + s = s[:maxFracDigits] + } + var f, scale uint64 = 0, 1 + for i := range len(s) { + f = f*decimalBase + uint64(s[i]-'0') + scale *= decimalBase + } + + return f, scale +} + +// isoFormat renders a duration in canonical ISO 8601 form (P[n]DT[n]H[n]M[n]S). +// +// Intermediate zero time components are emitted when needed to keep the output contiguous (e.g. 1h5s → "PT1H0M5S"), +// so the structure is always syntactically valid ISO 8601. Years, months and weeks are not reconstructed (a +// [time.Duration] does not carry calendar context); a fractional second is emitted losslessly, and a negative duration +// is prefixed with "-". +func isoFormat(d time.Duration) string { + if d == 0 { + return "PT0S" + } + neg := d < 0 + var u uint64 + if neg { + u = uint64(-d) + } else { + u = uint64(d) + } + + days := u / isoDay + u %= isoDay + hours := u / isoHour + u %= isoHour + minutes := u / isoMinute + u %= isoMinute + seconds := u / isoSecond + frac := u % isoSecond + + var b strings.Builder + if neg { + b.WriteByte('-') + } + + b.WriteByte('P') + if days > 0 { + b.WriteString(strconv.FormatUint(days, decimalBase)) + b.WriteByte('D') + } + isoWriteTimeSection(&b, hours, minutes, seconds, frac) + + return b.String() +} + +// isoWriteTimeSection appends the "T…" part of a canonical ISO 8601 duration, emitting a zero-minute filler when it +// must bridge hours and seconds so the output stays contiguous (e.g. 1h5s → "T1H0M5S"). +func isoWriteTimeSection(b *strings.Builder, hours, minutes, seconds, frac uint64) { + hasH := hours > 0 + hasM := minutes > 0 + hasS := seconds > 0 || frac > 0 + if !hasH && !hasM && !hasS { + return + } + + b.WriteByte('T') + if hasH { + b.WriteString(strconv.FormatUint(hours, decimalBase)) + b.WriteByte('H') + } + + // Emit minutes if non-zero, or as a zero filler bridging hours and seconds. + if hasM || (hasH && hasS) { + b.WriteString(strconv.FormatUint(minutes, decimalBase)) + b.WriteByte('M') + } + + if hasS { + b.WriteString(strconv.FormatUint(seconds, decimalBase)) + if frac > 0 { + b.WriteByte('.') + b.WriteString(isoFormatFraction(frac)) + } + b.WriteByte('S') + } +} + +// isoEmit renders d under the given policy. +// +// A policy that forbids a feature the value requires (a sign, or sub-second precision) cannot represent it, and returns +// an error rather than emitting output a matching parser would reject. +func isoEmit(d time.Duration, cfg isoDurationConfig) (string, error) { + if !cfg.allowSign && d < 0 { + return "", fmt.Errorf("a negative duration cannot be represented under this ISO 8601 policy: %w", ErrFormat) + } + + if !cfg.allowFraction && d%time.Second != 0 { + return "", fmt.Errorf("sub-second precision cannot be represented under this ISO 8601 policy: %w", ErrFormat) + } + + return isoFormat(d), nil +} + +// isoFormatFraction renders a nanosecond remainder (0..1e9) as its 9-digit fractional part with trailing zeros trimmed. +func isoFormatFraction(frac uint64) string { + s := strconv.FormatUint(frac, decimalBase) + if len(s) < nanoDigits { + s = strings.Repeat("0", nanoDigits-len(s)) + s + } + return strings.TrimRight(s, "0") +} diff --git a/duration_iso8601_bench_test.go b/duration_iso8601_bench_test.go new file mode 100644 index 0000000..d0ac55b --- /dev/null +++ b/duration_iso8601_bench_test.go @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package strfmt + +import ( + "fmt" + "io" + "testing" + "time" +) + +// BenchmarkParseISO8601Duration measures the single-pass parser and pins it against the standard library. +// +// The parser is designed to be zero-alloc and to stay in the same order of magnitude as time.ParseDuration. +// The "strict-time-only" and "stdlib-ParseDuration" sub-benchmarks parse the *same* durations (ISO- vs Go-encoded) +// for an apples-to-apples comparison; the calendar designators (Y/M/W) have no time.Duration-string equivalent. +func BenchmarkParseISO8601Duration(b *testing.B) { + // Full calendar+time inputs exercising every designator. + isoInputs := []string{ + "P1Y2M3DT4H5M6S", + "P4DT12H30M5S", + "P1DT12H", + "P2W", + "PT1H30M", + "PT0S", + } + b.Run("strict", benchmarkParseISO(isoInputs, DurationStrict{}.isoDurationConfig())) + b.Run("lenient", benchmarkParseISO(isoInputs, DurationLenient{}.isoDurationConfig())) + + // Fractional inputs exercise the float path; fractions are lenient-only. + fracInputs := []string{ + "PT0.5S", + "PT1H30M0.25S", + "PT23.999999999S", + } + b.Run("lenient-fraction", benchmarkParseISO(fracInputs, DurationLenient{}.isoDurationConfig())) + + // Apples-to-apples yardstick: the same time-only durations, ISO- vs Go-encoded. + isoTimeInputs := []string{ + "PT4H5M6S", + "PT12H30M5S", + "PT1H30M", + "PT0S", + } + goInputs := []string{ + "4h5m6s", + "12h30m5s", + "1h30m", + "0s", + } + b.Run("strict-time-only", benchmarkParseISO(isoTimeInputs, DurationStrict{}.isoDurationConfig())) + b.Run("stdlib-ParseDuration", func(b *testing.B) { + var ( + d time.Duration + err error + i int + ) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + d, err = time.ParseDuration(goInputs[i%len(goInputs)]) + i++ + } + fmt.Fprintln(io.Discard, d, err) + }) +} + +// BenchmarkISODurationFormat measures the canonical marshaler (isoFormat). +func BenchmarkISODurationFormat(b *testing.B) { + durations := []time.Duration{ + 4*24*time.Hour + 12*time.Hour + 30*time.Minute + 5*time.Second, + 90 * time.Minute, + time.Second + 500*time.Millisecond, + -(time.Hour + 30*time.Minute), + 0, + } + var ( + s string + i int + ) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + s = isoFormat(durations[i%len(durations)]) + i++ + } + fmt.Fprintln(io.Discard, s) +} + +// benchmarkParseISO benchmarks parseISO8601Duration over a rotating set of inputs under a single policy. +func benchmarkParseISO(inputs []string, cfg isoDurationConfig) func(*testing.B) { + return func(b *testing.B) { + var ( + d time.Duration + err error + i int + ) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + d, err = parseISO8601Duration(inputs[i%len(inputs)], cfg) + i++ + } + fmt.Fprintln(io.Discard, d, err) + } +} diff --git a/duration_iso8601_options.go b/duration_iso8601_options.go new file mode 100644 index 0000000..4f90a5c --- /dev/null +++ b/duration_iso8601_options.go @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package strfmt + +// ISODurationPolicy is a compile-time policy selecting the parsing behaviour of an [ISODuration]. +// +// It is a phantom type parameter: a zero-size value whose single method yields the parser configuration. +// See [DurationStrict] and [DurationLenient]. +type ISODurationPolicy interface { + isoDurationConfig() isoDurationConfig +} + +// isoDurationConfig holds the leniency knobs of the ISO 8601 duration parser. +// +// The zero value is the strict RFC 3339 Appendix A grammar (the JSON Schema "duration" format): every field false. +type isoDurationConfig struct { + allowFraction bool // accept a decimal fraction on the least significant component + allowSign bool // accept a leading '+' or '-' + allowSpace bool // tolerate surrounding whitespace + weekCombinable bool // allow the 'W' designator to combine with other components + relaxAnchoring bool // allow gaps in the component chain (e.g. P1Y2D, PT1H2S) +} + +// DurationStrict is the strict RFC 3339 Appendix A policy. +// +// This is the grammar that the JSON Schema "duration" format is defined against (draft 2020): +// no fraction, no sign, no whitespace, strict ordering and anchoring, and an exclusive 'W' designator. +type DurationStrict struct{} + +func (DurationStrict) isoDurationConfig() isoDurationConfig { return isoDurationConfig{} } + +// DurationLenient relaxes every strictness knob. +// +// It accepts a decimal fraction on the least significant component, a leading sign, surrounding whitespace, +// component chains with gaps (e.g. P1Y2D), and a 'W' designator combined with other components. +// +// It remains ordered (P2D1Y is still rejected). +type DurationLenient struct{} + +func (DurationLenient) isoDurationConfig() isoDurationConfig { + return isoDurationConfig{ + allowFraction: true, + allowSign: true, + allowSpace: true, + weekCombinable: true, + relaxAnchoring: true, + } +} + +// ISODurationOption relaxes the strict parser on the explicit [ParseISO8601Duration] path, for programmatic callers. +// +// It has no effect on the registry / struct-field decode path, which is governed by the type's policy. +type ISODurationOption func(*isoDurationConfig) + +// WithISOFractions accepts a decimal fraction on the least significant component. +func WithISOFractions() ISODurationOption { + return func(c *isoDurationConfig) { c.allowFraction = true } +} + +// WithISOSign accepts a leading '+' or '-'. +func WithISOSign() ISODurationOption { return func(c *isoDurationConfig) { c.allowSign = true } } + +// WithISOSpace tolerates surrounding whitespace. +func WithISOSpace() ISODurationOption { return func(c *isoDurationConfig) { c.allowSpace = true } } + +// WithISOWeekCombinable allows the 'W' designator to combine with other components. +func WithISOWeekCombinable() ISODurationOption { + return func(c *isoDurationConfig) { c.weekCombinable = true } +} + +// WithISORelaxedAnchoring allows gaps in the component chain (e.g. P1Y2D, PT1H2S). +func WithISORelaxedAnchoring() ISODurationOption { + return func(c *isoDurationConfig) { c.relaxAnchoring = true } +} + +// WithISOLenient relaxes every strictness knob (see [DurationLenient]). +func WithISOLenient() ISODurationOption { + return func(c *isoDurationConfig) { *c = DurationLenient{}.isoDurationConfig() } +} diff --git a/duration_iso8601_options_test.go b/duration_iso8601_options_test.go new file mode 100644 index 0000000..77ea163 --- /dev/null +++ b/duration_iso8601_options_test.go @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package strfmt + +import ( + "testing" + "time" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestParseISO8601Duration_Options exercises the explicit programmatic parse path: each option relaxes exactly one +// strict rule, and the strict default rejects what the matching option accepts. +func TestParseISO8601Duration_Options(t *testing.T) { + cases := []struct { + name string + in string + opt ISODurationOption + want time.Duration + }{ + {"fractions", "PT0.5S", WithISOFractions(), 500 * time.Millisecond}, + {"sign", "-P1D", WithISOSign(), -24 * time.Hour}, + {"space", " P1D ", WithISOSpace(), 24 * time.Hour}, + {"relaxed-anchoring", "P1Y2D", WithISORelaxedAnchoring(), isoYearDur + 2*24*time.Hour}, + {"week-combinable", "P1WT1H", WithISOWeekCombinable(), 7*24*time.Hour + time.Hour}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // strict default rejects it + _, err := ParseISO8601Duration(tc.in) + require.Error(t, err, "strict default must reject %q", tc.in) + + // the matching option accepts it + got, err := ParseISO8601Duration(tc.in, tc.opt) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } + + // WithISOLenient relaxes every knob at once. + got, err := ParseISO8601Duration(" -P1Y2WT0.5S ", WithISOLenient()) + require.NoError(t, err) + assert.Equal(t, -(isoYearDur + 2*7*24*time.Hour + 500*time.Millisecond), got) + + // ordering is never relaxed, even under full leniency. + _, err = ParseISO8601Duration("P2D1Y", WithISOLenient()) + require.Error(t, err, "out-of-order components stay invalid") +} + +func TestDurationISO8601_ValueSemantics(t *testing.T) { + a := DurationISO8601(time.Hour) + b := DurationISO8601(time.Hour) + c := DurationISO8601(2 * time.Hour) + assert.True(t, a.Equal(b)) + assert.False(t, a.Equal(c)) + + // DeepCopy yields an independent, equal value. + cp := a.DeepCopy() + require.NotNil(t, cp) + assert.True(t, a.Equal(*cp)) + *cp = c + assert.True(t, a.Equal(b), "mutating the copy must not touch the original") + + // DeepCopy of a nil pointer is nil. + var nilp *DurationISO8601 + assert.Nil(t, nilp.DeepCopy()) +} + +// isoYearDur is the fixed-length ISO year as a time.Duration (365 days), for readable expectations above. +const isoYearDur = 365 * 24 * time.Hour diff --git a/duration_iso8601_registry_test.go b/duration_iso8601_registry_test.go new file mode 100644 index 0000000..c510c33 --- /dev/null +++ b/duration_iso8601_registry_test.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package strfmt + +import ( + "reflect" + "testing" + "time" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestDurationISO8601_Registration(t *testing.T) { + // The strict ISO 8601 duration is registered under a distinct handle; "duration" stays the human-readable type. + assert.True(t, Default.ContainsName("duration-iso8601")) + + tpe, ok := Default.GetType("duration-iso8601") + require.True(t, ok) + assert.Equal(t, reflect.TypeFor[DurationISO8601](), tpe) + + // Validation goes through the strict validator. + assert.True(t, Default.Validates("duration-iso8601", "P1DT2H")) + assert.False(t, Default.Validates("duration-iso8601", "PT0.5S"), "strict rejects fractions") + assert.False(t, Default.Validates("duration-iso8601", "1h30m"), "the ISO handle is not the human-readable parser") + + // Parse goes through UnmarshalText and yields a typed value. + parsed, err := Default.Parse("duration-iso8601", "P1DT2H") + require.NoError(t, err) + d, ok := parsed.(*DurationISO8601) + require.True(t, ok) + assert.Equal(t, 26*time.Hour, time.Duration(*d)) + + // The human-readable "duration" handle is untouched (non-breaking). + dtype, ok := Default.GetType("duration") + require.True(t, ok) + assert.Equal(t, reflect.TypeFor[Duration](), dtype) +} + +func TestDurationISO8601_SQL(t *testing.T) { + d := DurationISO8601(26 * time.Hour) + v, err := d.Value() + require.NoError(t, err) + assert.Equal(t, int64(26*time.Hour), v) + + var back DurationISO8601 + require.NoError(t, back.Scan(int64(26*time.Hour))) + assert.Equal(t, d, back) + + require.NoError(t, back.Scan(float64(time.Second))) + assert.Equal(t, DurationISO8601(time.Second), back) + + require.NoError(t, back.Scan(nil)) + assert.Equal(t, DurationISO8601(0), back) + + require.Error(t, back.Scan("P1D"), "string is not a valid SQL source") +} + +func TestDurationISO8601_BSON(t *testing.T) { + // BSON is a storage boundary: it must round-trip losslessly regardless of policy — including values a strict policy + // cannot emit on the text/JSON path (a sign, or sub-second precision). + cases := []time.Duration{ + 0, + 26 * time.Hour, + time.Second + 500*time.Millisecond, + -(time.Hour + 30*time.Minute), + } + for _, want := range cases { + d := DurationISO8601(want) + data, err := d.MarshalBSON() + require.NoError(t, err, "storage marshal must never fail on a representable duration") + + var back DurationISO8601 + require.NoError(t, back.UnmarshalBSON(data)) + assert.Equal(t, want, time.Duration(back)) + } +} diff --git a/duration_iso8601_test.go b/duration_iso8601_test.go new file mode 100644 index 0000000..cb31afb --- /dev/null +++ b/duration_iso8601_test.go @@ -0,0 +1,175 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package strfmt + +import ( + "math" + "testing" + "time" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +func TestDurationISO8601_Values(t *testing.T) { + strict := DurationStrict{}.isoDurationConfig() + cases := map[string]time.Duration{ + "P1Y": isoYearD(1), + "P1M": 30 * 24 * time.Hour, + "P2W": 14 * 24 * time.Hour, + "P1D": 24 * time.Hour, + "PT1H": time.Hour, + "PT1M": time.Minute, + "PT30S": 30 * time.Second, + "P4DT12H30M5S": 4*24*time.Hour + 12*time.Hour + 30*time.Minute + 5*time.Second, + "P1Y2M3DT4H5M6S": isoYearD(1) + 2*30*24*time.Hour + 3*24*time.Hour + 4*time.Hour + 5*time.Minute + 6*time.Second, + "PT36H": 36 * time.Hour, + "P1DT12H": 36 * time.Hour, + "PT0S": 0, + "P0D": 0, + } + for in, want := range cases { + t.Run(in, func(t *testing.T) { + got, err := parseISO8601Duration(in, strict) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + } +} + +func isoYearD(n int64) time.Duration { return time.Duration(n) * 365 * 24 * time.Hour } + +func TestDurationISO8601_Fractions(t *testing.T) { + lenient := DurationLenient{}.isoDurationConfig() + cases := map[string]time.Duration{ + "PT1.5S": time.Second + 500*time.Millisecond, + "PT1,5S": time.Second + 500*time.Millisecond, + "P1.5D": 36 * time.Hour, + "PT0.5M": 30 * time.Second, + "PT0.25H": 15 * time.Minute, + "PT0.001S": time.Millisecond, + "PT0.000000001S": time.Nanosecond, + "PT0.123456789S": 123456789 * time.Nanosecond, + } + for in, want := range cases { + t.Run(in, func(t *testing.T) { + got, err := parseISO8601Duration(in, lenient) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + } +} + +// TestDurationISO8601_Overflow guards the silent-overflow class of bug: a value past the time.Duration ceiling must +// error, never wrap to a small value. +func TestDurationISO8601_Overflow(t *testing.T) { + lenient := DurationLenient{}.isoDurationConfig() + + mustErr := []string{ + "PT18446744073S", // integer, way over 1<<63 + "PT18446744073.9S", // the fraction-wrap case: must error, not 190ms + "PT18446744073.999999999S", // + "PT9223372037S", // just over MaxInt64 seconds + "PT9223372036.854775808S", // MaxInt64 + 1 + "P999999999999999999999999Y", + "P100000000Y", + } + for _, in := range mustErr { + t.Run("err/"+in, func(t *testing.T) { + d, err := parseISO8601Duration(in, lenient) + assert.Errorf(t, err, "expected overflow error, got %v", d) + }) + } + + // Exact boundaries must parse. + maxD, err := parseISO8601Duration("PT9223372036.854775807S", lenient) + require.NoError(t, err) + assert.Equal(t, time.Duration(math.MaxInt64), maxD) + + minD, err := parseISO8601Duration("-PT9223372036.854775808S", lenient) + require.NoError(t, err) + assert.Equal(t, time.Duration(math.MinInt64), minD) +} + +func TestDurationISO8601_RoundTrip(t *testing.T) { + // Strict-representable values: whole-second, non-negative. + // Canonical output must re-parse under the STRICT policy to the same value (true symmetry), including the zero-filler + // that keeps a gap contiguous (1h5s -> PT1H0M5S). + strictCases := map[time.Duration]string{ + 0: "PT0S", + time.Second: "PT1S", + 90 * time.Minute: "PT1H30M", + 25 * time.Hour: "P1DT1H", + 36 * time.Hour: "P1DT12H", + time.Hour + 5*time.Second: "PT1H0M5S", // gap filled with 0M + 4*24*time.Hour + 30*time.Minute: "P4DT30M", + } + for d, want := range strictCases { + t.Run("strict/"+want, func(t *testing.T) { + assert.Equal(t, want, isoFormat(d)) + back, err := parseISO8601Duration(want, DurationStrict{}.isoDurationConfig()) + require.NoError(t, err) + assert.Equal(t, d, back) + }) + } + + // Lossless (lenient) emit for values strict cannot represent. + lenientCases := map[time.Duration]string{ + time.Second + 500*time.Millisecond: "PT1.5S", + -(time.Hour + 30*time.Minute): "-PT1H30M", + } + for d, want := range lenientCases { + t.Run("lenient/"+want, func(t *testing.T) { + assert.Equal(t, want, isoFormat(d)) + back, err := parseISO8601Duration(want, DurationLenient{}.isoDurationConfig()) + require.NoError(t, err) + assert.Equal(t, d, back) + }) + } +} + +func TestDurationISO8601_PolicyAwareEmit(t *testing.T) { + // A strict duration holding a value it cannot represent must fail to marshal, not emit output a strict parser would + // reject. + subSecond := DurationISO8601(1500 * time.Millisecond) + _, err := subSecond.MarshalText() + require.Error(t, err, "strict must not emit sub-second precision") + _, err = subSecond.MarshalJSON() + require.Error(t, err) + // String stays lossless for display. + assert.Equal(t, "PT1.5S", subSecond.String()) + + negative := DurationISO8601(-time.Hour) + _, err = negative.MarshalText() + require.Error(t, err, "strict must not emit a negative sign") + + // The lenient policy emits both losslessly. + lenient := ISODuration[DurationLenient](1500 * time.Millisecond) + b, err := lenient.MarshalText() + require.NoError(t, err) + assert.Equal(t, "PT1.5S", string(b)) +} + +func TestDurationISO8601_TypeMethods(t *testing.T) { + // UnmarshalText applies the type's policy. + var strict DurationISO8601 + require.NoError(t, strict.UnmarshalText([]byte("P1DT2H"))) + assert.Equal(t, 26*time.Hour, time.Duration(strict)) + + require.Error(t, strict.UnmarshalText([]byte("PT0.5S")), "strict type must reject fractions") + require.Error(t, strict.UnmarshalText([]byte("P1Y2D")), "strict type must reject gaps") + + var lenient ISODuration[DurationLenient] + require.NoError(t, lenient.UnmarshalText([]byte("PT0.5S"))) + assert.Equal(t, 500*time.Millisecond, time.Duration(lenient)) + + // JSON round-trip. + b, err := strict.MarshalJSON() + require.NoError(t, err) + assert.Equal(t, `"P1DT2H"`, string(b)) + + var back DurationISO8601 + require.NoError(t, back.UnmarshalJSON(b)) + assert.Equal(t, strict, back) +} diff --git a/example_duration_iso8601_test.go b/example_duration_iso8601_test.go new file mode 100644 index 0000000..c295f6b --- /dev/null +++ b/example_duration_iso8601_test.go @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package strfmt_test + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/go-openapi/strfmt" + "github.com/go-openapi/strfmt/conv" +) + +// The strict parser accepts the RFC 3339 Appendix A grammar (the JSON Schema "duration" format) +// and returns an ordinary [time.Duration]. Calendar units are collapsed to fixed lengths +// (year = 365 days, month = 30 days). +func ExampleParseISO8601Duration() { + d, err := strfmt.ParseISO8601Duration("P1DT12H") + if err != nil { + fmt.Println(err) + return + } + fmt.Println(d) + + // The strict grammar rejects fractions, signs and whitespace; every rejection + // wraps strfmt.ErrFormat. + _, err = strfmt.ParseISO8601Duration("PT0.5S") + fmt.Println(errors.Is(err, strfmt.ErrFormat)) + + // Output: + // 36h0m0s + // true +} + +// Options relax individual rules on the explicit ParseISO8601Duration path only — never the +// registry or struct-field decode path, which stays strict and unambiguous. +func ExampleParseISO8601Duration_options() { + // A decimal fraction on the least significant component is opt-in. + frac, _ := strfmt.ParseISO8601Duration("PT0.5S", strfmt.WithISOFractions()) + fmt.Println(frac) + + // A leading sign is opt-in. + neg, _ := strfmt.ParseISO8601Duration("-PT1H", strfmt.WithISOSign()) + fmt.Println(neg) + + // WithISOLenient turns on every relaxation at once (fraction, sign, whitespace, + // non-contiguous components, combinable "W"). Ordering is still enforced. + lenient, _ := strfmt.ParseISO8601Duration(" P1Y2D ", strfmt.WithISOLenient()) + fmt.Println(lenient) + + // Output: + // 500ms + // -1h0m0s + // 8808h0m0s +} + +// DurationISO8601 is the strict alias; it round-trips through JSON as a canonical ISO 8601 string. +func ExampleDurationISO8601_json() { + type Payload struct { + Timeout strfmt.DurationISO8601 `json:"timeout"` + } + + var p Payload + if err := json.Unmarshal([]byte(`{"timeout":"PT1H30M"}`), &p); err != nil { + fmt.Println(err) + return + } + fmt.Println(time.Duration(p.Timeout)) + + out, err := json.Marshal(p) + if err != nil { + fmt.Println(err) + return + } + fmt.Println(string(out)) + + // Output: + // 1h30m0s + // {"timeout":"PT1H30M"} +} + +// String is a lossless display form: it always renders the true value, even one a strict policy +// could not emit (a sign, or sub-second precision). +func ExampleDurationISO8601_String() { + fmt.Println(strfmt.DurationISO8601(90 * time.Minute).String()) + fmt.Println(strfmt.DurationISO8601(time.Second + 500*time.Millisecond).String()) + fmt.Println(strfmt.DurationISO8601(-(time.Hour + 30*time.Minute)).String()) + + // Output: + // PT1H30M + // PT1.5S + // -PT1H30M +} + +// Selecting the DurationLenient policy as the type parameter applies the relaxed grammar on the +// struct-decode path too — useful when consuming feeds that emit fractions or signs. +func ExampleISODuration_lenient() { + type Config struct { + Interval strfmt.ISODuration[strfmt.DurationLenient] `json:"interval"` + } + + var c Config + if err := json.Unmarshal([]byte(`{"interval":"PT0.25S"}`), &c); err != nil { + fmt.Println(err) + return + } + fmt.Println(time.Duration(c.Interval)) + + // Output: + // 250ms +} + +// The format is registered in the Default registry under the "duration-iso8601" handle, distinct +// from the human-readable "duration" format. +func ExampleDurationISO8601_registry() { + // A plain week duration is valid; strict "W" is exclusive, so combining it is rejected. + fmt.Println(strfmt.Default.Validates("duration-iso8601", "P2W")) + fmt.Println(strfmt.Default.Validates("duration-iso8601", "P2W3D")) // invalid + fmt.Println() + fmt.Println(strfmt.Default.Validates("duration", "2 weeks")) // human duration is the default for duration + fmt.Println(strfmt.Default.Validates("duration", "P2W")) // iso8601 is NOT the default for duration + fmt.Println() + fmt.Println(strfmt.JSONSchema2020Registry.Validates("duration", "P2W")) // iso8601 is the duration for [JSONSchema2020Registry] + fmt.Println(strfmt.JSONSchema2020Registry.Validates("duration", "2 weeks")) // human duration is NOT the default duration for JSONSchema 2020 + fmt.Println(strfmt.JSONSchema2020Registry.Validates("duration-human", "2 weeks")) // human duration remain supported as an explicit format + + // Output: + // true + // false + // + // true + // false + // + // true + // false + // true +} + +// The conv sub-package provides the usual value/pointer helpers for the strict alias. +func ExampleDurationISO8601Value() { + p := conv.DurationISO8601(strfmt.DurationISO8601(time.Hour)) + fmt.Println(time.Duration(conv.DurationISO8601Value(p))) + + // A nil pointer decodes to the zero duration. + fmt.Println(time.Duration(conv.DurationISO8601Value(nil))) + + // Output: + // 1h0m0s + // 0s +} diff --git a/format.go b/format.go index e494dd7..09c3754 100644 --- a/format.go +++ b/format.go @@ -10,15 +10,11 @@ import ( "slices" "strings" "sync" - "time" "github.com/go-openapi/errors" "github.com/go-viper/mapstructure/v2" ) -// Default is the default formats registry. -var Default = NewSeededFormats(nil, nil) //nolint:gochecknoglobals // package-level default registry, by design - // Validator represents a validator for a string format. type Validator func(string) bool @@ -48,11 +44,28 @@ type knownFormat struct { } // NameNormalizer is a function that normalizes a format name. +// +// The default duration format corresponds to "duration-human". type NameNormalizer func(string) string +var dashReplacer = strings.NewReplacer("-", "") //nolint:gochecknoglobals // it's okay to use a global private replacer + // DefaultNameNormalizer removes all dashes. func DefaultNameNormalizer(name string) string { - return strings.ReplaceAll(name, "-", "") + if name == "duration" { + name = "duration-human" + } + + return dashReplacer.Replace(name) +} + +// JSONSchema2020Normalizer is like [NameNormalizer] but adopts "duration-iso8601" as the default "duration" format. +func JSONSchema2020Normalizer(name string) string { + if name == "duration" { + name = "duration-iso8601" + } + + return dashReplacer.Replace(name) } type defaultFormats struct { @@ -63,6 +76,11 @@ type defaultFormats struct { } // MapStructureHookFunc is a decode hook function for mapstructure. +// +// A registered format is decoded by delegating to the destination type's own +// [encoding.TextUnmarshaler], so the mapstructure path is identical to the JSON +// and [defaultFormats.Parse] paths. New formats are picked up automatically as +// soon as they are registered — no per-type wiring here. func (f *defaultFormats) MapStructureHookFunc() mapstructure.DecodeHookFunc { //nolint:ireturn // returns interface required by mapstructure return func(from reflect.Type, to reflect.Type, obj any) (any, error) { if from.Kind() != reflect.String { @@ -74,85 +92,25 @@ func (f *defaultFormats) MapStructureHookFunc() mapstructure.DecodeHookFunc { // } for _, v := range f.data { - tpe, _ := f.GetType(v.Name) - if to == tpe { - return decodeFormatFromString(v.Name, data) + if to != v.Type { + continue } - } - return data, nil - } -} -// decodeFormatFromString decodes a string into the appropriate format type by name. -func decodeFormatFromString(name, data string) (any, error) { //nolint:gocyclo,cyclop // flat switch over format names, no real complexity - switch name { - case "date": - d, err := time.ParseInLocation(RFC3339FullDate, data, DefaultTimeLocation) - if err != nil { - return nil, err - } - return Date(d), nil - case "datetime": - if len(data) == 0 { - return nil, fmt.Errorf("empty string is an invalid datetime format: %w", ErrFormat) - } - return ParseDateTime(data) - case "duration": - dur, err := ParseDuration(data) - if err != nil { - return nil, err - } - return Duration(dur), nil - case "uri": - return URI(data), nil - case "email": - return Email(data), nil - case "uuid": - return UUID(data), nil - case "uuid3": - return UUID3(data), nil - case "uuid4": - return UUID4(data), nil - case "uuid5": - return UUID5(data), nil - case "uuid7": - return UUID7(data), nil - case "hostname": - return Hostname(data), nil - case "ipv4": - return IPv4(data), nil - case "ipv6": - return IPv6(data), nil - case "cidr": - return CIDR(data), nil - case "mac": - return MAC(data), nil - case "isbn": - return ISBN(data), nil - case "isbn10": - return ISBN10(data), nil - case "isbn13": - return ISBN13(data), nil - case "creditcard": - return CreditCard(data), nil - case "ssn": - return SSN(data), nil - case "hexcolor": - return HexColor(data), nil - case "rgbcolor": - return RGBColor(data), nil - case "byte": - return Base64(data), nil - case "password": - return Password(data), nil - case "ulid": - ulid, err := ParseULID(data) - if err != nil { - return nil, err + // reflect.New yields an addressable *T so the (pointer-receiver) + // TextUnmarshaler can hydrate it; we hand back the dereferenced + // value to match the destination field's (value) type. + nw := reflect.New(v.Type).Interface() + dec, isText := nw.(encoding.TextUnmarshaler) + if !isText { + return nil, errors.InvalidTypeName(v.Name) + } + if err := dec.UnmarshalText([]byte(data)); err != nil { + return nil, err + } + return reflect.ValueOf(nw).Elem().Interface(), nil } - return ulid, nil - default: - return nil, errors.InvalidTypeName(name) + + return data, nil } } diff --git a/format_test.go b/format_test.go index 13e9dd5..3d7f482 100644 --- a/format_test.go +++ b/format_test.go @@ -203,7 +203,7 @@ func TestDecodeHook(t *testing.T) { Ssn: SSN("111-11-1111"), Hexcolor: HexColor("#FFFFFF"), Rgbcolor: RGBColor("rgb(255,255,255)"), - B64: Base64("ZWxpemFiZXRocG9zZXk="), + B64: Base64("elizabethposey"), // decoded bytes: the hook now delegates to Base64.UnmarshalText Pw: Password("super secret stuff here"), ULID: ulid, } @@ -224,16 +224,21 @@ func TestDecodeHook(t *testing.T) { func TestDecodeDateTimeHook(t *testing.T) { testCases := []struct { - Name string - Input string + Name string + Input string + WantErr bool }{ { + // The hook delegates to DateTime.UnmarshalText, which mirrors + // ParseDateTime: an empty string decodes to the zero datetime. "empty datetime", "", + false, }, { "invalid non empty datetime", "2019-01-01abc", + true, }, } registry := NewFormats() @@ -254,7 +259,13 @@ func TestDecodeDateTimeHook(t *testing.T) { input := make(map[string]any) input["datetime"] = tc.Input err = d.Decode(input) - require.Error(t, err, "error expected got none") + if tc.WantErr { + require.Error(t, err, "error expected got none") + return + } + require.NoError(t, err) + require.NotNil(t, test.DateTime) + assert.True(t, test.DateTime.Equal(NewDateTime()), "empty datetime should decode to the zero value") }) } } diff --git a/fuzz_test.go b/fuzz_test.go index 74d64dc..61ee73f 100644 --- a/fuzz_test.go +++ b/fuzz_test.go @@ -87,7 +87,7 @@ func fuzzBuckets() map[string][]string { "uuids": {"uuid", "uuid3", "uuid4", "uuid5", "uuid7"}, "network": {"uri", "email", "hostname", "ipv4", "ipv6", "cidr", "mac"}, "numeric": {"isbn", "isbn10", "isbn13", "creditcard", "ssn"}, - "temporal": {"date", "datetime", "duration"}, + "temporal": {"date", "datetime", "durationiso8601", "durationhuman"}, "misc": {"bsonobjectid", "hexcolor", "rgbcolor", "password", "byte", "ulid"}, } } @@ -258,7 +258,7 @@ var fuzzFormatCorpus = map[string][]string{ "2014-12-15T08:00", // missing seconds/zone "2014-12-15", // date only }, - "duration": { + "durationhuman": { "1s", "300ms", "-1.5h", @@ -270,6 +270,30 @@ var fuzzFormatCorpus = map[string][]string{ "x", "1s1s1s 1s 1s ", }, + "durationiso8601": { + // strict-valid + "P1Y2M3DT4H5M6S", + "P2W", + "P1DT12H", + "PT0S", + "P4Y", + "PT1H0M5S", + // leniency the strict handle must reject + "PT0.5S", // fraction + "-P1D", // sign + " P1D", // surrounding space + "P1Y2D", // gap (non-contiguous) + "P1Y2W", // W combined with other components + "P२Y", // non-ASCII (Devanagari) digit + "P", // no components + "PT", // no components after T + "P1", // value without designator + // overflow boundaries (the bug the fraction path once had, and the uint64 / int64 ceilings) + "PT18446744073.9S", // fraction path near the 1<<64 gap + "PT18446744073709551616S", // > MaxUint64 + "PT9223372036854775807S", // ~ MaxInt64 seconds (overflows nanoseconds) + "P9999999999999999999Y", + }, "uri": { "http://foo.bar/baz?q=1#frag", "mailto:a@b.c", diff --git a/internal/testintegration/mariadb/mariadb_test.go b/internal/testintegration/mariadb/mariadb_test.go index 3817e71..0ef034a 100644 --- a/internal/testintegration/mariadb/mariadb_test.go +++ b/internal/testintegration/mariadb/mariadb_test.go @@ -196,6 +196,25 @@ func TestMariaDB_Duration(t *testing.T) { assert.EqualT(t, original, got) } +func TestMariaDB_DurationISO8601(t *testing.T) { + // DurationISO8601.Value() returns int64 (nanoseconds), so use BIGINT column. + // SQL is a storage boundary: sub-second precision — which the strict text/JSON path refuses to emit — must survive. + db := setupMariaDB(t) + ctx := context.Background() + table := createTable(t, db, "value BIGINT") + + original := strfmt.DurationISO8601(26*time.Hour + 1500*time.Millisecond) + + _, err := db.ExecContext(ctx, fmt.Sprintf("INSERT INTO %s (id, value) VALUES (?, ?)", table), "duriso1", original) + require.NoError(t, err) + + var got strfmt.DurationISO8601 + err = db.QueryRowContext(ctx, fmt.Sprintf("SELECT value FROM %s WHERE id = ?", table), "duriso1").Scan(&got) + require.NoError(t, err) + + assert.EqualT(t, original, got) +} + // stringRoundTrip is a helper for string-based strfmt types stored in VARCHAR columns. func stringRoundTrip[T interface { ~string diff --git a/internal/testintegration/mongotest/mongotest.go b/internal/testintegration/mongotest/mongotest.go index d02cb55..1bbc08e 100644 --- a/internal/testintegration/mongotest/mongotest.go +++ b/internal/testintegration/mongotest/mongotest.go @@ -142,6 +142,38 @@ func RunAllTests(t *testing.T) { assert.EqualT(t, original, got) }) + t.Run("DurationISO8601", func(t *testing.T) { + // BSON is a storage boundary: DurationISO8601 marshals losslessly and parses back leniently, so values a + // strict policy refuses to emit on the text/JSON path (sub-second precision, a negative sign) must still + // round-trip through MongoDB. + values := map[string]time.Duration{ + "whole": 42 * time.Second, + "sub_second": 26*time.Hour + 1500*time.Millisecond, + "negative": -(time.Hour + 30*time.Minute), + "zero": 0, + } + for name, v := range values { + t.Run(name, func(t *testing.T) { + coll := Setup(t) + original := strfmt.DurationISO8601(v) + + doc := bson.M{"_id": "duration_iso8601_test", "value": original} + result := roundTrip(t, coll, doc) + + raw, ok := result["value"].(bson.D) + require.TrueT(t, ok, "expected bson.D for value, got %T", result["value"]) + + rawBytes, err := bson.Marshal(raw) + require.NoError(t, err) + + var got strfmt.DurationISO8601 + require.NoError(t, bson.Unmarshal(rawBytes, &got)) + + assert.EqualT(t, original, got) + }) + } + }) + t.Run("Base64", func(t *testing.T) { coll := Setup(t) payload := []byte("hello world with special chars: éàü") diff --git a/internal/testintegration/postgresql/postgresql_test.go b/internal/testintegration/postgresql/postgresql_test.go index c5bd028..6aec247 100644 --- a/internal/testintegration/postgresql/postgresql_test.go +++ b/internal/testintegration/postgresql/postgresql_test.go @@ -145,6 +145,25 @@ func TestPostgreSQL_Duration(t *testing.T) { assert.EqualT(t, original, got) } +func TestPostgreSQL_DurationISO8601(t *testing.T) { + // DurationISO8601.Value() returns int64 (nanoseconds), so use BIGINT column. + // SQL is a storage boundary: sub-second precision — which the strict text/JSON path refuses to emit — must survive. + db := setupPostgreSQL(t) + ctx := context.Background() + table := createTable(t, db, "value BIGINT") + + original := strfmt.DurationISO8601(26*time.Hour + 1500*time.Millisecond) + + _, err := db.ExecContext(ctx, fmt.Sprintf("INSERT INTO %s (id, value) VALUES ($1, $2)", table), "duriso1", original) + require.NoError(t, err) + + var got strfmt.DurationISO8601 + err = db.QueryRowContext(ctx, fmt.Sprintf("SELECT value FROM %s WHERE id = $1", table), "duriso1").Scan(&got) + require.NoError(t, err) + + assert.EqualT(t, original, got) +} + // stringRoundTrip is a helper for string-based strfmt types stored in TEXT columns. func stringRoundTrip[T interface { ~string diff --git a/mongo.go b/mongo.go index b518298..69ac948 100644 --- a/mongo.go +++ b/mongo.go @@ -40,6 +40,8 @@ var ( _ bsonUnmarshaler = &Base64{} _ bsonMarshaler = Duration(0) _ bsonUnmarshaler = (*Duration)(nil) + _ bsonMarshaler = DurationISO8601(0) + _ bsonUnmarshaler = (*DurationISO8601)(nil) _ bsonMarshaler = DateTime{} _ bsonUnmarshaler = &DateTime{} _ bsonMarshaler = ULID{} @@ -170,6 +172,38 @@ func (d *Duration) UnmarshalBSON(data []byte) error { return nil } +// MarshalBSON renders the [ISODuration] as a BSON document. +// +// BSON is a storage boundary (like SQL): the value is emitted losslessly with [ISODuration.String], regardless of the +// policy P — a strict policy that could not serialize a sign or sub-second precision on the interchange path must still +// be persistable. +func (d ISODuration[P]) MarshalBSON() ([]byte, error) { + return bsonlite.C.MarshalDoc(d.String()) +} + +// UnmarshalBSON reads an [ISODuration] from a BSON document. +// +// The stored value is our own canonical output, so it is parsed leniently: BSON is trusted storage, not external +// interchange, and must round-trip whatever [ISODuration.MarshalBSON] emitted even under a strict policy P. +func (d *ISODuration[P]) UnmarshalBSON(data []byte) error { + v, err := bsonlite.C.UnmarshalDoc(data) + if err != nil { + return err + } + + s, ok := v.(string) + if !ok { + return fmt.Errorf("couldn't unmarshal bson bytes value as ISODuration: %w", ErrFormat) + } + + rd, err := parseISO8601Duration(s, DurationLenient{}.isoDurationConfig()) + if err != nil { + return err + } + *d = ISODuration[P](rd) + return nil +} + // MarshalBSON renders the [DateTime] as a BSON document. func (t DateTime) MarshalBSON() ([]byte, error) { tNorm := NormalizeTimeForMarshal(time.Time(t)) diff --git a/register.go b/register.go new file mode 100644 index 0000000..843ec71 --- /dev/null +++ b/register.go @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package strfmt + +// Default is the default formats registry. +// +// NOTE: format "duration" is wire by default onto "duration-human". +var Default Registry //nolint:gochecknoglobals // package-level default registry, by design + +// JSONSchema2020Registry is the format registry with JSONSchema draft 2020 formats (e.g. duration is an iso8601 duration). +var JSONSchema2020Registry Registry //nolint:gochecknoglobals // package-level default registry, by design + +func init() { //nolint:gochecknoinits // registers all default string formats in the registry + // register formats in the default registry: + // - byte + // - creditcard + // - email + // - hexcolor + // - hostname + // - ipv4 + // - ipv6 + // - cidr + // - isbn + // - isbn10 + // - isbn13 + // - mac + // - password + // - rgbcolor + // - ssn + // - uri + // - uuid + // - uuid3 + // - uuid4 + // - uuid5 + // - uuid7 + Default = NewSeededFormats(nil, nil) + + u := URI("") + Default.Add("uri", &u, isRequestURI) + + eml := Email("") + Default.Add("email", &eml, IsEmail) + + hn := Hostname("") + Default.Add("hostname", &hn, IsHostname) + + ip4 := IPv4("") + Default.Add("ipv4", &ip4, isIPv4) + + ip6 := IPv6("") + Default.Add("ipv6", &ip6, isIPv6) + + cidr := CIDR("") + Default.Add("cidr", &cidr, isCIDR) + + mac := MAC("") + Default.Add("mac", &mac, isMAC) + + uid := UUID("") + Default.Add("uuid", &uid, IsUUID) + + uid3 := UUID3("") + Default.Add("uuid3", &uid3, IsUUID3) + + uid4 := UUID4("") + Default.Add("uuid4", &uid4, IsUUID4) + + uid5 := UUID5("") + Default.Add("uuid5", &uid5, IsUUID5) + + uid7 := UUID7("") + Default.Add("uuid7", &uid7, IsUUID7) + + isbn := ISBN("") + Default.Add("isbn", &isbn, func(str string) bool { return isISBN10(str) || isISBN13(str) }) + + isbn10 := ISBN10("") + Default.Add("isbn10", &isbn10, isISBN10) + + isbn13 := ISBN13("") + Default.Add("isbn13", &isbn13, isISBN13) + + cc := CreditCard("") + Default.Add("creditcard", &cc, isCreditCard) + + ssn := SSN("") + Default.Add("ssn", &ssn, isSSN) + + hc := HexColor("") + Default.Add("hexcolor", &hc, isHexcolor) + + rc := RGBColor("") + Default.Add("rgbcolor", &rc, isRGBcolor) + + b64 := Base64([]byte(nil)) + Default.Add("byte", &b64, isBase64) + + pw := Password("") + Default.Add("password", &pw, func(_ string) bool { return true }) + + d := Date{} + Default.Add("date", &d, IsDate) + + dt := DateTime{} + Default.Add("datetime", &dt, IsDateTime) + + du := Duration(0) + Default.Add("duration", &du, IsDuration) + Default.Add("duration-human", &du, IsDuration) + + di := DurationISO8601(0) + Default.Add("duration-iso8601", &di, IsDurationISO8601) + + var id ObjectId + Default.Add("bsonobjectid", &id, IsBSONObjectID) + + ulid := ULID{} + Default.Add("ulid", &ulid, IsULID) + + def, ok := Default.(*defaultFormats) + if !ok { + panic("internal error: can't initialize") + } + + JSONSchema2020Registry = NewSeededFormats(def.data, JSONSchema2020Normalizer) +} diff --git a/testdata/jsonschema-suite/duration.json b/testdata/jsonschema-suite/duration.json new file mode 100644 index 0000000..53aea67 --- /dev/null +++ b/testdata/jsonschema-suite/duration.json @@ -0,0 +1,223 @@ +[ + { + "description": "validation of duration strings", + "comment": "RFC 3339 Appendix A defines the ABNF grammar for ISO-8601 durations used by JSON Schema format 'duration'. These tests enforce only the syntax defined by that grammar.", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "format": "duration" + }, + "tests": [ + { + "description": "all string formats ignore integers", + "data": 12, + "valid": true + }, + { + "description": "all string formats ignore floats", + "data": 13.7, + "valid": true + }, + { + "description": "all string formats ignore objects", + "data": {}, + "valid": true + }, + { + "description": "all string formats ignore arrays", + "data": [], + "valid": true + }, + { + "description": "all string formats ignore booleans", + "data": false, + "valid": true + }, + { + "description": "all string formats ignore nulls", + "data": null, + "valid": true + }, + { + "description": "a valid duration string", + "data": "P4DT12H30M5S", + "valid": true + }, + { + "description": "an invalid duration string", + "data": "PT1D", + "valid": false + }, + { + "description": "must start with P", + "data": "4DT12H30M5S", + "valid": false + }, + { + "description": "no elements present", + "data": "P", + "valid": false + }, + { + "description": "no time elements present", + "data": "P1YT", + "valid": false + }, + { + "description": "no date or time elements present", + "data": "PT", + "valid": false + }, + { + "description": "elements out of order", + "data": "P2D1Y", + "valid": false + }, + { + "description": "missing time separator", + "data": "P1D2H", + "valid": false + }, + { + "description": "time element in the date position", + "data": "P2S", + "valid": false + }, + { + "description": "four years duration", + "data": "P4Y", + "valid": true + }, + { + "description": "zero time, in seconds", + "data": "PT0S", + "valid": true + }, + { + "description": "zero time, in days", + "data": "P0D", + "valid": true + }, + { + "description": "one month duration", + "data": "P1M", + "valid": true + }, + { + "description": "one minute duration", + "data": "PT1M", + "valid": true + }, + { + "description": "one and a half days, in hours", + "data": "PT36H", + "valid": true + }, + { + "description": "one and a half days, in days and hours", + "data": "P1DT12H", + "valid": true + }, + { + "description": "two weeks", + "data": "P2W", + "valid": true + }, + { + "description": "weeks cannot be combined with other units", + "data": "P1Y2W", + "valid": false + }, + { + "description": "invalid non-ASCII '২' (a Bengali 2)", + "data": "P২Y", + "valid": false + }, + { + "description": "element without unit", + "data": "P1", + "valid": false + }, + { + "description": "all date and time components", + "data": "P1Y2M3DT4H5M6S", + "valid": true + }, + { + "description": "date components only", + "data": "P1Y2M3D", + "valid": true + }, + { + "description": "time components only", + "data": "PT1H2M3S", + "valid": true + }, + { + "description": "month and day", + "data": "P1M2D", + "valid": true + }, + { + "description": "hour and minute", + "data": "PT1H30M", + "valid": true + }, + { + "description": "multi-digit values in all components", + "data": "P10Y10M10DT10H10M10S", + "valid": true + }, + { + "description": "fractional duration is not allowed by RFC 3339 ABNF", + "comment": "numeric components use 1*DIGIT where DIGIT = %x30-39; '.' is not allowed", + "data": "PT0.5S", + "valid": false + }, + { + "description": "leading whitespace is invalid", + "data": " P1D", + "valid": false + }, + { + "description": "trailing whitespace is invalid", + "data": "P1D ", + "valid": false + }, + { + "description": "empty string is invalid", + "data": "", + "valid": false + }, + { + "description": "years and months can appear without days", + "data": "P1Y2M", + "valid": true + }, + { + "description": "years and days cannot appear without months", + "data": "P1Y2D", + "valid": false + }, + { + "description": "months and days can appear without years", + "data": "P1M2D", + "valid": true + }, + { + "description": "hours and minutes can appear without seconds", + "data": "PT1H2M", + "valid": true + }, + { + "description": "hours and seconds cannot appear without minutes", + "data": "PT1H2S", + "valid": false + }, + { + "description": "minutes and seconds can appear without hour", + "data": "PT1M2S", + "valid": true + } + ] + } +] diff --git a/time.go b/time.go index 1fde8c6..94e409e 100644 --- a/time.go +++ b/time.go @@ -17,11 +17,6 @@ import ( // Unix 0 for an EST timezone is not equivalent to a UTC timezone. var UnixZero = time.Unix(0, 0).UTC() //nolint:gochecknoglobals // package-level sentinel value for unix epoch -func init() { //nolint:gochecknoinits // registers datetime format in the default registry - dt := DateTime{} - Default.Add("datetime", &dt, IsDateTime) -} - // IsDateTime returns true when the string is a valid date-time. // // JSON datetime format consist of a date and a time separated by a "T", e.g. 2012-04-23T18:25:43.511Z. diff --git a/ulid.go b/ulid.go index f05d22c..38809ad 100644 --- a/ulid.go +++ b/ulid.go @@ -69,11 +69,6 @@ var ( ULIDValueOverrideFunc = ULIDValueDefaultFunc ) -func init() { //nolint:gochecknoinits // registers ulid format in the default registry - ulid := ULID{} - Default.Add("ulid", &ulid, IsULID) -} - // IsULID checks if provided string is [ULID] format // Be noticed that this function considers overflowed [ULID] as non-[ulid]. // For more details see https://github.com/[ulid]/spec