diff --git a/bind.go b/bind.go index 13b6172a9..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,10 +434,16 @@ 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 { - t, err := time.Parse(formatTag, val) + layout := formatTag + 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 { return true, err } diff --git a/bind_test.go b/bind_test.go index 6a40985e9..8a9c2c50e 100644 --- a/bind_test.go +++ b/bind_test.go @@ -1698,3 +1698,136 @@ func TestTimeFormatBinding(t *testing.T) { }) } } + +func TestOpenAPIFormatTimeBinding(t *testing.T) { + type TestStruct struct { + 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"` + } + + 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()) + } + }) + } + } + } +} + +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)) + } + }) + } +} + +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, time.Date(2023, 12, 25, 14, 30, 45, 0, time.UTC).Equal(time.Time(result.Time))) + assert.Equal(t, value, result.Text) + } +}