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
22 changes: 19 additions & 3 deletions bind.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ type Binder interface {
}

// DefaultBinder is the default implementation of the Binder interface.
// For path, query, header, and form binding, time.Time and *time.Time fields (not slices)
// 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, "date" accepts
// "2006-01-02", and other values specify Go time layouts. "date", "date-time-local", and
// layouts without a timezone give UTC times. JSON and XML decoding do not use this tag.
// HTML datetime-local inputs omit seconds by default, so bind them with a Go layout
// such as "2006-01-02T15:04" instead of "date-time-local".
type DefaultBinder struct{}

// BindUnmarshaler is the interface used to wrap the UnmarshalParam method.
Expand Down Expand Up @@ -430,10 +437,19 @@ 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)
// OpenAPI dates and local date-times have no timezone. time.Parse assigns UTC,
// as it does for custom layouts without timezone information.
layout := formatTag
switch formatTag {
case "date":
layout = "2006-01-02"
case "date-time-local":
layout = "2006-01-02T15:04:05"
}
t, err := time.Parse(layout, val)
if err != nil {
return true, err
}
Expand Down
175 changes: 175 additions & 0 deletions bind_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1698,3 +1698,178 @@ 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"`
Date time.Time `param:"full_date" query:"full_date" header:"full_date" form:"full_date" format:"date"`
DatePtr *time.Time `param:"full_date_ptr" query:"full_date_ptr" header:"full_date_ptr" form:"full_date_ptr" format:"date"`
}

utc := time.Date(2023, 12, 25, 14, 30, 45, 0, time.UTC)
testCases := []struct {
name, field, value string
want time.Time
wantError bool
}{
{name: "ok, date-time UTC", field: "date_time", value: "2023-12-25T14:30:45Z", want: utc},
{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))},
{name: "nok, date-time requires timezone", field: "date_time", value: "2023-12-25T14:30:45", wantError: true},
{name: "nok, date-time invalid date", field: "date_time", value: "2023-02-30T14:30:45Z", wantError: true},
{name: "ok, local date-time", field: "date_time_local", value: "2023-12-25T14:30:45", want: utc},
{name: "ok, local date-time fraction", field: "date_time_local", value: "2023-12-25T14:30:45.123456789", want: utc.Add(123456789 * time.Nanosecond)},
{name: "nok, local date-time rejects UTC suffix", field: "date_time_local", value: "2023-12-25T14:30:45Z", wantError: true},
{name: "nok, local date-time rejects offset", field: "date_time_local", value: "2023-12-25T14:30:45+02:00", wantError: true},
{name: "nok, local date-time requires seconds", field: "date_time_local", value: "2023-12-25T14:30", wantError: true},
{name: "nok, local date-time invalid date", field: "date_time_local", value: "2023-02-30T14:30:45", wantError: true},
{name: "ok, date", field: "full_date", value: "2023-12-25", want: time.Date(2023, 12, 25, 0, 0, 0, 0, time.UTC)},
{name: "nok, date rejects time", field: "full_date", value: "2023-12-25T14:30:45Z", wantError: true},
{name: "nok, date invalid date", field: "full_date", value: "2023-02-30", 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
switch tc.field {
case "date_time_local":
actual, actualPtr = result.DateTimeLocal, result.DateTimeLocalPtr
case "full_date":
actual, actualPtr = result.Date, result.DatePtr
}
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" {
assert.Same(t, time.UTC, actual.Location())
}
})
}
}
}
}

func TestOpenAPIFormatTimeBindingJSON(t *testing.T) {
for _, tc := range []struct {
name, value string
wantError bool
}{
{name: "ok, RFC3339 decoding ignores format tag", value: "2023-12-25T14:30:45Z"},
{name: "nok, 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)
}
}

func TestOpenAPIFormatTimeBindingEdgeCases(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"`
Date time.Time `query:"full_date" format:"date"`
Slice []time.Time `query:"slice" format:"date-time-local"`
}
testCases := []struct {
name, query string
wantError bool
}{
{name: "nok, literal date-time name is not a value", query: "date_time=date-time", wantError: true},
{name: "nok, literal date-time-local name is not a value", query: "date_time_local=date-time-local", wantError: true},
{name: "nok, literal date name is not a value", query: "full_date=date", wantError: true},
{name: "ok, slices ignore the format tag", query: "slice=2023-12-25T14%3A30%3A45Z"},
{name: "nok, slices ignore the format tag", query: "slice=2023-12-25T14%3A30%3A45", wantError: true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/?"+tc.query, nil)
c := New().NewContext(req, httptest.NewRecorder())
var result TestStruct
err := BindQueryParams(c, &result)
if tc.wantError {
assert.Error(t, err)
return
}
if assert.NoError(t, err) && assert.Len(t, result.Slice, 1) {
assert.True(t, time.Date(2023, 12, 25, 14, 30, 45, 0, time.UTC).Equal(result.Slice[0]))
}
})
}
}
Loading