gin/binding/json_test.go
waterWang 04a7275c71 fix: support time_format tag in JSON binding (ShouldBindJSON)
The time_format struct tag works for form/query binding but is completely
ignored when binding via JSON (ShouldBindJSON). This commit adds support
for time_format, time_utc, and time_location tags in the JSON binding path.

When a struct has time.Time fields with time_format tags, the JSON decoder
falls back to mappingByPtr with a jsonSource setter that uses setTimeField
to parse the string value with the custom format. For structs without
time_format tags, the original fast path (direct JSON unmarshal) is used,
preserving full backward compatibility.

Fixes #2170
2026-07-29 20:14:35 +08:00

270 lines
7.5 KiB
Go

// Copyright 2019 Gin Core Team. All rights reserved.
// Use of this source code is governed by a MIT style
// license that can be found in the LICENSE file.
package binding
import (
"io"
"net/http/httptest"
"testing"
"time"
"unsafe"
"github.com/gin-gonic/gin/codec/json"
"github.com/gin-gonic/gin/render"
jsoniter "github.com/json-iterator/go"
"github.com/modern-go/reflect2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestJSONBindingBindBody(t *testing.T) {
var s struct {
Foo string `json:"foo"`
}
err := jsonBinding{}.BindBody([]byte(`{"foo": "FOO"}`), &s)
require.NoError(t, err)
assert.Equal(t, "FOO", s.Foo)
}
func TestJSONBindingBindBodyMap(t *testing.T) {
s := make(map[string]string)
err := jsonBinding{}.BindBody([]byte(`{"foo": "FOO","hello":"world"}`), &s)
require.NoError(t, err)
assert.Len(t, s, 2)
assert.Equal(t, "FOO", s["foo"])
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
defer func() {
json.API = oldMarshal
}()
// Custom json api
json.API = customJsonApi{}
// test decode json
obj := customReq{}
err := jsonBinding{}.BindBody([]byte(`{"time_empty":null,"time_struct": "2001-12-05 10:01:02.345","time_nil":null,"time_pointer":"2002-12-05 10:01:02.345"}`), &obj)
require.NoError(t, err)
assert.Equal(t, zeroTime, obj.TimeEmpty)
assert.Equal(t, time.Date(2001, 12, 5, 10, 1, 2, 345000000, time.Local), obj.TimeStruct)
assert.Nil(t, obj.TimeNil)
assert.Equal(t, time.Date(2002, 12, 5, 10, 1, 2, 345000000, time.Local), *obj.TimePointer)
// test encode json
w := httptest.NewRecorder()
err2 := (render.PureJSON{Data: obj}).Render(w)
require.NoError(t, err2)
assert.JSONEq(t, "{\"time_empty\":null,\"time_struct\":\"2001-12-05 10:01:02.345\",\"time_nil\":null,\"time_pointer\":\"2002-12-05 10:01:02.345\"}\n", w.Body.String())
assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type"))
}
type customReq struct {
TimeEmpty time.Time `json:"time_empty"`
TimeStruct time.Time `json:"time_struct"`
TimeNil *time.Time `json:"time_nil"`
TimePointer *time.Time `json:"time_pointer"`
}
var customConfig = jsoniter.Config{
EscapeHTML: true,
SortMapKeys: true,
ValidateJsonRawMessage: true,
}.Froze()
func init() {
customConfig.RegisterExtension(&TimeEx{})
customConfig.RegisterExtension(&TimePointerEx{})
}
type customJsonApi struct{}
func (j customJsonApi) Marshal(v any) ([]byte, error) {
return customConfig.Marshal(v)
}
func (j customJsonApi) Unmarshal(data []byte, v any) error {
return customConfig.Unmarshal(data, v)
}
func (j customJsonApi) MarshalIndent(v any, prefix, indent string) ([]byte, error) {
return customConfig.MarshalIndent(v, prefix, indent)
}
func (j customJsonApi) NewEncoder(writer io.Writer) json.Encoder {
return customConfig.NewEncoder(writer)
}
func (j customJsonApi) NewDecoder(reader io.Reader) json.Decoder {
return customConfig.NewDecoder(reader)
}
// region Time Extension
var (
zeroTime = time.Time{}
timeType = reflect2.TypeOfPtr((*time.Time)(nil)).Elem()
defaultTimeCodec = &timeCodec{}
)
type TimeEx struct {
jsoniter.DummyExtension
}
func (te *TimeEx) CreateDecoder(typ reflect2.Type) jsoniter.ValDecoder {
if typ == timeType {
return defaultTimeCodec
}
return nil
}
func (te *TimeEx) CreateEncoder(typ reflect2.Type) jsoniter.ValEncoder {
if typ == timeType {
return defaultTimeCodec
}
return nil
}
type timeCodec struct{}
func (tc timeCodec) IsEmpty(ptr unsafe.Pointer) bool {
t := *((*time.Time)(ptr))
return t.Equal(zeroTime)
}
func (tc timeCodec) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) {
t := *((*time.Time)(ptr))
if t.Equal(zeroTime) {
stream.WriteNil()
return
}
stream.WriteString(t.In(time.Local).Format("2006-01-02 15:04:05.000"))
}
func (tc timeCodec) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) {
ts := iter.ReadString()
if len(ts) == 0 {
*((*time.Time)(ptr)) = zeroTime
return
}
t, err := time.ParseInLocation("2006-01-02 15:04:05.000", ts, time.Local)
if err != nil {
panic(err)
}
*((*time.Time)(ptr)) = t
}
// endregion
// region *Time Extension
var (
timePointerType = reflect2.TypeOfPtr((**time.Time)(nil)).Elem()
defaultTimePointerCodec = &timePointerCodec{}
)
type TimePointerEx struct {
jsoniter.DummyExtension
}
func (tpe *TimePointerEx) CreateDecoder(typ reflect2.Type) jsoniter.ValDecoder {
if typ == timePointerType {
return defaultTimePointerCodec
}
return nil
}
func (tpe *TimePointerEx) CreateEncoder(typ reflect2.Type) jsoniter.ValEncoder {
if typ == timePointerType {
return defaultTimePointerCodec
}
return nil
}
type timePointerCodec struct{}
func (tpc timePointerCodec) IsEmpty(ptr unsafe.Pointer) bool {
t := *((**time.Time)(ptr))
return t == nil || (*t).Equal(zeroTime)
}
func (tpc timePointerCodec) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) {
t := *((**time.Time)(ptr))
if t == nil || (*t).Equal(zeroTime) {
stream.WriteNil()
return
}
stream.WriteString(t.In(time.Local).Format("2006-01-02 15:04:05.000"))
}
func (tpc timePointerCodec) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) {
ts := iter.ReadString()
if len(ts) == 0 {
*((**time.Time)(ptr)) = nil
return
}
t, err := time.ParseInLocation("2006-01-02 15:04:05.000", ts, time.Local)
if err != nil {
panic(err)
}
*((**time.Time)(ptr)) = &t
}
// endregion