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