Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
.env
.mcp.json
go.work.sum
.worktrees
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo

Suggested change
and the new "duration-iso8601". There is no "dual" parser that accepts both formats: types are specialzed.
and the new "duration-iso8601". There is no "dual" parser that accepts both formats: types are specialized.


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()`.
Expand All @@ -104,6 +119,7 @@ List of defined types:
- Date
- DateTime
- Duration
- DurationISO8601 and `ISODuration[P ISODurationPolicy]` (for optional behavior)
- Email
- HexColor
- Hostname
Expand Down
5 changes: 0 additions & 5 deletions bson.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions conv/duration_iso8601.go
Original file line number Diff line number Diff line change
@@ -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
}
19 changes: 19 additions & 0 deletions conv/duration_iso8601_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
5 changes: 0 additions & 5 deletions date.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
87 changes: 0 additions & 87 deletions default.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions default_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
5 changes: 0 additions & 5 deletions duration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
114 changes: 114 additions & 0 deletions duration_compliance_test.go
Original file line number Diff line number Diff line change
@@ -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},
})
}
Loading
Loading