gin/binding/json.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

170 lines
4.9 KiB
Go

// Copyright 2014 Manu Martinez-Almeida. 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 (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"reflect"
"strconv"
"time"
ginjson "github.com/gin-gonic/gin/codec/json"
)
// EnableDecoderUseNumber is used to call the UseNumber method on the JSON
// Decoder instance. UseNumber causes the Decoder to unmarshal a number into an
// any as a Number instead of as a float64.
var EnableDecoderUseNumber = false
// EnableDecoderDisallowUnknownFields is used to call the DisallowUnknownFields method
// on the JSON Decoder instance. DisallowUnknownFields causes the Decoder to
// return an error when the destination is a struct and the input contains object
// keys which do not match any non-ignored, exported fields in the destination.
var EnableDecoderDisallowUnknownFields = false
type jsonBinding struct{}
func (jsonBinding) Name() string {
return "json"
}
func (jsonBinding) Bind(req *http.Request, obj any) error {
if req == nil || req.Body == nil {
return errors.New("invalid request")
}
return decodeJSON(req.Body, obj)
}
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 {
// 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()
}
if EnableDecoderDisallowUnknownFields {
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
}