feat(binding): auto-bind types implementing encoding.TextUnmarshaler

Types like uuid.UUID implement encoding.TextUnmarshaler but previously
required an explicit `parser=encoding.TextUnmarshaler` struct tag to be
used during form/query/URI binding. This meant ShouldBind/ShouldBindQuery
would fail with unhelpful errors like:
  ["45e1f85e-bca5-458d-bd9c-c56edd8f847b"] is not valid value for uuid.UUID

This change makes trySetCustom automatically check for
encoding.TextUnmarshaler as a fallback after BindUnmarshaler,
so any type implementing TextUnmarshaler (uuid.UUID, net.IP,
custom types, etc.) works out of the box with no extra tags.

Precedence order:
1. BindUnmarshaler (UnmarshalParam)
2. encoding.TextUnmarshaler (UnmarshalText)
3. Default type-based binding

time.Time is explicitly excluded from automatic TextUnmarshaler
handling since gin has dedicated time parsing with time_format,
time_utc, and time_location struct tags.

Fixes #2423

Signed-off-by: Varun Chawla <varun_6april@hotmail.com>
This commit is contained in:
Varun Chawla 2026-02-16 16:27:29 -08:00 committed by takanuva15
parent 3e44fdc4d1
commit 43b0b61e22
2 changed files with 140 additions and 8 deletions

View File

@ -185,13 +185,23 @@ type BindUnmarshaler interface {
UnmarshalParam(param string) error
}
// trySetCustom tries to set a custom type value
// If the value implements the BindUnmarshaler interface, it will be used to set the value, we will return `true`
// to skip the default value setting.
// trySetCustom tries to set a custom type value.
// It checks for BindUnmarshaler first, then falls back to encoding.TextUnmarshaler.
// This allows types like uuid.UUID (which implement TextUnmarshaler) to be bound
// automatically without requiring an explicit parser tag.
//
// Note: time.Time is excluded from automatic TextUnmarshaler handling because gin
// provides dedicated time parsing via time_format, time_utc, and time_location tags.
func trySetCustom(val string, value reflect.Value) (isSet bool, err error) {
switch v := value.Addr().Interface().(type) {
case BindUnmarshaler:
return true, v.UnmarshalParam(val)
case encoding.TextUnmarshaler:
// Skip time.Time — it has dedicated handling in setWithProperType via setTimeField,
// which supports time_format, time_utc, and time_location struct tags.
if _, isTime := value.Interface().(time.Time); !isTime {
return true, v.UnmarshalText([]byte(val))
}
}
return false, nil
}

View File

@ -1016,9 +1016,8 @@ func TestMappingUsingBindUnmarshalerAndTextUnmarshalerWhenOnlyBindUnmarshalerDef
assert.EqualValues(t, 0xf5, s.HexByUnmarshalText) // reverts to BindUnmarshaler binding
}
// If someone does not specify parser=TextUnmarshaler even when it's defined for the type, gin should ignore the
// UnmarshalText logic and continue using its default binding logic. (This ensures gin does not break backwards
// compatibility)
// When a type implements encoding.TextUnmarshaler, it is used automatically even without
// the parser tag. Both fields should produce the same result.
func TestMappingUsingBindUnmarshalerAndTextUnmarshalerWhenOnlyTextUnmarshalerDefined(t *testing.T) {
var s struct {
Hex customUnmarshalTextHex `form:"hex"`
@ -1030,8 +1029,8 @@ func TestMappingUsingBindUnmarshalerAndTextUnmarshalerWhenOnlyTextUnmarshalerDef
}, "form")
require.NoError(t, err)
assert.EqualValues(t, 11, s.Hex) // this is using default int binding, not our custom hex binding. 0x11 should be 17 in decimal
assert.EqualValues(t, 0x11, s.HexByUnmarshalText) // correct expected value for normal hex binding
assert.EqualValues(t, 0x11, s.Hex) // TextUnmarshaler is now used automatically
assert.EqualValues(t, 0x11, s.HexByUnmarshalText) // explicit parser tag also uses TextUnmarshaler
}
type customHexUnmarshalParamAndUnmarshalText int
@ -1144,3 +1143,126 @@ func TestMappingEmptyValues(t *testing.T) {
assert.Equal(t, []int{1, 2, 3}, s.SliceCsv)
})
}
// ==== Automatic TextUnmarshaler binding tests (no parser tag required) ====
// testUUID mimics uuid.UUID — a [16]byte array implementing encoding.TextUnmarshaler
type testUUID [16]byte
func (u *testUUID) UnmarshalText(text []byte) error {
s := string(text)
// simple hex-dash parser for 8-4-4-4-12 format
s = strings.ReplaceAll(s, "-", "")
if len(s) != 32 {
return errors.New("invalid UUID length: " + strconv.Itoa(len(s)))
}
for i := 0; i < 16; i++ {
b, err := strconv.ParseUint(s[i*2:i*2+2], 16, 8)
if err != nil {
return errors.New("invalid UUID hex at position " + strconv.Itoa(i*2))
}
u[i] = byte(b)
}
return nil
}
var _ encoding.TextUnmarshaler = (*testUUID)(nil)
func mustParseTestUUID(s string) testUUID {
var u testUUID
if err := u.UnmarshalText([]byte(s)); err != nil {
panic(err)
}
return u
}
func TestMappingTextUnmarshalerAutoBindForm(t *testing.T) {
var s struct {
ID testUUID `form:"id"`
}
err := mappingByPtr(&s, formSource{"id": {"45e1f85e-bca5-458d-bd9c-c56edd8f847b"}}, "form")
require.NoError(t, err)
assert.Equal(t, mustParseTestUUID("45e1f85e-bca5-458d-bd9c-c56edd8f847b"), s.ID)
}
func TestMappingTextUnmarshalerAutoBindURI(t *testing.T) {
var s struct {
ID testUUID `uri:"id"`
}
err := mappingByPtr(&s, formSource{"id": {"45e1f85e-bca5-458d-bd9c-c56edd8f847b"}}, "uri")
require.NoError(t, err)
assert.Equal(t, mustParseTestUUID("45e1f85e-bca5-458d-bd9c-c56edd8f847b"), s.ID)
}
func TestMappingTextUnmarshalerAutoBindSlice(t *testing.T) {
var s struct {
IDs []testUUID `form:"ids" collection_format:"csv"`
}
err := mappingByPtr(&s, formSource{"ids": {"45e1f85e-bca5-458d-bd9c-c56edd8f847b,68dc3815-6ab5-4883-81a1-96eff25b659f"}}, "form")
require.NoError(t, err)
expected := []testUUID{
mustParseTestUUID("45e1f85e-bca5-458d-bd9c-c56edd8f847b"),
mustParseTestUUID("68dc3815-6ab5-4883-81a1-96eff25b659f"),
}
assert.Equal(t, expected, s.IDs)
}
func TestMappingTextUnmarshalerAutoBindMultipleValues(t *testing.T) {
var s struct {
IDs []testUUID `form:"ids"`
}
err := mappingByPtr(&s, formSource{"ids": {
"45e1f85e-bca5-458d-bd9c-c56edd8f847b",
"68dc3815-6ab5-4883-81a1-96eff25b659f",
}}, "form")
require.NoError(t, err)
expected := []testUUID{
mustParseTestUUID("45e1f85e-bca5-458d-bd9c-c56edd8f847b"),
mustParseTestUUID("68dc3815-6ab5-4883-81a1-96eff25b659f"),
}
assert.Equal(t, expected, s.IDs)
}
func TestMappingTextUnmarshalerAutoBindDefault(t *testing.T) {
var s struct {
ID testUUID `form:"id,default=45e1f85e-bca5-458d-bd9c-c56edd8f847b"`
}
err := mappingByPtr(&s, formSource{}, "form")
require.NoError(t, err)
assert.Equal(t, mustParseTestUUID("45e1f85e-bca5-458d-bd9c-c56edd8f847b"), s.ID)
}
func TestMappingTextUnmarshalerAutoBindInvalidValue(t *testing.T) {
var s struct {
ID testUUID `form:"id"`
}
err := mappingByPtr(&s, formSource{"id": {"not-a-uuid"}}, "form")
require.Error(t, err)
}
// BindUnmarshaler should take precedence over TextUnmarshaler
type testDualUnmarshaler struct {
Value string
}
func (d *testDualUnmarshaler) UnmarshalParam(param string) error {
d.Value = "param:" + param
return nil
}
func (d *testDualUnmarshaler) UnmarshalText(text []byte) error {
d.Value = "text:" + string(text)
return nil
}
var _ BindUnmarshaler = (*testDualUnmarshaler)(nil)
var _ encoding.TextUnmarshaler = (*testDualUnmarshaler)(nil)
func TestMappingBindUnmarshalerTakesPrecedenceOverTextUnmarshaler(t *testing.T) {
var s struct {
Field testDualUnmarshaler `form:"field"`
}
err := mappingByPtr(&s, formSource{"field": {"hello"}}, "form")
require.NoError(t, err)
assert.Equal(t, "param:hello", s.Field.Value) // BindUnmarshaler wins
}