fix(binding): handle non-JSON string values in form binding for custom struct types

When a struct field implements json.Unmarshaler, form binding passes the
raw form value directly to json.Unmarshal. Since form values are plain
strings (e.g. "2020/09/23 13:20:49"), not valid JSON, the unmarshal
fails. Fix this by retrying with the value wrapped in JSON quotes when
the initial unmarshal fails.

Fixes #2510
This commit is contained in:
easonysliu 2026-03-16 12:52:51 +08:00
parent ecd26c8835
commit cb5463e5d8
2 changed files with 68 additions and 2 deletions

View File

@ -373,9 +373,9 @@ func setWithProperType(val string, value reflect.Value, field reflect.StructFiel
case multipart.FileHeader:
return nil
}
return json.API.Unmarshal(bytesconv.StringToBytes(val), value.Addr().Interface())
return unmarshalFieldAsJSON(val, value)
case reflect.Map:
return json.API.Unmarshal(bytesconv.StringToBytes(val), value.Addr().Interface())
return unmarshalFieldAsJSON(val, value)
case reflect.Ptr:
if !value.Elem().IsValid() {
value.Set(reflect.New(value.Type().Elem()))
@ -548,3 +548,24 @@ func setFormMap(ptr any, form map[string][]string) error {
return nil
}
// unmarshalFieldAsJSON tries to unmarshal a form value as JSON. If the initial
// unmarshal fails, it retries by wrapping the value in double quotes, treating
// the form value as a JSON string. This is necessary because form values are
// plain strings (e.g. "2020/09/23 13:20:49") while json.Unmarshal expects
// valid JSON input (e.g. quoted strings for string-like values).
func unmarshalFieldAsJSON(val string, value reflect.Value) error {
b := bytesconv.StringToBytes(val)
if err := json.API.Unmarshal(b, value.Addr().Interface()); err != nil {
// The raw form value is not valid JSON. Wrap it in quotes so that
// types implementing json.Unmarshaler that expect a JSON string
// (e.g. custom datetime types) can decode it successfully.
quoted := `"` + val + `"`
if errRetry := json.API.Unmarshal(bytesconv.StringToBytes(quoted), value.Addr().Interface()); errRetry != nil {
// Return the original error — the quoted attempt was only a
// best-effort fallback.
return err
}
}
return nil
}

View File

@ -7,6 +7,7 @@ package binding
import (
"encoding"
"encoding/hex"
"encoding/json"
"errors"
"mime/multipart"
"reflect"
@ -484,6 +485,50 @@ func TestMappingMapField(t *testing.T) {
assert.Equal(t, map[string]int{"one": 1}, s.M)
}
// customDateTime is a custom type that implements json.Unmarshaler.
// It expects a JSON-quoted string in "2006/01/02 15:04:05" format.
type customDateTime time.Time
const customDateTimeFormat = "2006/01/02 15:04:05"
func (t *customDateTime) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
parsed, err := time.Parse(customDateTimeFormat, s)
if err != nil {
return err
}
*t = customDateTime(parsed)
return nil
}
func TestMappingCustomJSONUnmarshalerStructFromForm(t *testing.T) {
var s struct {
Time customDateTime `form:"Time"`
}
err := mappingByPtr(&s, formSource{"Time": {"2020/09/23 13:20:49"}}, "form")
require.NoError(t, err)
expected, _ := time.Parse(customDateTimeFormat, "2020/09/23 13:20:49")
assert.Equal(t, customDateTime(expected), s.Time)
}
func TestMappingCustomJSONUnmarshalerStructValidJSON(t *testing.T) {
// When the form value is already valid JSON, it should still work.
var s struct {
J struct {
I int
}
}
err := mappingByPtr(&s, formSource{"J": {`{"I": 9}`}}, "form")
require.NoError(t, err)
assert.Equal(t, 9, s.J.I)
}
func TestMappingIgnoredCircularRef(t *testing.T) {
type S struct {
S *S `form:"-"`