Skip to content

Commit bbc6de8

Browse files
authored
fix(bind): honor OpenAPI date, date-time and date-time-local format tags (#3145)
Swag and OpenAPI put format names such as `format:"date-time"` on `time.Time` fields. Echo treated those names as Go time layouts, so valid input failed to bind. - `date-time` uses the same RFC3339 decoding as an untagged `time.Time`. - `date-time-local` accepts `2006-01-02T15:04:05` with optional fractional seconds, rejects time-zone suffixes, and assigns UTC. - `date` accepts `2006-01-02` and assigns UTC. - Other `format` values are still Go layouts. Slice fields, JSON/XML decoding, and custom unmarshalers are unchanged. Fixes #3107 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 99c395d commit bbc6de8

2 files changed

Lines changed: 194 additions & 3 deletions

File tree

‎bind.go‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ type Binder interface {
2323
}
2424

2525
// DefaultBinder is the default implementation of the Binder interface.
26+
// For path, query, header, and form binding, time.Time and *time.Time fields (not slices)
27+
// may use a format tag: "date-time" uses the standard RFC3339 decoding, "date-time-local"
28+
// accepts "2006-01-02T15:04:05" with optional fractional seconds, "date" accepts
29+
// "2006-01-02", and other values specify Go time layouts. "date", "date-time-local", and
30+
// layouts without a timezone give UTC times. JSON and XML decoding do not use this tag.
31+
// HTML datetime-local inputs omit seconds by default, so bind them with a Go layout
32+
// such as "2006-01-02T15:04" instead of "date-time-local".
2633
type DefaultBinder struct{}
2734

2835
// BindUnmarshaler is the interface used to wrap the UnmarshalParam method.
@@ -430,10 +437,19 @@ func unmarshalInputToField(valueKind reflect.Kind, val string, field reflect.Val
430437
}
431438

432439
fieldIValue := field.Addr().Interface()
433-
// Handle time.Time with custom format tag
434-
if formatTag != "" {
440+
// date-time uses the same TextUnmarshaler as an untagged time.Time.
441+
if formatTag != "" && formatTag != "date-time" {
435442
if _, isTime := fieldIValue.(*time.Time); isTime {
436-
t, err := time.Parse(formatTag, val)
443+
// OpenAPI dates and local date-times have no timezone. time.Parse assigns UTC,
444+
// as it does for custom layouts without timezone information.
445+
layout := formatTag
446+
switch formatTag {
447+
case "date":
448+
layout = "2006-01-02"
449+
case "date-time-local":
450+
layout = "2006-01-02T15:04:05"
451+
}
452+
t, err := time.Parse(layout, val)
437453
if err != nil {
438454
return true, err
439455
}

‎bind_test.go‎

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1698,3 +1698,178 @@ func TestTimeFormatBinding(t *testing.T) {
16981698
})
16991699
}
17001700
}
1701+
1702+
func TestOpenAPIFormatTimeBinding(t *testing.T) {
1703+
type TestStruct struct {
1704+
DateTime time.Time `param:"date_time" query:"date_time" header:"date_time" form:"date_time" format:"date-time"`
1705+
DateTimePtr *time.Time `param:"date_time_ptr" query:"date_time_ptr" header:"date_time_ptr" form:"date_time_ptr" format:"date-time"`
1706+
DateTimeLocal time.Time `param:"date_time_local" query:"date_time_local" header:"date_time_local" form:"date_time_local" format:"date-time-local"`
1707+
DateTimeLocalPtr *time.Time `param:"date_time_local_ptr" query:"date_time_local_ptr" header:"date_time_local_ptr" form:"date_time_local_ptr" format:"date-time-local"`
1708+
Date time.Time `param:"full_date" query:"full_date" header:"full_date" form:"full_date" format:"date"`
1709+
DatePtr *time.Time `param:"full_date_ptr" query:"full_date_ptr" header:"full_date_ptr" form:"full_date_ptr" format:"date"`
1710+
}
1711+
1712+
utc := time.Date(2023, 12, 25, 14, 30, 45, 0, time.UTC)
1713+
testCases := []struct {
1714+
name, field, value string
1715+
want time.Time
1716+
wantError bool
1717+
}{
1718+
{name: "ok, date-time UTC", field: "date_time", value: "2023-12-25T14:30:45Z", want: utc},
1719+
{name: "ok, date-time offset and fraction", field: "date_time", value: "2023-12-25T14:30:45.123456789+02:00", want: time.Date(2023, 12, 25, 14, 30, 45, 123456789, time.FixedZone("", 2*60*60))},
1720+
{name: "nok, date-time requires timezone", field: "date_time", value: "2023-12-25T14:30:45", wantError: true},
1721+
{name: "nok, date-time invalid date", field: "date_time", value: "2023-02-30T14:30:45Z", wantError: true},
1722+
{name: "ok, local date-time", field: "date_time_local", value: "2023-12-25T14:30:45", want: utc},
1723+
{name: "ok, local date-time fraction", field: "date_time_local", value: "2023-12-25T14:30:45.123456789", want: utc.Add(123456789 * time.Nanosecond)},
1724+
{name: "nok, local date-time rejects UTC suffix", field: "date_time_local", value: "2023-12-25T14:30:45Z", wantError: true},
1725+
{name: "nok, local date-time rejects offset", field: "date_time_local", value: "2023-12-25T14:30:45+02:00", wantError: true},
1726+
{name: "nok, local date-time requires seconds", field: "date_time_local", value: "2023-12-25T14:30", wantError: true},
1727+
{name: "nok, local date-time invalid date", field: "date_time_local", value: "2023-02-30T14:30:45", wantError: true},
1728+
{name: "ok, date", field: "full_date", value: "2023-12-25", want: time.Date(2023, 12, 25, 0, 0, 0, 0, time.UTC)},
1729+
{name: "nok, date rejects time", field: "full_date", value: "2023-12-25T14:30:45Z", wantError: true},
1730+
{name: "nok, date invalid date", field: "full_date", value: "2023-02-30", wantError: true},
1731+
}
1732+
for _, source := range []string{"param", "query", "header", "form", "multipart"} {
1733+
for _, tc := range testCases {
1734+
for _, pointer := range []bool{false, true} {
1735+
t.Run(fmt.Sprintf("%s/%s/pointer=%t", source, tc.name, pointer), func(t *testing.T) {
1736+
field := tc.field
1737+
if pointer {
1738+
field += "_ptr"
1739+
}
1740+
req := httptest.NewRequest(http.MethodGet, "/", nil)
1741+
bind := BindQueryParams
1742+
switch source {
1743+
case "param":
1744+
bind = BindPathValues
1745+
case "query":
1746+
req.URL.RawQuery = url.Values{field: {tc.value}}.Encode()
1747+
case "header":
1748+
req.Header.Set(field, tc.value)
1749+
bind = BindHeaders
1750+
case "form":
1751+
req = httptest.NewRequest(http.MethodPost, "/", strings.NewReader(url.Values{field: {tc.value}}.Encode()))
1752+
req.Header.Set(HeaderContentType, MIMEApplicationForm)
1753+
bind = BindBody
1754+
case "multipart":
1755+
var body bytes.Buffer
1756+
writer := multipart.NewWriter(&body)
1757+
if !assert.NoError(t, writer.WriteField(field, tc.value)) || !assert.NoError(t, writer.Close()) {
1758+
return
1759+
}
1760+
req = httptest.NewRequest(http.MethodPost, "/", &body)
1761+
req.Header.Set(HeaderContentType, writer.FormDataContentType())
1762+
bind = BindBody
1763+
}
1764+
c := New().NewContext(req, httptest.NewRecorder())
1765+
c.SetPathValues(PathValues{{Name: field, Value: tc.value}})
1766+
var result TestStruct
1767+
err := bind(c, &result)
1768+
if tc.wantError {
1769+
assert.Error(t, err)
1770+
assert.Equal(t, http.StatusBadRequest, StatusCode(err))
1771+
return
1772+
}
1773+
if !assert.NoError(t, err) {
1774+
return
1775+
}
1776+
actual, actualPtr := result.DateTime, result.DateTimePtr
1777+
switch tc.field {
1778+
case "date_time_local":
1779+
actual, actualPtr = result.DateTimeLocal, result.DateTimeLocalPtr
1780+
case "full_date":
1781+
actual, actualPtr = result.Date, result.DatePtr
1782+
}
1783+
if pointer {
1784+
if !assert.NotNil(t, actualPtr) {
1785+
return
1786+
}
1787+
actual = *actualPtr
1788+
}
1789+
assert.True(t, tc.want.Equal(actual), "expected %v, got %v", tc.want, actual)
1790+
_, wantOffset := tc.want.Zone()
1791+
_, actualOffset := actual.Zone()
1792+
assert.Equal(t, wantOffset, actualOffset)
1793+
if tc.field != "date_time" {
1794+
assert.Same(t, time.UTC, actual.Location())
1795+
}
1796+
})
1797+
}
1798+
}
1799+
}
1800+
}
1801+
1802+
func TestOpenAPIFormatTimeBindingJSON(t *testing.T) {
1803+
for _, tc := range []struct {
1804+
name, value string
1805+
wantError bool
1806+
}{
1807+
{name: "ok, RFC3339 decoding ignores format tag", value: "2023-12-25T14:30:45Z"},
1808+
{name: "nok, local format tag does not enable timezone-free JSON", value: "2023-12-25T14:30:45", wantError: true},
1809+
} {
1810+
t.Run(tc.name, func(t *testing.T) {
1811+
var result struct {
1812+
Time *time.Time `json:"time" format:"date-time-local"`
1813+
}
1814+
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(fmt.Sprintf(`{"time":%q}`, tc.value)))
1815+
req.Header.Set(HeaderContentType, MIMEApplicationJSON)
1816+
err := New().NewContext(req, httptest.NewRecorder()).Bind(&result)
1817+
if tc.wantError {
1818+
assert.Error(t, err)
1819+
assert.Equal(t, http.StatusBadRequest, StatusCode(err))
1820+
return
1821+
}
1822+
if assert.NoError(t, err) && assert.NotNil(t, result.Time) {
1823+
assert.True(t, time.Date(2023, 12, 25, 14, 30, 45, 0, time.UTC).Equal(*result.Time))
1824+
}
1825+
})
1826+
}
1827+
}
1828+
1829+
func TestOpenAPIFormatTimeBindingCustomTypes(t *testing.T) {
1830+
var result struct {
1831+
Time Timestamp `query:"time" format:"date-time-local"`
1832+
Text string `query:"text" format:"date-time-local"`
1833+
}
1834+
value := "2023-12-25T14:30:45Z"
1835+
req := httptest.NewRequest(http.MethodGet, "/?"+url.Values{"time": {value}, "text": {value}}.Encode(), nil)
1836+
err := New().NewContext(req, httptest.NewRecorder()).Bind(&result)
1837+
if assert.NoError(t, err) {
1838+
assert.True(t, time.Date(2023, 12, 25, 14, 30, 45, 0, time.UTC).Equal(time.Time(result.Time)))
1839+
assert.Equal(t, value, result.Text)
1840+
}
1841+
}
1842+
1843+
func TestOpenAPIFormatTimeBindingEdgeCases(t *testing.T) {
1844+
type TestStruct struct {
1845+
DateTime time.Time `query:"date_time" format:"date-time"`
1846+
DateTimeLocal time.Time `query:"date_time_local" format:"date-time-local"`
1847+
Date time.Time `query:"full_date" format:"date"`
1848+
Slice []time.Time `query:"slice" format:"date-time-local"`
1849+
}
1850+
testCases := []struct {
1851+
name, query string
1852+
wantError bool
1853+
}{
1854+
{name: "nok, literal date-time name is not a value", query: "date_time=date-time", wantError: true},
1855+
{name: "nok, literal date-time-local name is not a value", query: "date_time_local=date-time-local", wantError: true},
1856+
{name: "nok, literal date name is not a value", query: "full_date=date", wantError: true},
1857+
{name: "ok, slices ignore the format tag", query: "slice=2023-12-25T14%3A30%3A45Z"},
1858+
{name: "nok, slices ignore the format tag", query: "slice=2023-12-25T14%3A30%3A45", wantError: true},
1859+
}
1860+
for _, tc := range testCases {
1861+
t.Run(tc.name, func(t *testing.T) {
1862+
req := httptest.NewRequest(http.MethodGet, "/?"+tc.query, nil)
1863+
c := New().NewContext(req, httptest.NewRecorder())
1864+
var result TestStruct
1865+
err := BindQueryParams(c, &result)
1866+
if tc.wantError {
1867+
assert.Error(t, err)
1868+
return
1869+
}
1870+
if assert.NoError(t, err) && assert.Len(t, result.Slice, 1) {
1871+
assert.True(t, time.Date(2023, 12, 25, 14, 30, 45, 0, time.UTC).Equal(result.Slice[0]))
1872+
}
1873+
})
1874+
}
1875+
}

0 commit comments

Comments
 (0)