From cb5463e5d88b42b17d233c7314daca4cbe0861b5 Mon Sep 17 00:00:00 2001 From: easonysliu Date: Mon, 16 Mar 2026 12:52:51 +0800 Subject: [PATCH] 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 --- binding/form_mapping.go | 25 ++++++++++++++++++-- binding/form_mapping_test.go | 45 ++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/binding/form_mapping.go b/binding/form_mapping.go index 6982fd4f..e629fee3 100644 --- a/binding/form_mapping.go +++ b/binding/form_mapping.go @@ -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 +} diff --git a/binding/form_mapping_test.go b/binding/form_mapping_test.go index c78f7398..ff93b5e9 100644 --- a/binding/form_mapping_test.go +++ b/binding/form_mapping_test.go @@ -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:"-"`