diff --git a/binding/json.go b/binding/json.go index f4ae921a..c4a2df28 100644 --- a/binding/json.go +++ b/binding/json.go @@ -6,11 +6,15 @@ package binding import ( "bytes" + "encoding/json" "errors" "io" "net/http" + "reflect" + "strconv" + "time" - "github.com/gin-gonic/gin/codec/json" + ginjson "github.com/gin-gonic/gin/codec/json" ) // EnableDecoderUseNumber is used to call the UseNumber method on the JSON @@ -41,8 +45,62 @@ func (jsonBinding) BindBody(body []byte, obj any) error { return decodeJSON(bytes.NewReader(body), obj) } +// jsonSource implements setter for JSON binding, allowing time_format +// and time_utc tags to work with JSON binding (ShouldBindJSON). +// For time.Time fields with time_format tags, it uses setTimeField +// to parse the string value. For all other fields, it uses standard +// JSON unmarshaling. +type jsonSource map[string]json.RawMessage + +var _ setter = jsonSource(nil) + +// timeTimeType is the reflect.Type for time.Time, used for comparison. +var timeTimeType = reflect.TypeOf(time.Time{}) + +// TrySet tries to set a value from a JSON source. +func (j jsonSource) TrySet(value reflect.Value, field reflect.StructField, key string, opt setOptions) (isSet bool, err error) { + raw, ok := j[key] + if !ok { + return false, nil + } + + // For time.Time fields with a time_format tag, use setTimeField + // to parse the string value using the custom format. + if value.Type() == timeTimeType && field.Tag.Get("time_format") != "" { + // Unmarshal the raw JSON value as a string + var s string + if err := ginjson.API.Unmarshal(raw, &s); err != nil { + // If the raw value is a number (unix timestamp), try that too + var n int64 + if err2 := ginjson.API.Unmarshal(raw, &n); err2 != nil { + return false, err + } + // Convert number to string for setTimeField + s = strconv.FormatInt(n, 10) + } + return true, setTimeField(s, field, value) + } + + // For all other types, use standard JSON unmarshaling. + if err := ginjson.API.Unmarshal(raw, value.Addr().Interface()); err != nil { + return false, err + } + return true, nil +} + func decodeJSON(r io.Reader, obj any) error { - decoder := json.API.NewDecoder(r) + // Read the full body first so we can retry if needed + body, err := io.ReadAll(r) + if err != nil { + return err + } + if len(body) == 0 { + return errors.New("empty JSON body") + } + + // Fast path: try the original decoder approach first (preserves UseNumber, + // DisallowUnknownFields, and works with any JSON codec backend). + decoder := ginjson.API.NewDecoder(bytes.NewReader(body)) if EnableDecoderUseNumber { decoder.UseNumber() } @@ -50,7 +108,63 @@ func decodeJSON(r io.Reader, obj any) error { decoder.DisallowUnknownFields() } if err := decoder.Decode(obj); err != nil { + // If the struct has time_format tags, the decode may have failed because + // a time.Time field used a non-RFC3339 format. Fall back to the jsonSource + // approach which respects the time_format tag. + if hasTimeFormatTag(obj) { + if err2 := decodeJSONWithTimeFormat(body, obj); err2 != nil { + return err // return the original decode error, which is more descriptive + } + return validate(obj) + } return err } + return validate(obj) } + +// decodeJSONWithTimeFormat decodes JSON using mappingByPtr with a jsonSource, +// which respects time_format tags on time.Time fields. +func decodeJSONWithTimeFormat(body []byte, obj any) error { + var rawMap map[string]json.RawMessage + if err := ginjson.API.Unmarshal(body, &rawMap); err != nil { + return err + } + + if err := mappingByPtr(obj, jsonSource(rawMap), "json"); err != nil { + return err + } + + return nil +} + +// hasTimeFormatTag checks if the given struct (or any nested struct) has +// any time.Time fields with a time_format tag. +func hasTimeFormatTag(obj any) bool { + v := reflect.ValueOf(obj) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return false + } + return hasTimeFormatTagRecursive(v.Type()) +} + +func hasTimeFormatTagRecursive(t reflect.Type) bool { + for i := range t.NumField() { + f := t.Field(i) + if f.Type.Kind() == reflect.Struct { + if f.Type == timeTimeType { + if f.Tag.Get("time_format") != "" { + return true + } + } else if f.Anonymous { + if hasTimeFormatTagRecursive(f.Type) { + return true + } + } + } + } + return false +} \ No newline at end of file diff --git a/binding/json_test.go b/binding/json_test.go index 942ee3eb..6ccf8089 100644 --- a/binding/json_test.go +++ b/binding/json_test.go @@ -37,6 +37,59 @@ func TestJSONBindingBindBodyMap(t *testing.T) { assert.Equal(t, "world", s["hello"]) } +func TestJSONBindingWithTimeFormat(t *testing.T) { + // Test that time_format tag works with JSON binding (issue #2170) + type TimeFormatStruct struct { + Timestamp time.Time `json:"Timestamp" time_format:"2006-01-02 15:04:05"` + UnixTime time.Time `json:"UnixTime" time_format:"unix"` + UnixNano time.Time `json:"UnixNano" time_format:"unixNano"` + } + + var s TimeFormatStruct + err := jsonBinding{}.BindBody([]byte(`{"Timestamp": "2001-11-11 11:11:11", "UnixTime": 1575528300, "UnixNano": 1575528300000000000}`), &s) + require.NoError(t, err) + assert.Equal(t, time.Date(2001, 11, 11, 11, 11, 11, 0, time.Local), s.Timestamp) + assert.Equal(t, time.Unix(1575528300, 0), s.UnixTime) + assert.Equal(t, time.Unix(0, 1575528300000000000), s.UnixNano) +} + +func TestJSONBindingWithTimeFormatAndUTC(t *testing.T) { + // Test that time_format works with time_utc tag in JSON binding + type TimeUTCStruct struct { + LocalTime time.Time `json:"local_time" time_format:"2006-01-02" time_utc:"1"` + } + + var s TimeUTCStruct + err := jsonBinding{}.BindBody([]byte(`{"local_time": "2001-11-11"}`), &s) + require.NoError(t, err) + assert.Equal(t, time.Date(2001, 11, 11, 0, 0, 0, 0, time.UTC), s.LocalTime) +} + +func TestJSONBindingWithTimeFormatAndLocation(t *testing.T) { + // Test that time_format works with time_location tag in JSON binding + type TimeLocationStruct struct { + LocalTime time.Time `json:"local_time" time_format:"2006-01-02" time_location:"Asia/Shanghai"` + } + + var s TimeLocationStruct + err := jsonBinding{}.BindBody([]byte(`{"local_time": "2001-11-11"}`), &s) + require.NoError(t, err) + loc, _ := time.LoadLocation("Asia/Shanghai") + assert.Equal(t, time.Date(2001, 11, 11, 0, 0, 0, 0, loc), s.LocalTime) +} + +func TestJSONBindingWithoutTimeFormat(t *testing.T) { + // Test that RFC3339 format still works (no time_format tag) + type NoTimeFormatStruct struct { + Timestamp time.Time `json:"timestamp"` + } + + var s NoTimeFormatStruct + err := jsonBinding{}.BindBody([]byte(`{"timestamp": "2001-11-11T11:11:11Z"}`), &s) + require.NoError(t, err) + assert.Equal(t, time.Date(2001, 11, 11, 11, 11, 11, 0, time.UTC), s.Timestamp) +} + func TestCustomJsonCodec(t *testing.T) { // Restore json encoding configuration after testing oldMarshal := json.API