From 3ab698dc5110af1977d57226e4995c57dd34c233 Mon Sep 17 00:00:00 2001 From: OHZEKI Naoki <0h23k1.n40k1@gmail.com> Date: Sat, 17 Jan 2026 17:40:43 +0900 Subject: [PATCH 1/4] refactor(recovery): smart error comparison (#4142) * refactor(recovery): rename var in CustomRecoveryWithWriter * refactor(recovery): smart error comparison * test(recovery): Directly reference the syscall error string --- recovery.go | 37 +++++++++++++++---------------------- recovery_test.go | 13 +++++++------ 2 files changed, 22 insertions(+), 28 deletions(-) diff --git a/recovery.go b/recovery.go index 6d4b4b2b..bbf1d565 100644 --- a/recovery.go +++ b/recovery.go @@ -12,12 +12,12 @@ import ( "fmt" "io" "log" - "net" "net/http" "net/http/httputil" "os" "runtime" "strings" + "syscall" "time" "github.com/gin-gonic/gin/internal/bytesconv" @@ -57,40 +57,33 @@ func CustomRecoveryWithWriter(out io.Writer, handle RecoveryFunc) HandlerFunc { } return func(c *Context) { defer func() { - if err := recover(); err != nil { + if rec := recover(); rec != nil { // Check for a broken connection, as it is not really a // condition that warrants a panic stack trace. - var brokenPipe bool - if ne, ok := err.(*net.OpError); ok { - var se *os.SyscallError - if errors.As(ne, &se) { - seStr := strings.ToLower(se.Error()) - if strings.Contains(seStr, "broken pipe") || - strings.Contains(seStr, "connection reset by peer") { - brokenPipe = true - } - } - } - if e, ok := err.(error); ok && errors.Is(e, http.ErrAbortHandler) { - brokenPipe = true + var isBrokenPipe bool + err, ok := rec.(error) + if ok { + isBrokenPipe = errors.Is(err, syscall.EPIPE) || + errors.Is(err, syscall.ECONNRESET) || + errors.Is(err, http.ErrAbortHandler) } if logger != nil { - if brokenPipe { - logger.Printf("%s\n%s%s", err, secureRequestDump(c.Request), reset) + if isBrokenPipe { + logger.Printf("%s\n%s%s", rec, secureRequestDump(c.Request), reset) } else if IsDebugging() { logger.Printf("[Recovery] %s panic recovered:\n%s\n%s\n%s%s", - timeFormat(time.Now()), secureRequestDump(c.Request), err, stack(stackSkip), reset) + timeFormat(time.Now()), secureRequestDump(c.Request), rec, stack(stackSkip), reset) } else { logger.Printf("[Recovery] %s panic recovered:\n%s\n%s%s", - timeFormat(time.Now()), err, stack(stackSkip), reset) + timeFormat(time.Now()), rec, stack(stackSkip), reset) } } - if brokenPipe { + if isBrokenPipe { // If the connection is dead, we can't write a status to it. - c.Error(err.(error)) //nolint: errcheck + c.Error(err) //nolint: errcheck c.Abort() } else { - handle(c, err) + handle(c, rec) } } }() diff --git a/recovery_test.go b/recovery_test.go index 912ab601..0faa3280 100644 --- a/recovery_test.go +++ b/recovery_test.go @@ -98,13 +98,13 @@ func TestFunction(t *testing.T) { func TestPanicWithBrokenPipe(t *testing.T) { const expectCode = 204 - expectMsgs := map[syscall.Errno]string{ - syscall.EPIPE: "broken pipe", - syscall.ECONNRESET: "connection reset by peer", + expectErrnos := []syscall.Errno{ + syscall.EPIPE, + syscall.ECONNRESET, } - for errno, expectMsg := range expectMsgs { - t.Run(expectMsg, func(t *testing.T) { + for _, errno := range expectErrnos { + t.Run("Recovery from "+errno.Error(), func(t *testing.T) { var buf strings.Builder router := New() @@ -122,7 +122,8 @@ func TestPanicWithBrokenPipe(t *testing.T) { w := PerformRequest(router, http.MethodGet, "/recovery") // TEST assert.Equal(t, expectCode, w.Code) - assert.Contains(t, strings.ToLower(buf.String()), expectMsg) + assert.Contains(t, strings.ToLower(buf.String()), errno.Error()) + assert.NotContains(t, strings.ToLower(buf.String()), "[Recovery]") }) } } From b2b489dbf4826c2c630717a77fd5e42774625410 Mon Sep 17 00:00:00 2001 From: WeidiDeng Date: Sun, 18 Jan 2026 12:56:22 +0800 Subject: [PATCH 2/4] chore(context): always trust xff headers from unix socket (#3359) Co-authored-by: Bo-Yi Wu --- context.go | 27 ++++++++++++++++++++------- context_test.go | 10 ++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/context.go b/context.go index c42459ff..be552078 100644 --- a/context.go +++ b/context.go @@ -978,14 +978,27 @@ func (c *Context) ClientIP() string { } } - // It also checks if the remoteIP is a trusted proxy or not. - // In order to perform this validation, it will see if the IP is contained within at least one of the CIDR blocks - // defined by Engine.SetTrustedProxies() - remoteIP := net.ParseIP(c.RemoteIP()) - if remoteIP == nil { - return "" + var ( + trusted bool + remoteIP net.IP + ) + // If gin is listening a unix socket, always trust it. + localAddr, ok := c.Request.Context().Value(http.LocalAddrContextKey).(net.Addr) + if ok && strings.HasPrefix(localAddr.Network(), "unix") { + trusted = true + } + + // Fallback + if !trusted { + // It also checks if the remoteIP is a trusted proxy or not. + // In order to perform this validation, it will see if the IP is contained within at least one of the CIDR blocks + // defined by Engine.SetTrustedProxies() + remoteIP = net.ParseIP(c.RemoteIP()) + if remoteIP == nil { + return "" + } + trusted = c.engine.isTrustedProxy(remoteIP) } - trusted := c.engine.isTrustedProxy(remoteIP) if trusted && c.engine.ForwardedByClientIP && c.engine.RemoteIPHeaders != nil { for _, headerName := range c.engine.RemoteIPHeaders { diff --git a/context_test.go b/context_test.go index 3080015c..f69d574f 100644 --- a/context_test.go +++ b/context_test.go @@ -1915,6 +1915,16 @@ func TestContextClientIP(t *testing.T) { c.engine.trustedCIDRs, _ = c.engine.prepareTrustedCIDRs() resetContextForClientIPTests(c) + // unix address + addr := &net.UnixAddr{Net: "unix", Name: "@"} + c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), http.LocalAddrContextKey, addr)) + c.Request.RemoteAddr = addr.String() + assert.Equal(t, "20.20.20.20", c.ClientIP()) + + // reset + c.Request = c.Request.WithContext(context.Background()) + resetContextForClientIPTests(c) + // Legacy tests (validating that the defaults don't break the // (insecure!) old behaviour) assert.Equal(t, "20.20.20.20", c.ClientIP()) From 192ac89eefc1c30f7c97ae48a9ffb1c6f1c8c8bc Mon Sep 17 00:00:00 2001 From: takanuva15 <6986426+takanuva15@users.noreply.github.com> Date: Sat, 24 Jan 2026 02:20:24 -0500 Subject: [PATCH 3/4] feat(binding): add support for encoding.UnmarshalText in uri/query binding (#4203) --- binding/form_mapping.go | 68 ++++-- binding/form_mapping_test.go | 390 +++++++++++++++++++++++++++++++++++ docs/doc.md | 78 ++++++- 3 files changed, 509 insertions(+), 27 deletions(-) diff --git a/binding/form_mapping.go b/binding/form_mapping.go index e76e7510..db6aa0dc 100644 --- a/binding/form_mapping.go +++ b/binding/form_mapping.go @@ -5,6 +5,7 @@ package binding import ( + "encoding" "errors" "fmt" "maps" @@ -137,6 +138,8 @@ func mapping(value reflect.Value, field reflect.StructField, setter setter, tag type setOptions struct { isDefaultExists bool defaultValue string + // parser specifies what interface to use for reading the request & default values (e.g. `encoding.TextUnmarshaler`) + parser string } func tryToSetValue(value reflect.Value, field reflect.StructField, setter setter, tag string) (bool, error) { @@ -168,6 +171,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 } } @@ -191,6 +196,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" { @@ -208,7 +227,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 @@ -244,7 +263,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 } @@ -252,7 +273,7 @@ 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 len(vs) == 0 { if !opt.isDefaultExists { @@ -267,7 +288,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 } @@ -279,27 +302,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 + } + // If it is a string type, no spaces are removed, and the user data is not modified here if value.Kind() != reflect.String { val = strings.TrimSpace(val) @@ -352,7 +380,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 } @@ -459,9 +487,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 } @@ -469,9 +497,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 e007573c..c78f7398 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" @@ -524,6 +525,16 @@ func TestMappingCustomUnmarshalParamHexWithURITag(t *testing.T) { assert.EqualValues(t, 245, s.Foo) } +func TestMappingCustomUnmarshalParamHexDefault(t *testing.T) { + var s struct { + Foo customUnmarshalParamHex `form:"foo,default=f5"` + } + err := mappingByPtr(&s, formSource{"foo": {}}, "form") + require.NoError(t, err) + + assert.EqualValues(t, 0xf5, s.Foo) +} + type customUnmarshalParamType struct { Protocol string Path string @@ -624,6 +635,33 @@ func TestMappingCustomSliceForm(t *testing.T) { assert.Equal(t, "foo", s.FileData[1]) } +func TestMappingCustomSliceStopsWhenError(t *testing.T) { + var s struct { + FileData customPath `form:"path"` + } + err := mappingByPtr(&s, formSource{"path": {"invalid"}}, "form") + require.ErrorContains(t, err, "invalid format") + require.Empty(t, s.FileData) +} + +func TestMappingCustomSliceOfSliceUri(t *testing.T) { + var s struct { + FileData []customPath `uri:"path" collection_format:"csv"` + } + err := mappingByPtr(&s, formSource{"path": {"bar/foo,bar/foo/spam"}}, "uri") + require.NoError(t, err) + assert.Equal(t, []customPath{{"bar", "foo"}, {"bar", "foo", "spam"}}, s.FileData) +} + +func TestMappingCustomSliceOfSliceForm(t *testing.T) { + var s struct { + FileData []customPath `form:"path" collection_format:"csv"` + } + err := mappingByPtr(&s, formSource{"path": {"bar/foo,bar/foo/spam"}}, "form") + require.NoError(t, err) + assert.Equal(t, []customPath{{"bar", "foo"}, {"bar", "foo", "spam"}}, s.FileData) +} + type objectID [12]byte func (o *objectID) UnmarshalParam(param string) error { @@ -675,6 +713,358 @@ func TestMappingCustomArrayForm(t *testing.T) { assert.Equal(t, expected, s.FileData) } +func TestMappingCustomArrayOfArrayUri(t *testing.T) { + id1, _ := convertTo(`664a062ac74a8ad104e0e80e`) + id2, _ := convertTo(`664a062ac74a8ad104e0e80f`) + + var s struct { + FileData []objectID `uri:"ids" collection_format:"csv"` + } + err := mappingByPtr(&s, formSource{"ids": {`664a062ac74a8ad104e0e80e,664a062ac74a8ad104e0e80f`}}, "uri") + require.NoError(t, err) + assert.Equal(t, []objectID{id1, id2}, s.FileData) +} + +func TestMappingCustomArrayOfArrayForm(t *testing.T) { + id1, _ := convertTo(`664a062ac74a8ad104e0e80e`) + id2, _ := convertTo(`664a062ac74a8ad104e0e80f`) + + var s struct { + FileData []objectID `form:"ids" collection_format:"csv"` + } + err := mappingByPtr(&s, formSource{"ids": {`664a062ac74a8ad104e0e80e,664a062ac74a8ad104e0e80f`}}, "form") + require.NoError(t, err) + assert.Equal(t, []objectID{id1, id2}, s.FileData) +} + +// ==== TextUnmarshaler tests START ==== + +type customUnmarshalTextHex int + +func (f *customUnmarshalTextHex) UnmarshalText(text []byte) error { + v, err := strconv.ParseInt(string(text), 16, 64) + if err != nil { + return err + } + *f = customUnmarshalTextHex(v) + return nil +} + +// verify type implements TextUnmarshaler +var _ encoding.TextUnmarshaler = (*customUnmarshalTextHex)(nil) + +func TestMappingCustomUnmarshalTextHexUri(t *testing.T) { + var s struct { + Field customUnmarshalTextHex `uri:"field,parser=encoding.TextUnmarshaler"` + } + err := mappingByPtr(&s, formSource{"field": {`f5`}}, "uri") + require.NoError(t, err) + assert.EqualValues(t, 245, s.Field) +} + +func TestMappingCustomUnmarshalTextHexForm(t *testing.T) { + var s struct { + Field customUnmarshalTextHex `form:"field,parser=encoding.TextUnmarshaler"` + } + err := mappingByPtr(&s, formSource{"field": {`f5`}}, "form") + require.NoError(t, err) + assert.EqualValues(t, 245, s.Field) +} + +func TestMappingCustomUnmarshalTextHexDefault(t *testing.T) { + var s struct { + Field customUnmarshalTextHex `form:"field,default=f5,parser=encoding.TextUnmarshaler"` + } + err := mappingByPtr(&s, formSource{"field1": {}}, "form") + require.NoError(t, err) + assert.EqualValues(t, 0xf5, s.Field) +} + +type customUnmarshalTextType struct { + Protocol string + Path string + Name string +} + +func (f *customUnmarshalTextType) 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 = (*customUnmarshalTextType)(nil) + +func TestMappingCustomStructTypeUnmarshalTextForm(t *testing.T) { + var s struct { + FileData customUnmarshalTextType `form:"data,parser=encoding.TextUnmarshaler"` + } + err := mappingByPtr(&s, formSource{"data": {`file:/foo:happiness`}}, "form") + require.NoError(t, err) + + assert.Equal(t, "file", s.FileData.Protocol) + assert.Equal(t, "/foo", s.FileData.Path) + assert.Equal(t, "happiness", s.FileData.Name) +} + +func TestMappingCustomStructTypeUnmarshalTextUri(t *testing.T) { + var s struct { + FileData customUnmarshalTextType `uri:"data,parser=encoding.TextUnmarshaler"` + } + err := mappingByPtr(&s, formSource{"data": {`file:/foo:happiness`}}, "uri") + require.NoError(t, err) + + assert.Equal(t, "file", s.FileData.Protocol) + assert.Equal(t, "/foo", s.FileData.Path) + assert.Equal(t, "happiness", s.FileData.Name) +} + +func TestMappingCustomPointerStructTypeUnmarshalTextForm(t *testing.T) { + var s struct { + FileData *customUnmarshalTextType `form:"data,parser=encoding.TextUnmarshaler"` + } + err := mappingByPtr(&s, formSource{"data": {`file:/foo:happiness`}}, "form") + require.NoError(t, err) + + assert.Equal(t, "file", s.FileData.Protocol) + assert.Equal(t, "/foo", s.FileData.Path) + assert.Equal(t, "happiness", s.FileData.Name) +} + +func TestMappingCustomPointerStructTypeUnmarshalTextUri(t *testing.T) { + var s struct { + FileData *customUnmarshalTextType `uri:"data,parser=encoding.TextUnmarshaler"` + } + err := mappingByPtr(&s, formSource{"data": {`file:/foo:happiness`}}, "uri") + require.NoError(t, err) + + assert.Equal(t, "file", s.FileData.Protocol) + assert.Equal(t, "/foo", s.FileData.Path) + assert.Equal(t, "happiness", s.FileData.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 TestMappingCustomSliceUnmarshalTextUri(t *testing.T) { + var s struct { + FileData customPathUnmarshalText `uri:"path,parser=encoding.TextUnmarshaler"` + } + err := mappingByPtr(&s, formSource{"path": {`bar/foo`}}, "uri") + require.NoError(t, err) + + assert.Equal(t, "bar", s.FileData[0]) + assert.Equal(t, "foo", s.FileData[1]) +} + +func TestMappingCustomSliceUnmarshalTextForm(t *testing.T) { + var s struct { + FileData customPathUnmarshalText `form:"path,parser=encoding.TextUnmarshaler"` + } + err := mappingByPtr(&s, formSource{"path": {`bar/foo`}}, "form") + require.NoError(t, err) + + assert.Equal(t, "bar", s.FileData[0]) + assert.Equal(t, "foo", s.FileData[1]) +} + +func TestMappingCustomSliceUnmarshalTextStopsWhenError(t *testing.T) { + var s struct { + FileData customPathUnmarshalText `form:"path,parser=encoding.TextUnmarshaler"` + } + err := mappingByPtr(&s, formSource{"path": {"invalid"}}, "form") + require.ErrorContains(t, err, "invalid format") + require.Empty(t, s.FileData) +} + +func TestMappingCustomSliceOfSliceUnmarshalTextUri(t *testing.T) { + var s struct { + FileData []customPathUnmarshalText `uri:"path,parser=encoding.TextUnmarshaler" collection_format:"csv"` + } + err := mappingByPtr(&s, formSource{"path": {"bar/foo,bar/foo/spam"}}, "uri") + require.NoError(t, err) + assert.Equal(t, []customPathUnmarshalText{{"bar", "foo"}, {"bar", "foo", "spam"}}, s.FileData) +} + +func TestMappingCustomSliceOfSliceUnmarshalTextForm(t *testing.T) { + var s struct { + FileData []customPathUnmarshalText `form:"path,parser=encoding.TextUnmarshaler" collection_format:"csv"` + } + err := mappingByPtr(&s, formSource{"path": {"bar/foo,bar/foo/spam"}}, "form") + require.NoError(t, err) + assert.Equal(t, []customPathUnmarshalText{{"bar", "foo"}, {"bar", "foo", "spam"}}, s.FileData) +} + +func TestMappingCustomSliceOfSliceUnmarshalTextDefault(t *testing.T) { + var s struct { + FileData []customPathUnmarshalText `form:"path,default=bar/foo;bar/foo/spam,parser=encoding.TextUnmarshaler" collection_format:"csv"` + } + err := mappingByPtr(&s, formSource{"path": {}}, "form") + require.NoError(t, err) + assert.Equal(t, []customPathUnmarshalText{{"bar", "foo"}, {"bar", "foo", "spam"}}, s.FileData) +} + +type objectIDUnmarshalText [12]byte + +func (o *objectIDUnmarshalText) UnmarshalText(text []byte) error { + oid, err := convertToOidUnmarshalText(string(text)) + if err != nil { + return err + } + + *o = oid + return nil +} + +func convertToOidUnmarshalText(s string) (objectIDUnmarshalText, error) { + oid, err := convertTo(s) + return objectIDUnmarshalText(oid), err +} + +var _ encoding.TextUnmarshaler = (*objectIDUnmarshalText)(nil) + +func TestMappingCustomArrayUnmarshalTextUri(t *testing.T) { + var s struct { + FileData objectIDUnmarshalText `uri:"id,parser=encoding.TextUnmarshaler"` + } + val := `664a062ac74a8ad104e0e80f` + err := mappingByPtr(&s, formSource{"id": {val}}, "uri") + require.NoError(t, err) + + expected, _ := convertToOidUnmarshalText(val) + assert.Equal(t, expected, s.FileData) +} + +func TestMappingCustomArrayUnmarshalTextForm(t *testing.T) { + var s struct { + FileData objectIDUnmarshalText `form:"id,parser=encoding.TextUnmarshaler"` + } + val := `664a062ac74a8ad104e0e80f` + err := mappingByPtr(&s, formSource{"id": {val}}, "form") + require.NoError(t, err) + + expected, _ := convertToOidUnmarshalText(val) + assert.Equal(t, expected, s.FileData) +} + +func TestMappingCustomArrayOfArrayUnmarshalTextUri(t *testing.T) { + id1, _ := convertToOidUnmarshalText(`664a062ac74a8ad104e0e80e`) + id2, _ := convertToOidUnmarshalText(`664a062ac74a8ad104e0e80f`) + + var s struct { + FileData []objectIDUnmarshalText `uri:"ids,parser=encoding.TextUnmarshaler" collection_format:"csv"` + } + err := mappingByPtr(&s, formSource{"ids": {`664a062ac74a8ad104e0e80e,664a062ac74a8ad104e0e80f`}}, "uri") + require.NoError(t, err) + assert.Equal(t, []objectIDUnmarshalText{id1, id2}, s.FileData) +} + +func TestMappingCustomArrayOfArrayUnmarshalTextForm(t *testing.T) { + id1, _ := convertToOidUnmarshalText(`664a062ac74a8ad104e0e80e`) + id2, _ := convertToOidUnmarshalText(`664a062ac74a8ad104e0e80f`) + + var s struct { + FileData []objectIDUnmarshalText `form:"ids,parser=encoding.TextUnmarshaler" collection_format:"csv"` + } + err := mappingByPtr(&s, formSource{"ids": {`664a062ac74a8ad104e0e80e,664a062ac74a8ad104e0e80f`}}, "form") + require.NoError(t, err) + assert.Equal(t, []objectIDUnmarshalText{id1, id2}, s.FileData) +} + +func TestMappingCustomArrayOfArrayUnmarshalTextDefault(t *testing.T) { + id1, _ := convertToOidUnmarshalText(`664a062ac74a8ad104e0e80e`) + id2, _ := convertToOidUnmarshalText(`664a062ac74a8ad104e0e80f`) + + var s struct { + FileData []objectIDUnmarshalText `form:"ids,default=664a062ac74a8ad104e0e80e;664a062ac74a8ad104e0e80f,parser=encoding.TextUnmarshaler" collection_format:"csv"` + } + err := mappingByPtr(&s, formSource{"ids": {}}, "form") + require.NoError(t, err) + assert.Equal(t, []objectIDUnmarshalText{id1, id2}, s.FileData) +} + +// 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 customUnmarshalParamHex `form:"hex"` + HexByUnmarshalText customUnmarshalParamHex `form:"hex2,parser=encoding.TextUnmarshaler"` + } + err := mappingByPtr(&s, formSource{ + "hex": {`f5`}, + "hex2": {`f5`}, + }, "form") + require.NoError(t, err) + + 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 customUnmarshalTextHex `form:"hex"` + HexByUnmarshalText customUnmarshalTextHex `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 normal 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 ==== + func TestMappingEmptyValues(t *testing.T) { t.Run("slice with default", func(t *testing.T) { var s struct { diff --git a/docs/doc.md b/docs/doc.md index 0dd86684..449c8d02 100644 --- a/docs/doc.md +++ b/docs/doc.md @@ -911,7 +911,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" @@ -1009,12 +1009,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 already implements `encoding.TextUnmarshaler` but you want to customize how gin binds the type differently (eg to change what error message is returned), you can implement the dedicated `BindUnmarshaler` interface provided by gin instead. + +```go +package main + +import ( + "strings" + + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" ) type Birthday string @@ -1024,29 +1080,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 From ac95fa6bbcf5ec102bc81fbbb70e8fccba90f0b1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 24 Jan 2026 15:22:06 +0800 Subject: [PATCH 4/4] chore(deps): bump goreleaser/goreleaser-action from 5 to 6 (#3992) Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 5 to 6. - [Release notes](https://github.com/goreleaser/goreleaser-action/releases) - [Commits](https://github.com/goreleaser/goreleaser-action/compare/v5...v6) --- updated-dependencies: - dependency-name: goreleaser/goreleaser-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>