From 842bd597a8d668e7065b179280571eac2f616f0d Mon Sep 17 00:00:00 2001 From: takanuva15 <6986426+takanuva15@users.noreply.github.com> Date: Fri, 21 Mar 2025 17:57:47 -0400 Subject: [PATCH] feat(binding): add support for encoding.UnmarshalText in uri/query binding --- binding/form_mapping.go | 71 +++-- binding/form_mapping_test.go | 523 ++++++++++++++++++++++++++++------- docs/doc.md | 78 +++++- 3 files changed, 541 insertions(+), 131 deletions(-) diff --git a/binding/form_mapping.go b/binding/form_mapping.go index 235692d2..38557597 100644 --- a/binding/form_mapping.go +++ b/binding/form_mapping.go @@ -5,6 +5,7 @@ package binding import ( + "encoding" "errors" "fmt" "mime/multipart" @@ -136,6 +137,7 @@ func mapping(value reflect.Value, field reflect.StructField, setter setter, tag type setOptions struct { isDefaultExists bool defaultValue string + parser string } func tryToSetValue(value reflect.Value, field reflect.StructField, setter setter, tag string) (bool, error) { @@ -167,6 +169,8 @@ func tryToSetValue(value reflect.Value, field reflect.StructField, setter setter setOpt.defaultValue = strings.ReplaceAll(v, ";", ",") } } + } else if k, v = head(opt, "="); k == "parser" { + setOpt.parser = v } } @@ -190,6 +194,20 @@ func trySetCustom(val string, value reflect.Value) (isSet bool, err error) { return false, nil } +// trySetUsingParser tries to set a custom type value based on the presence of the "parser" tag on the field. +// If the parser tag does not exist or does not match any of the supported parsers, gin will skip over this. +func trySetUsingParser(val string, value reflect.Value, parser string) (isSet bool, err error) { + switch parser { + case "encoding.TextUnmarshaler": + v, ok := value.Addr().Interface().(encoding.TextUnmarshaler) + if !ok { + return false, nil + } + return true, v.UnmarshalText([]byte(val)) + } + return false, nil +} + func trySplit(vs []string, field reflect.StructField) (newVs []string, err error) { cfTag := field.Tag.Get("collection_format") if cfTag == "" || cfTag == "multi" { @@ -207,7 +225,7 @@ func trySplit(vs []string, field reflect.StructField) (newVs []string, err error case "pipes": sep = "|" default: - return vs, fmt.Errorf("%s is not supported in the collection_format. (csv, ssv, pipes)", cfTag) + return vs, fmt.Errorf("%s is not supported in the collection_format. (multi, csv, ssv, tsv, pipes)", cfTag) } totalLength := 0 @@ -230,7 +248,7 @@ func setByForm(value reflect.Value, field reflect.StructField, form map[string][ switch value.Kind() { case reflect.Slice: - if !ok { + if !ok || len(vs) == 0 || (len(vs) > 0 && vs[0] == "") { vs = []string{opt.defaultValue} // pre-process the default value for multi if present @@ -240,7 +258,9 @@ func setByForm(value reflect.Value, field reflect.StructField, form map[string][ } } - if ok, err = trySetCustom(vs[0], value); ok { + if ok, err = trySetUsingParser(vs[0], value, opt.parser); ok { + return ok, err + } else if ok, err = trySetCustom(vs[0], value); ok { return ok, err } @@ -248,9 +268,9 @@ func setByForm(value reflect.Value, field reflect.StructField, form map[string][ return false, err } - return true, setSlice(vs, value, field) + return true, setSlice(vs, value, field, opt) case reflect.Array: - if !ok { + if !ok || len(vs) == 0 || (len(vs) > 0 && vs[0] == "") { vs = []string{opt.defaultValue} // pre-process the default value for multi if present @@ -260,7 +280,9 @@ func setByForm(value reflect.Value, field reflect.StructField, form map[string][ } } - if ok, err = trySetCustom(vs[0], value); ok { + if ok, err = trySetUsingParser(vs[0], value, opt.parser); ok { + return ok, err + } else if ok, err = trySetCustom(vs[0], value); ok { return ok, err } @@ -272,27 +294,32 @@ func setByForm(value reflect.Value, field reflect.StructField, form map[string][ return false, fmt.Errorf("%q is not valid value for %s", vs, value.Type().String()) } - return true, setArray(vs, value, field) + return true, setArray(vs, value, field, opt) default: var val string - if !ok { + if !ok || len(vs) == 0 || (len(vs) > 0 && vs[0] == "") { val = opt.defaultValue + } else if len(vs) > 0 { + val = vs[0] } - if len(vs) > 0 { - val = vs[0] - if val == "" { - val = opt.defaultValue - } - } - if ok, err := trySetCustom(val, value); ok { + if ok, err = trySetUsingParser(val, value, opt.parser); ok { + return ok, err + } else if ok, err = trySetCustom(val, value); ok { return ok, err } - return true, setWithProperType(val, value, field) + return true, setWithProperType(val, value, field, opt) } } -func setWithProperType(val string, value reflect.Value, field reflect.StructField) error { +func setWithProperType(val string, value reflect.Value, field reflect.StructField, opt setOptions) error { + // this if-check is required for parsing nested types like []MyId, where MyId is [12]byte + if ok, err := trySetUsingParser(val, value, opt.parser); ok { + return err + } else if ok, err = trySetCustom(val, value); ok { + return err + } + switch value.Kind() { case reflect.Int: return setIntField(val, 0, value) @@ -340,7 +367,7 @@ func setWithProperType(val string, value reflect.Value, field reflect.StructFiel if !value.Elem().IsValid() { value.Set(reflect.New(value.Type().Elem())) } - return setWithProperType(val, value.Elem(), field) + return setWithProperType(val, value.Elem(), field, opt) default: return errUnknownType } @@ -447,9 +474,9 @@ func setTimeField(val string, structField reflect.StructField, value reflect.Val return nil } -func setArray(vals []string, value reflect.Value, field reflect.StructField) error { +func setArray(vals []string, value reflect.Value, field reflect.StructField, opt setOptions) error { for i, s := range vals { - err := setWithProperType(s, value.Index(i), field) + err := setWithProperType(s, value.Index(i), field, opt) if err != nil { return err } @@ -457,9 +484,9 @@ func setArray(vals []string, value reflect.Value, field reflect.StructField) err return nil } -func setSlice(vals []string, value reflect.Value, field reflect.StructField) error { +func setSlice(vals []string, value reflect.Value, field reflect.StructField, opt setOptions) error { slice := reflect.MakeSlice(value.Type(), len(vals), len(vals)) - err := setArray(vals, slice, field) + err := setArray(vals, slice, field, opt) if err != nil { return err } diff --git a/binding/form_mapping_test.go b/binding/form_mapping_test.go index 1277fd5f..d77b5bb6 100644 --- a/binding/form_mapping_test.go +++ b/binding/form_mapping_test.go @@ -5,6 +5,7 @@ package binding import ( + "encoding" "encoding/hex" "errors" "mime/multipart" @@ -454,44 +455,44 @@ func TestMappingIgnoredCircularRef(t *testing.T) { require.NoError(t, err) } -type customUnmarshalParamHex int +// ==== BindUmarshaler tests START ==== -func (f *customUnmarshalParamHex) UnmarshalParam(param string) error { +type customHexUnmarshalParam int + +func (f *customHexUnmarshalParam) UnmarshalParam(param string) error { v, err := strconv.ParseInt(param, 16, 64) if err != nil { return err } - *f = customUnmarshalParamHex(v) + *f = customHexUnmarshalParam(v) return nil } -func TestMappingCustomUnmarshalParamHexWithFormTag(t *testing.T) { - var s struct { - Foo customUnmarshalParamHex `form:"foo"` - } - err := mappingByPtr(&s, formSource{"foo": {`f5`}}, "form") - require.NoError(t, err) +func TestMappingCustomHexUnmarshalParam(t *testing.T) { + RunMappingUsingUriAndFormTagAndAssertForUnmarshalParam[customHexUnmarshalParam]( + t, + `f5`, + func(hex customHexUnmarshalParam, t *testing.T) { + assert.EqualValues(t, 245, hex) + }, + ) - assert.EqualValues(t, 245, s.Foo) + // verify default binding works with UnmarshalParam + var sDefaultValue struct { + Field1 customHexUnmarshalParam `form:"field1,default=f5"` + } + err := mappingByPtr(&sDefaultValue, formSource{"field1": {}}, "form") + require.NoError(t, err) + assert.EqualValues(t, 0xf5, sDefaultValue.Field1) } -func TestMappingCustomUnmarshalParamHexWithURITag(t *testing.T) { - var s struct { - Foo customUnmarshalParamHex `uri:"foo"` - } - err := mappingByPtr(&s, formSource{"foo": {`f5`}}, "uri") - require.NoError(t, err) - - assert.EqualValues(t, 245, s.Foo) -} - -type customUnmarshalParamType struct { +type customTypeUnmarshalParam struct { Protocol string Path string Name string } -func (f *customUnmarshalParamType) UnmarshalParam(param string) error { +func (f *customTypeUnmarshalParam) UnmarshalParam(param string) error { parts := strings.Split(param, ":") if len(parts) != 3 { return errors.New("invalid format") @@ -502,52 +503,28 @@ func (f *customUnmarshalParamType) UnmarshalParam(param string) error { return nil } -func TestMappingCustomStructTypeWithFormTag(t *testing.T) { - var s struct { - FileData customUnmarshalParamType `form:"data"` - } - err := mappingByPtr(&s, formSource{"data": {`file:/foo:happiness`}}, "form") - require.NoError(t, err) - - assert.EqualValues(t, "file", s.FileData.Protocol) - assert.EqualValues(t, "/foo", s.FileData.Path) - assert.EqualValues(t, "happiness", s.FileData.Name) +func TestMappingCustomStructType(t *testing.T) { + RunMappingUsingUriAndFormTagAndAssertForUnmarshalParam[customTypeUnmarshalParam]( + t, + `file:/foo:happiness`, + func(data customTypeUnmarshalParam, t *testing.T) { + assert.EqualValues(t, "file", data.Protocol) + assert.EqualValues(t, "/foo", data.Path) + assert.EqualValues(t, "happiness", data.Name) + }, + ) } -func TestMappingCustomStructTypeWithURITag(t *testing.T) { - var s struct { - FileData customUnmarshalParamType `uri:"data"` - } - err := mappingByPtr(&s, formSource{"data": {`file:/foo:happiness`}}, "uri") - require.NoError(t, err) - - assert.EqualValues(t, "file", s.FileData.Protocol) - assert.EqualValues(t, "/foo", s.FileData.Path) - assert.EqualValues(t, "happiness", s.FileData.Name) -} - -func TestMappingCustomPointerStructTypeWithFormTag(t *testing.T) { - var s struct { - FileData *customUnmarshalParamType `form:"data"` - } - err := mappingByPtr(&s, formSource{"data": {`file:/foo:happiness`}}, "form") - require.NoError(t, err) - - assert.EqualValues(t, "file", s.FileData.Protocol) - assert.EqualValues(t, "/foo", s.FileData.Path) - assert.EqualValues(t, "happiness", s.FileData.Name) -} - -func TestMappingCustomPointerStructTypeWithURITag(t *testing.T) { - var s struct { - FileData *customUnmarshalParamType `uri:"data"` - } - err := mappingByPtr(&s, formSource{"data": {`file:/foo:happiness`}}, "uri") - require.NoError(t, err) - - assert.EqualValues(t, "file", s.FileData.Protocol) - assert.EqualValues(t, "/foo", s.FileData.Path) - assert.EqualValues(t, "happiness", s.FileData.Name) +func TestMappingCustomPointerStructType(t *testing.T) { + RunMappingUsingUriAndFormTagAndAssertForUnmarshalParam[*customTypeUnmarshalParam]( + t, + `file:/foo:happiness`, + func(data *customTypeUnmarshalParam, t *testing.T) { + assert.EqualValues(t, "file", data.Protocol) + assert.EqualValues(t, "/foo", data.Path) + assert.EqualValues(t, "happiness", data.Name) + }, + ) } type customPath []string @@ -563,32 +540,49 @@ func (p *customPath) UnmarshalParam(param string) error { return nil } -func TestMappingCustomSliceUri(t *testing.T) { - var s struct { - FileData customPath `uri:"path"` - } - err := mappingByPtr(&s, formSource{"path": {`bar/foo`}}, "uri") - require.NoError(t, err) - - assert.EqualValues(t, "bar", s.FileData[0]) - assert.EqualValues(t, "foo", s.FileData[1]) +func TestMappingCustomSlice(t *testing.T) { + RunMappingUsingUriAndFormTagAndAssertForUnmarshalParam[customPath]( + t, + `bar/foo`, + func(path customPath, t *testing.T) { + assert.EqualValues(t, "bar", path[0]) + assert.EqualValues(t, "foo", path[1]) + }, + ) } -func TestMappingCustomSliceForm(t *testing.T) { - var s struct { - FileData customPath `form:"path"` +func TestMappingCustomSliceStopsWhenError(t *testing.T) { + var sForm struct { + Field1 customPath `form:"field1"` } - err := mappingByPtr(&s, formSource{"path": {`bar/foo`}}, "form") - require.NoError(t, err) + err := mappingByPtr(&sForm, formSource{"field1": {"invalid"}}, "form") + require.ErrorContains(t, err, "invalid format") + require.Empty(t, sForm.Field1) +} - assert.EqualValues(t, "bar", s.FileData[0]) - assert.EqualValues(t, "foo", s.FileData[1]) +func TestMappingCustomSliceOfSlice(t *testing.T) { + val := `bar/foo,bar/foo/spam` + expected := []customPath{{"bar", "foo"}, {"bar", "foo", "spam"}} + + var sUri struct { + Field1 []customPath `uri:"field1" collection_format:"csv"` + } + err := mappingByPtr(&sUri, formSource{"field1": {val}}, "uri") + require.NoError(t, err) + assert.EqualValues(t, expected, sUri.Field1) + + var sForm struct { + Field1 []customPath `form:"field1" collection_format:"csv"` + } + err = mappingByPtr(&sForm, formSource{"field1": {val}}, "form") + require.NoError(t, err) + assert.EqualValues(t, expected, sForm.Field1) } type objectID [12]byte func (o *objectID) UnmarshalParam(param string) error { - oid, err := convertTo(param) + oid, err := convertTo[objectID](param) if err != nil { return err } @@ -597,8 +591,8 @@ func (o *objectID) UnmarshalParam(param string) error { return nil } -func convertTo(s string) (objectID, error) { - var nilObjectID objectID +func convertTo[T ~[12]byte](s string) (T, error) { + var nilObjectID T if len(s) != 24 { return nilObjectID, errors.New("invalid format") } @@ -612,26 +606,351 @@ func convertTo(s string) (objectID, error) { return oid, nil } -func TestMappingCustomArrayUri(t *testing.T) { - var s struct { - FileData objectID `uri:"id"` - } - val := `664a062ac74a8ad104e0e80f` - err := mappingByPtr(&s, formSource{"id": {val}}, "uri") - require.NoError(t, err) - - expected, _ := convertTo(val) - assert.EqualValues(t, expected, s.FileData) +func TestMappingCustomArray(t *testing.T) { + RunMappingUsingUriAndFormTagAndAssertForUnmarshalParam[objectID]( + t, + `664a062ac74a8ad104e0e80f`, + func(oid objectID, t *testing.T) { + expected, _ := convertTo[objectID](`664a062ac74a8ad104e0e80f`) + assert.EqualValues(t, expected, oid) + }, + ) } -func TestMappingCustomArrayForm(t *testing.T) { - var s struct { - FileData objectID `form:"id"` +func TestMappingCustomArrayOfArray(t *testing.T) { + val := `664a062ac74a8ad104e0e80e,664a062ac74a8ad104e0e80f` + expected1, _ := convertTo[objectID](`664a062ac74a8ad104e0e80e`) + expected2, _ := convertTo[objectID](`664a062ac74a8ad104e0e80f`) + expected := []objectID{expected1, expected2} + + var sUri struct { + Field1 []objectID `uri:"field1" collection_format:"csv"` } - val := `664a062ac74a8ad104e0e80f` - err := mappingByPtr(&s, formSource{"id": {val}}, "form") + err := mappingByPtr(&sUri, formSource{"field1": {val}}, "uri") + require.NoError(t, err) + assert.EqualValues(t, expected, sUri.Field1) + + var sForm struct { + Field1 []objectID `form:"field1" collection_format:"csv"` + } + err = mappingByPtr(&sForm, formSource{"field1": {val}}, "form") + require.NoError(t, err) + assert.EqualValues(t, expected, sForm.Field1) + + var sDefaultValue struct { + Field1 []objectID `form:"field1,default=664a062ac74a8ad104e0e80e;664a062ac74a8ad104e0e80f" collection_format:"csv"` + } + err = mappingByPtr(&sDefaultValue, formSource{"field1": {}}, "form") + require.NoError(t, err) + assert.EqualValues(t, expected, sDefaultValue.Field1) +} + +// RunMappingUsingUriAndFormTagAndAssertForUnmarshalParam declares a struct with a field of the given generic type T +// and runs a mapping test using the given value for both the uri and form tag. Any asserts that should be done on the +// result are passed as a function in the last parameter. +// +// This method eliminates the need for writing duplicate tests to verify both form+uri tags for BindUnmarshaler tests +func RunMappingUsingUriAndFormTagAndAssertForUnmarshalParam[T any]( + t *testing.T, + valueToBind string, + assertsToRunAfterBind func(T, *testing.T), +) { + var sUri struct { + Field1 T `uri:"field1"` + } + err := mappingByPtr(&sUri, formSource{"field1": {valueToBind}}, "uri") + require.NoError(t, err) + assertsToRunAfterBind(sUri.Field1, t) + + var sForm struct { + Field1 T `form:"field1"` + } + err = mappingByPtr(&sForm, formSource{"field1": {valueToBind}}, "form") + require.NoError(t, err) + assertsToRunAfterBind(sForm.Field1, t) +} + +// ==== BindUmarshaler tests END ==== + +// ==== TextUnmarshaler tests START ==== + +type customHexUnmarshalText int + +func (f *customHexUnmarshalText) UnmarshalText(text []byte) error { + v, err := strconv.ParseInt(string(text), 16, 64) + if err != nil { + return err + } + *f = customHexUnmarshalText(v) + return nil +} + +// verify type implements TextUnmarshaler +var _ encoding.TextUnmarshaler = (*customHexUnmarshalText)(nil) + +func TestMappingCustomHexUnmarshalText(t *testing.T) { + RunMappingUsingUriAndFormTagAndAssertForUnmarshalText[customHexUnmarshalText]( + t, + `f5`, + func(hex customHexUnmarshalText, t *testing.T) { + assert.EqualValues(t, 245, hex) + }, + ) + + // verify default binding works with UnmarshalText + var sDefaultValue struct { + Field1 customHexUnmarshalText `form:"field1,default=f5,parser=encoding.TextUnmarshaler"` + } + err := mappingByPtr(&sDefaultValue, formSource{"field1": {}}, "form") + require.NoError(t, err) + assert.EqualValues(t, 0xf5, sDefaultValue.Field1) +} + +type customTypeUnmarshalText struct { + Protocol string + Path string + Name string +} + +func (f *customTypeUnmarshalText) UnmarshalText(text []byte) error { + parts := strings.Split(string(text), ":") + if len(parts) != 3 { + return errors.New("invalid format") + } + f.Protocol = parts[0] + f.Path = parts[1] + f.Name = parts[2] + return nil +} + +var _ encoding.TextUnmarshaler = (*customTypeUnmarshalText)(nil) + +func TestMappingCustomStructTypeUnmarshalText(t *testing.T) { + RunMappingUsingUriAndFormTagAndAssertForUnmarshalText[customTypeUnmarshalText]( + t, + `file:/foo:happiness`, + func(data customTypeUnmarshalText, t *testing.T) { + assert.EqualValues(t, "file", data.Protocol) + assert.EqualValues(t, "/foo", data.Path) + assert.EqualValues(t, "happiness", data.Name) + }, + ) +} + +func TestMappingCustomPointerStructTypeUnmarshalText(t *testing.T) { + RunMappingUsingUriAndFormTagAndAssertForUnmarshalText[*customTypeUnmarshalText]( + t, + `file:/foo:happiness`, + func(data *customTypeUnmarshalText, t *testing.T) { + assert.EqualValues(t, "file", data.Protocol) + assert.EqualValues(t, "/foo", data.Path) + assert.EqualValues(t, "happiness", data.Name) + }, + ) +} + +type customPathUnmarshalText []string + +func (p *customPathUnmarshalText) UnmarshalText(text []byte) error { + elems := strings.Split(string(text), "/") + n := len(elems) + if n < 2 { + return errors.New("invalid format") + } + + *p = elems + return nil +} + +var _ encoding.TextUnmarshaler = (*customPathUnmarshalText)(nil) + +func TestMappingCustomSliceUnmarshalText(t *testing.T) { + RunMappingUsingUriAndFormTagAndAssertForUnmarshalText[customPathUnmarshalText]( + t, + `bar/foo`, + func(path customPathUnmarshalText, t *testing.T) { + assert.EqualValues(t, "bar", path[0]) + assert.EqualValues(t, "foo", path[1]) + }, + ) +} + +func TestMappingCustomSliceUnmarshalTextStopsWhenError(t *testing.T) { + var sForm struct { + Field1 customPathUnmarshalText `form:"field1,parser=encoding.TextUnmarshaler"` + } + err := mappingByPtr(&sForm, formSource{"field1": {"invalid"}}, "form") + require.ErrorContains(t, err, "invalid format") + require.Empty(t, sForm.Field1) +} + +func TestMappingCustomSliceOfSliceUnmarshalText(t *testing.T) { + val := `bar/foo,bar/foo/spam` + expected := []customPathUnmarshalText{{"bar", "foo"}, {"bar", "foo", "spam"}} + + var sUri struct { + Field1 []customPathUnmarshalText `uri:"field1,parser=encoding.TextUnmarshaler" collection_format:"csv"` + } + err := mappingByPtr(&sUri, formSource{"field1": {val}}, "uri") + require.NoError(t, err) + assert.EqualValues(t, expected, sUri.Field1) + + var sForm struct { + Field1 []customPathUnmarshalText `form:"field1,parser=encoding.TextUnmarshaler" collection_format:"csv"` + } + err = mappingByPtr(&sForm, formSource{"field1": {val}}, "form") + require.NoError(t, err) + assert.EqualValues(t, expected, sForm.Field1) + + var sDefaultValue struct { + Field1 []customPathUnmarshalText `form:"field1,default=bar/foo;bar/foo/spam,parser=encoding.TextUnmarshaler" collection_format:"csv"` + } + err = mappingByPtr(&sDefaultValue, formSource{"field1": {}}, "form") + require.NoError(t, err) + assert.EqualValues(t, expected, sDefaultValue.Field1) +} + +type objectIDUnmarshalText [12]byte + +func (o *objectIDUnmarshalText) UnmarshalText(text []byte) error { + oid, err := convertTo[objectIDUnmarshalText](string(text)) + if err != nil { + return err + } + + *o = oid + return nil +} + +var _ encoding.TextUnmarshaler = (*objectIDUnmarshalText)(nil) + +func TestMappingCustomArrayUnmarshalText(t *testing.T) { + RunMappingUsingUriAndFormTagAndAssertForUnmarshalText[objectIDUnmarshalText]( + t, + `664a062ac74a8ad104e0e80f`, + func(oid objectIDUnmarshalText, t *testing.T) { + expected, _ := convertTo[objectIDUnmarshalText](`664a062ac74a8ad104e0e80f`) + assert.EqualValues(t, expected, oid) + }, + ) +} + +func TestMappingCustomArrayOfArrayUnmarshalText(t *testing.T) { + val := `664a062ac74a8ad104e0e80e,664a062ac74a8ad104e0e80f` + expected1, _ := convertTo[objectIDUnmarshalText](`664a062ac74a8ad104e0e80e`) + expected2, _ := convertTo[objectIDUnmarshalText](`664a062ac74a8ad104e0e80f`) + expected := []objectIDUnmarshalText{expected1, expected2} + + var sUri struct { + Field1 []objectIDUnmarshalText `uri:"field1,parser=encoding.TextUnmarshaler" collection_format:"csv"` + } + err := mappingByPtr(&sUri, formSource{"field1": {val}}, "uri") + require.NoError(t, err) + assert.EqualValues(t, expected, sUri.Field1) + + var sForm struct { + Field1 []objectIDUnmarshalText `form:"field1,parser=encoding.TextUnmarshaler" collection_format:"csv"` + } + err = mappingByPtr(&sForm, formSource{"field1": {val}}, "form") + require.NoError(t, err) + assert.EqualValues(t, expected, sForm.Field1) + + var sDefaultValue struct { + Field1 []objectIDUnmarshalText `form:"field1,default=664a062ac74a8ad104e0e80e;664a062ac74a8ad104e0e80f,parser=encoding.TextUnmarshaler" collection_format:"csv"` + } + err = mappingByPtr(&sDefaultValue, formSource{"field1": {}}, "form") + require.NoError(t, err) + assert.EqualValues(t, expected, sDefaultValue.Field1) +} + +// RunMappingUsingUriAndFormTagAndAssertForUnmarshalText declares a struct with a field of the given generic type T +// and runs a mapping test using the given value for both the uri and form tag. Any asserts that should be done on the +// result are passed as a function in the last parameter. +// +// This method eliminates the need for writing duplicate tests to verify both form+uri tags for TextUnmarshaler tests +func RunMappingUsingUriAndFormTagAndAssertForUnmarshalText[T any]( + t *testing.T, + valueToBind string, + assertsToRunAfterBind func(T, *testing.T), +) { + var sUri struct { + Field1 T `uri:"field1,parser=encoding.TextUnmarshaler"` + } + err := mappingByPtr(&sUri, formSource{"field1": {valueToBind}}, "uri") + require.NoError(t, err) + assertsToRunAfterBind(sUri.Field1, t) + + var sForm struct { + Field1 T `form:"field1,parser=encoding.TextUnmarshaler"` + } + err = mappingByPtr(&sForm, formSource{"field1": {valueToBind}}, "form") + require.NoError(t, err) + assertsToRunAfterBind(sForm.Field1, t) +} + +// If someone specifies parser=TextUnmarshaler and it's not defined for the type, gin should revert to using its default +// binding logic. +func TestMappingUsingBindUnmarshalerAndTextUnmarshalerWhenOnlyBindUnmarshalerDefined(t *testing.T) { + var s struct { + Hex customHexUnmarshalParam `form:"hex"` + HexByUnmarshalText customHexUnmarshalParam `form:"hex2,parser=encoding.TextUnmarshaler"` + } + err := mappingByPtr(&s, formSource{ + "hex": {`f5`}, + "hex2": {`f5`}, + }, "form") require.NoError(t, err) - expected, _ := convertTo(val) - assert.EqualValues(t, expected, s.FileData) + assert.EqualValues(t, 0xf5, s.Hex) + 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) +func TestMappingUsingBindUnmarshalerAndTextUnmarshalerWhenOnlyTextUnmarshalerDefined(t *testing.T) { + var s struct { + Hex customHexUnmarshalText `form:"hex"` + HexByUnmarshalText customHexUnmarshalText `form:"hex2,parser=encoding.TextUnmarshaler"` + } + err := mappingByPtr(&s, formSource{ + "hex": {`11`}, + "hex2": {`11`}, + }, "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 hex binding +} + +type customHexUnmarshalParamAndUnmarshalText int + +func (f *customHexUnmarshalParamAndUnmarshalText) UnmarshalParam(param string) error { + return errors.New("should not be called in unit test if parser tag present") +} + +func (f *customHexUnmarshalParamAndUnmarshalText) UnmarshalText(text []byte) error { + v, err := strconv.ParseInt(string(text), 16, 64) + if err != nil { + return err + } + *f = customHexUnmarshalParamAndUnmarshalText(v) + return nil + +} + +// If a type has both UnmarshalParam and UnmarshalText methods defined, but the parser tag is set to TextUnmarshaler, +// then only the UnmarshalText method should be invoked. +func TestMappingUsingTextUnmarshalerWhenBindUnmarshalerAlsoDefined(t *testing.T) { + var s struct { + Hex customHexUnmarshalParamAndUnmarshalText `form:"hex,parser=encoding.TextUnmarshaler"` + } + err := mappingByPtr(&s, formSource{ + "hex": {`f5`}, + }, "form") + require.NoError(t, err) + + assert.EqualValues(t, 0xf5, s.Hex) +} + +// ==== TextUnmarshaler tests END ==== diff --git a/docs/doc.md b/docs/doc.md index bea417b2..a1522015 100644 --- a/docs/doc.md +++ b/docs/doc.md @@ -910,7 +910,7 @@ curl -X POST http://localhost:8080/person NOTE: For default [collection values](#collection-format-for-arrays), the following rules apply: - Since commas are used to delimit tag options, they are not supported within a default value and will result in undefined behavior -- For the collection formats "multi" and "csv", a semicolon should be used in place of a comma to delimited default values +- For the collection formats "multi" and "csv", a semicolon should be used in place of a comma to delimit default values - Since semicolons are used to delimit default values for "multi" and "csv", they are not supported within a default value for "multi" and "csv" @@ -1008,12 +1008,68 @@ curl -v localhost:8088/thinkerou/not-uuid ### Bind custom unmarshaler +To override gin's default binding logic, define a function on your type that satisfies the `encoding.TextUnmarshaler` interface from the Golang standard library. Then specify `parser=encoding.TextUnmarshaler` in the `uri`/`form` tag of the field being bound. + ```go package main import ( - "github.com/gin-gonic/gin" + "encoding" "strings" + + "github.com/gin-gonic/gin" +) + +type Birthday string + +func (b *Birthday) UnmarshalText(text []byte) error { + *b = Birthday(strings.Replace(string(text), "-", "/", -1)) + return nil +} + +var _ encoding.TextUnmarshaler = (*Birthday)(nil) //assert Birthday implements encoding.TextUnmarshaler + +func main() { + route := gin.Default() + var request struct { + Birthday Birthday `form:"birthday,parser=encoding.TextUnmarshaler"` + Birthdays []Birthday `form:"birthdays,parser=encoding.TextUnmarshaler" collection_format:"csv"` + BirthdaysDefault []Birthday `form:"birthdaysDef,default=2020-09-01;2020-09-02,parser=encoding.TextUnmarshaler" collection_format:"csv"` + } + route.GET("/test", func(ctx *gin.Context) { + _ = ctx.BindQuery(&request) + ctx.JSON(200, request) + }) + _ = route.Run(":8088") +} +``` + +Test it with: + +```sh +curl 'localhost:8088/test?birthday=2000-01-01&birthdays=2000-01-01,2000-01-02' +``` +Result +```sh +{"Birthday":"2000/01/01","Birthdays":["2000/01/01","2000/01/02"],"BirthdaysDefault":["2020/09/01","2020/09/02"] +``` + +Note: +- If `parser=encoding.TextUnmarshaler` is specified for a type that does **not** implement `encoding.TextUnmarshaler`, gin will ignore it and proceed with its default binding logic. +- If `parser=encoding.TextUnmarshaler` is specified for a type and that type's implementation of `encoding.TextUnmarshaler` returns an error, gin will stop binding and return the error to the client. + +--- + +If a type implements `encoding.TextUnmarshaler` but you still want to customize how gin binds the type separately (eg to change what error message is returned), you can implement the dedicated `BindUnmarshaler` interface provided by gin. + +```go +package main + +import ( + "strings" + + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" ) type Birthday string @@ -1023,29 +1079,37 @@ func (b *Birthday) UnmarshalParam(param string) error { return nil } +var _ binding.BindUnmarshaler = (*Birthday)(nil) //assert Birthday implements binding.BindUnmarshaler + func main() { route := gin.Default() var request struct { - Birthday Birthday `form:"birthday"` + Birthday Birthday `form:"birthday"` + Birthdays []Birthday `form:"birthdays" collection_format:"csv"` + BirthdaysDefault []Birthday `form:"birthdaysDef,default=2020-09-01;2020-09-02" collection_format:"csv"` } route.GET("/test", func(ctx *gin.Context) { _ = ctx.BindQuery(&request) - ctx.JSON(200, request.Birthday) + ctx.JSON(200, request) }) - route.Run(":8088") + _ = route.Run(":8088") } ``` Test it with: ```sh -curl 'localhost:8088/test?birthday=2000-01-01' +curl 'localhost:8088/test?birthday=2000-01-01&birthdays=2000-01-01,2000-01-02' ``` Result ```sh -"2000/01/01" +{"Birthday":"2000/01/01","Birthdays":["2000/01/01","2000/01/02"],"BirthdaysDefault":["2020/09/01","2020/09/02"] ``` +Note: +- If a type implements both `encoding.TextUnmarshaler` and `BindUnmarshaler`, gin will use `BindUnmarshaler` by default unless you specify `parser=encoding.TextUnmarshaler` in the binding tag. +- If a type returns an error from its implementation of `BindUnmarshaler`, gin will stop binding and return the error to the client. + ### Bind Header ```go