From c08f2f20354246f9f562127c8b7b878a4a991eca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:00:14 +0000 Subject: [PATCH 1/3] Initial plan From a3d6b48e523d553cc305fd385190b2c30238875b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:02:32 +0000 Subject: [PATCH 2/3] Support OpenAPI time format tags Co-authored-by: vishr <314036+vishr@users.noreply.github.com> --- bind.go | 7 ++++++- bind_test.go | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/bind.go b/bind.go index 13b6172a9..e2139d3a4 100644 --- a/bind.go +++ b/bind.go @@ -433,7 +433,12 @@ func unmarshalInputToField(valueKind reflect.Kind, val string, field reflect.Val // Handle time.Time with custom format tag if formatTag != "" { if _, isTime := fieldIValue.(*time.Time); isTime { - t, err := time.Parse(formatTag, val) + layout := formatTag + switch formatTag { + case "date-time", "date-time-local": + layout = time.RFC3339 + } + t, err := time.Parse(layout, val) if err != nil { return true, err } diff --git a/bind_test.go b/bind_test.go index 6a40985e9..9cce49c84 100644 --- a/bind_test.go +++ b/bind_test.go @@ -1698,3 +1698,26 @@ func TestTimeFormatBinding(t *testing.T) { }) } } + +func TestOpenAPIFormatTimeBinding(t *testing.T) { + type TestStruct struct { + DateTime time.Time `query:"date_time" format:"date-time"` + DateTimeLocal *time.Time `query:"date_time_local" format:"date-time-local"` + } + + e := New() + req := httptest.NewRequest(http.MethodGet, "/?date_time=2023-12-25T14:30:00Z&date_time_local=2023-12-25T14:30:00Z", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + var result TestStruct + err := c.Bind(&result) + + expected := time.Date(2023, 12, 25, 14, 30, 0, 0, time.UTC) + if assert.NoError(t, err) { + assert.True(t, expected.Equal(result.DateTime)) + if assert.NotNil(t, result.DateTimeLocal) { + assert.True(t, expected.Equal(*result.DateTimeLocal)) + } + } +} From bba9ea172a67bf32e956a67e862544a44f287107 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Fri, 2 Oct 2026 11:48:33 -0700 Subject: [PATCH 3/3] fix(bind): distinguish OpenAPI local date-time parsing --- bind.go | 15 ++++-- bind_test.go | 136 ++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 133 insertions(+), 18 deletions(-) diff --git a/bind.go b/bind.go index e2139d3a4..d3fb46730 100644 --- a/bind.go +++ b/bind.go @@ -23,6 +23,10 @@ type Binder interface { } // DefaultBinder is the default implementation of the Binder interface. +// For path, query, header, and form binding, time.Time fields may use a format tag: +// "date-time" uses the standard RFC3339 decoding, "date-time-local" accepts +// "2006-01-02T15:04:05" with optional fractional seconds and assigns UTC, and other +// values specify Go time layouts. JSON and XML decoding do not use this tag. type DefaultBinder struct{} // BindUnmarshaler is the interface used to wrap the UnmarshalParam method. @@ -430,13 +434,14 @@ func unmarshalInputToField(valueKind reflect.Kind, val string, field reflect.Val } fieldIValue := field.Addr().Interface() - // Handle time.Time with custom format tag - if formatTag != "" { + // date-time uses the same TextUnmarshaler as an untagged time.Time. + if formatTag != "" && formatTag != "date-time" { if _, isTime := fieldIValue.(*time.Time); isTime { layout := formatTag - switch formatTag { - case "date-time", "date-time-local": - layout = time.RFC3339 + if formatTag == "date-time-local" { + // OpenAPI local date-times have no timezone. time.Parse assigns UTC, + // as it does for custom layouts without timezone information. + layout = "2006-01-02T15:04:05" } t, err := time.Parse(layout, val) if err != nil { diff --git a/bind_test.go b/bind_test.go index 9cce49c84..8a9c2c50e 100644 --- a/bind_test.go +++ b/bind_test.go @@ -1701,23 +1701,133 @@ func TestTimeFormatBinding(t *testing.T) { func TestOpenAPIFormatTimeBinding(t *testing.T) { type TestStruct struct { - DateTime time.Time `query:"date_time" format:"date-time"` - DateTimeLocal *time.Time `query:"date_time_local" format:"date-time-local"` + DateTime time.Time `param:"date_time" query:"date_time" header:"date_time" form:"date_time" format:"date-time"` + DateTimePtr *time.Time `param:"date_time_ptr" query:"date_time_ptr" header:"date_time_ptr" form:"date_time_ptr" format:"date-time"` + DateTimeLocal time.Time `param:"date_time_local" query:"date_time_local" header:"date_time_local" form:"date_time_local" format:"date-time-local"` + 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"` } - e := New() - req := httptest.NewRequest(http.MethodGet, "/?date_time=2023-12-25T14:30:00Z&date_time_local=2023-12-25T14:30:00Z", nil) - rec := httptest.NewRecorder() - c := e.NewContext(req, rec) + utc := time.Date(2023, 12, 25, 14, 30, 45, 0, time.UTC) + testCases := []struct { + name, field, value string + want time.Time + wantError bool + }{ + {name: "date-time UTC", field: "date_time", value: "2023-12-25T14:30:45Z", want: utc}, + {name: "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))}, + {name: "date-time requires timezone", field: "date_time", value: "2023-12-25T14:30:45", wantError: true}, + {name: "date-time invalid date", field: "date_time", value: "2023-02-30T14:30:45Z", wantError: true}, + {name: "local date-time", field: "date_time_local", value: "2023-12-25T14:30:45", want: utc}, + {name: "local date-time fraction", field: "date_time_local", value: "2023-12-25T14:30:45.123456789", want: utc.Add(123456789 * time.Nanosecond)}, + {name: "local date-time rejects UTC suffix", field: "date_time_local", value: "2023-12-25T14:30:45Z", wantError: true}, + {name: "local date-time rejects offset", field: "date_time_local", value: "2023-12-25T14:30:45+02:00", wantError: true}, + {name: "local date-time requires seconds", field: "date_time_local", value: "2023-12-25T14:30", wantError: true}, + {name: "local date-time invalid date", field: "date_time_local", value: "2023-02-30T14:30:45", wantError: true}, + } + for _, source := range []string{"param", "query", "header", "form", "multipart"} { + for _, tc := range testCases { + for _, pointer := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%s/pointer=%t", source, tc.name, pointer), func(t *testing.T) { + field := tc.field + if pointer { + field += "_ptr" + } + req := httptest.NewRequest(http.MethodGet, "/", nil) + bind := BindQueryParams + switch source { + case "param": + bind = BindPathValues + case "query": + req.URL.RawQuery = url.Values{field: {tc.value}}.Encode() + case "header": + req.Header.Set(field, tc.value) + bind = BindHeaders + case "form": + req = httptest.NewRequest(http.MethodPost, "/", strings.NewReader(url.Values{field: {tc.value}}.Encode())) + req.Header.Set(HeaderContentType, MIMEApplicationForm) + bind = BindBody + case "multipart": + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if !assert.NoError(t, writer.WriteField(field, tc.value)) || !assert.NoError(t, writer.Close()) { + return + } + req = httptest.NewRequest(http.MethodPost, "/", &body) + req.Header.Set(HeaderContentType, writer.FormDataContentType()) + bind = BindBody + } + c := New().NewContext(req, httptest.NewRecorder()) + c.SetPathValues(PathValues{{Name: field, Value: tc.value}}) + var result TestStruct + err := bind(c, &result) + if tc.wantError { + assert.Error(t, err) + assert.Equal(t, http.StatusBadRequest, StatusCode(err)) + return + } + if !assert.NoError(t, err) { + return + } + actual, actualPtr := result.DateTime, result.DateTimePtr + if tc.field == "date_time_local" { + actual, actualPtr = result.DateTimeLocal, result.DateTimeLocalPtr + } + if pointer { + if !assert.NotNil(t, actualPtr) { + return + } + actual = *actualPtr + } + assert.True(t, tc.want.Equal(actual), "expected %v, got %v", tc.want, actual) + _, wantOffset := tc.want.Zone() + _, actualOffset := actual.Zone() + assert.Equal(t, wantOffset, actualOffset) + if tc.field == "date_time_local" { + assert.Same(t, time.UTC, actual.Location()) + } + }) + } + } + } +} - var result TestStruct - err := c.Bind(&result) +func TestOpenAPIFormatTimeBindingJSON(t *testing.T) { + for _, tc := range []struct { + name, value string + wantError bool + }{ + {name: "RFC3339 decoding ignores format tag", value: "2023-12-25T14:30:45Z"}, + {name: "local format tag does not enable timezone-free JSON", value: "2023-12-25T14:30:45", wantError: true}, + } { + t.Run(tc.name, func(t *testing.T) { + var result struct { + Time *time.Time `json:"time" format:"date-time-local"` + } + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(fmt.Sprintf(`{"time":%q}`, tc.value))) + req.Header.Set(HeaderContentType, MIMEApplicationJSON) + err := New().NewContext(req, httptest.NewRecorder()).Bind(&result) + if tc.wantError { + assert.Error(t, err) + assert.Equal(t, http.StatusBadRequest, StatusCode(err)) + return + } + if assert.NoError(t, err) && assert.NotNil(t, result.Time) { + assert.True(t, time.Date(2023, 12, 25, 14, 30, 45, 0, time.UTC).Equal(*result.Time)) + } + }) + } +} - expected := time.Date(2023, 12, 25, 14, 30, 0, 0, time.UTC) +func TestOpenAPIFormatTimeBindingCustomTypes(t *testing.T) { + var result struct { + Time Timestamp `query:"time" format:"date-time-local"` + Text string `query:"text" format:"date-time-local"` + } + value := "2023-12-25T14:30:45Z" + req := httptest.NewRequest(http.MethodGet, "/?"+url.Values{"time": {value}, "text": {value}}.Encode(), nil) + err := New().NewContext(req, httptest.NewRecorder()).Bind(&result) if assert.NoError(t, err) { - assert.True(t, expected.Equal(result.DateTime)) - if assert.NotNil(t, result.DateTimeLocal) { - assert.True(t, expected.Equal(*result.DateTimeLocal)) - } + assert.True(t, time.Date(2023, 12, 25, 14, 30, 45, 0, time.UTC).Equal(time.Time(result.Time))) + assert.Equal(t, value, result.Text) } }