Repository: gin-gonic/gin
Pull request #3933 (2024-04-25 08:49:51)

feat(binding): Support custom BindUnmarshaler for binding.
## Feature
Add `binding.BindUnmarshaler` interface to support custom unmarshaling (from the suggestion in https://github.com/gin-gonic/gin/issues/2673)

## Description
This PR is inspired by the information provided on the following page: [labstack/echo/v4#BindUnmarshaler ](https://pkg.go.dev/github.com/labstack/echo/v4#BindUnmarshaler) and  [PR-3045](https://github.com/gin-gonic/gin/pull/3045).

As [ssoroka](https://github.com/ssoroka) (the author of [PR-3045](https://github.com/gin-gonic/gin/pull/3045)) mentioned:
> This is way off my radar (it's been 2 years and I've switched to huma). Feel free to take over the change and apply it in a new PR."

Therefore, I would like to open a new pull request to implement this feature. However, I have identified two potential issues with PR-3045:

1. PR-3045 also introduces support for the `encoding.TextUnmarshaler` interface. I believe there might be compatibility issues with this approach. Instead, I suggest that users implement the new interface binding.BindUnmarshaler to achieve the desired custom unmarshaler functionality.

2. In order to support arrays and slices, PR-3045 uses `vs[0]` to retrieve the first item. This approach may be susceptible to boundary issues. Upon closer examination of the implementation, I found that addressing this complexity might be challenging. As a temporary solution, I have decided not to support this functionality for arrays and slices. If there is a future demand from other users, we can consider implementing it at that time.


```go
if ok, err := trySetCustom(vs[0], value); ok || err != nil {
    return ok, err
}
```
## Usage
```go
package main

import (
  "github.com/gin-gonic/gin"
  "strings"
)

type Birthday string

func (b *Birthday) UnmarshalParam(param string) error {
  *b = Birthday(strings.Replace(param, "-", "/", -1))
  return nil
}

func main() {
  route := gin.Default()
  var request struct {
    Birthday Birthday `form:"birthday"`
  }
  route.GET("/test", func(ctx *gin.Context) {
    _ = ctx.BindQuery(&request)
    ctx.JSON(200, request.Birthday)
  })
  route.Run(":8088")
}
```

Test it with:

```sh
curl 'localhost:8088/test?birthday=2000-01-01'
```
Result
```sh
"2000/01/01"
```

## Testing
1. Unit testing, see `binding/form_mapping_test.go`
2. Integration testing, see `gin_test.go`

## References
- Close https://github.com/gin-gonic/gin/issues/2673
- Close https://github.com/gin-gonic/gin/issues/2631
- Related to https://github.com/gin-gonic/gin/pull/3045

dkkb: @appleboy  Hi, Boyi, could you take a look at this PR. Any help would be greatly appreciated.
appleboy: Testing error:

<img width="638" alt="image" src="https://github.com/gin-gonic/gin/assets/21979/1c7d1589-2d2f-4784-a31b-e32db54989ee">

dkkb: That's weird, all my tests pass when I run them locally.

```
➜ make test  

--- PASS: TestBindMiddleware (0.00s)
=== RUN   TestMarshalXMLforH
--- PASS: TestMarshalXMLforH (0.00s)
=== RUN   TestIsASCII
--- PASS: TestIsASCII (0.00s)
PASS
coverage: 99.3% of statements
ok      github.com/gin-gonic/gin        0.457s  coverage: 99.3% of statements

```

```
=== RUN   TestMappingBaseTypes
--- PASS: TestMappingBaseTypes (0.00s)
=== RUN   TestMappingDefault
--- PASS: TestMappingDefault (0.00s)
=== RUN   TestMappingSkipField
--- PASS: TestMappingSkipField (0.00s)
=== RUN   TestMappingIgnoreField
--- PASS: TestMappingIgnoreField (0.00s)
=== RUN   TestMappingUnexportedField
--- PASS: TestMappingUnexportedField (0.00s)
=== RUN   TestMappingPrivateField
--- PASS: TestMappingPrivateField (0.00s)
=== RUN   TestMappingUnknownFieldType
--- PASS: TestMappingUnknownFieldType (0.00s)
=== RUN   TestMappingURI
--- PASS: TestMappingURI (0.00s)
=== RUN   TestMappingForm
--- PASS: TestMappingForm (0.00s)
=== RUN   TestMapFormWithTag
--- PASS: TestMapFormWithTag (0.00s)
=== RUN   TestMappingTime
--- PASS: TestMappingTime (0.00s)
=== RUN   TestMappingTimeDuration
--- PASS: TestMappingTimeDuration (0.00s)
=== RUN   TestMappingSlice
--- PASS: TestMappingSlice (0.00s)
=== RUN   TestMappingArray
--- PASS: TestMappingArray (0.00s)
=== RUN   TestMappingStructField
--- PASS: TestMappingStructField (0.00s)
=== RUN   TestMappingPtrField
--- PASS: TestMappingPtrField (0.00s)
=== RUN   TestMappingMapField
--- PASS: TestMappingMapField (0.00s)
=== RUN   TestMappingIgnoredCircularRef
--- PASS: TestMappingIgnoredCircularRef (0.00s)
=== RUN   TestMappingCustomUnmarshalParamHexWithFormTag
--- PASS: TestMappingCustomUnmarshalParamHexWithFormTag (0.00s)
=== RUN   TestMappingCustomUnmarshalParamHexWithURITag
--- PASS: TestMappingCustomUnmarshalParamHexWithURITag (0.00s)
=== RUN   TestMappingCustomStructTypeWithFormTag
--- PASS: TestMappingCustomStructTypeWithFormTag (0.00s)
=== RUN   TestMappingCustomStructTypeWithURITag
--- PASS: TestMappingCustomStructTypeWithURITag (0.00s)
=== RUN   TestMappingCustomPointerStructTypeWithFormTag
--- PASS: TestMappingCustomPointerStructTypeWithFormTag (0.00s)
=== RUN   TestMappingCustomPointerStructTypeWithURITag
--- PASS: TestMappingCustomPointerStructTypeWithURITag (0.00s)
PASS
```


dkkb: I move the newly added interface to `form_mapping.go`, it works now.
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/gin-gonic/gin/pull/3933?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 99.18%. Comparing base [(`3dc1cd6`)](https://app.codecov.io/gh/gin-gonic/gin/commit/3dc1cd6572b4e3a0cd170a15debe546c2c72294f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) to head [(`9dc4b6e`)](https://app.codecov.io/gh/gin-gonic/gin/pull/3933?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic).
> Report is 38 commits behind head on master.


<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #3933      +/-   ##
==========================================
- Coverage   99.21%   99.18%   -0.03%     
==========================================
  Files          42       43       +1     
  Lines        3182     2716     -466     
==========================================
- Hits         3157     2694     -463     
+ Misses         17       12       -5     
- Partials        8       10       +2     
```

| [Flag](https://app.codecov.io/gh/gin-gonic/gin/pull/3933/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | Coverage Δ | |
|---|---|---|
| [](https://app.codecov.io/gh/gin-gonic/gin/pull/3933/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `?` | |
| [-tags "sonic avx"](https://app.codecov.io/gh/gin-gonic/gin/pull/3933/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `99.18% <100.00%> (?)` | |
| [-tags go_json](https://app.codecov.io/gh/gin-gonic/gin/pull/3933/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `99.18% <100.00%> (?)` | |
| [-tags nomsgpack](https://app.codecov.io/gh/gin-gonic/gin/pull/3933/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `99.17% <100.00%> (?)` | |
| [go-1.18](https://app.codecov.io/gh/gin-gonic/gin/pull/3933/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `99.11% <100.00%> (-0.01%)` | :arrow_down: |
| [go-1.19](https://app.codecov.io/gh/gin-gonic/gin/pull/3933/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `99.18% <100.00%> (-0.03%)` | :arrow_down: |
| [go-1.20](https://app.codecov.io/gh/gin-gonic/gin/pull/3933/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `99.18% <100.00%> (-0.03%)` | :arrow_down: |
| [go-1.21](https://app.codecov.io/gh/gin-gonic/gin/pull/3933/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `99.18% <100.00%> (-0.04%)` | :arrow_down: |
| [macos-latest](https://app.codecov.io/gh/gin-gonic/gin/pull/3933/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `99.10% <100.00%> (-0.11%)` | :arrow_down: |
| [ubuntu-latest](https://app.codecov.io/gh/gin-gonic/gin/pull/3933/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `99.18% <100.00%> (-0.03%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic#carryforward-flags-in-the-pull-request-comment) to find out more.


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/gin-gonic/gin/pull/3933?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic).


Patch:
diff --git a/binding/form_mapping.go b/binding/form_mapping.go
index 77a1bde..db235e5 100644
--- a/binding/form_mapping.go
+++ b/binding/form_mapping.go
@@ -165,6 +165,23 @@ func tryToSetValue(value reflect.Value, field reflect.StructField, setter setter
 	return setter.TrySet(value, field, tagValue, setOpt)
 }
 
+// BindUnmarshaler is the interface used to wrap the UnmarshalParam method.
+type BindUnmarshaler interface {
+	// UnmarshalParam decodes and assigns a value from an form or query param.
+	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.
+func trySetCustom(val string, value reflect.Value) (isSet bool, err error) {
+	switch v := value.Addr().Interface().(type) {
+	case BindUnmarshaler:
+		return true, v.UnmarshalParam(val)
+	}
+	return false, nil
+}
+
 func setByForm(value reflect.Value, field reflect.StructField, form map[string][]string, tagValue string, opt setOptions) (isSet bool, err error) {
 	vs, ok := form[tagValue]
 	if !ok && !opt.isDefaultExists {
@@ -194,6 +211,9 @@ func setByForm(value reflect.Value, field reflect.StructField, form map[string][
 		if len(vs) > 0 {
 			val = vs[0]
 		}
+		if ok, err := trySetCustom(val, value); ok {
+			return ok, err
+		}
 		return true, setWithProperType(val, value, field)
 	}
 }
diff --git a/docs/doc.md b/docs/doc.md
index 70c9f27..177c447 100644
--- a/docs/doc.md
+++ b/docs/doc.md
@@ -27,6 +27,7 @@
   - [Only Bind Query String](#only-bind-query-string)
   - [Bind Query String or Post Data](#bind-query-string-or-post-data)
   - [Bind Uri](#bind-uri)
+  - [Bind custom unmarshaler](#bind-custom-unmarshaler)
   - [Bind Header](#bind-header)
   - [Bind HTML checkboxes](#bind-html-checkboxes)
   - [Multipart/Urlencoded binding](#multiparturlencoded-binding)
@@ -899,6 +900,46 @@ curl -v localhost:8088/thinkerou/987fbc97-4bed-5078-9f07-9141ba07c9f3
 curl -v localhost:8088/thinkerou/not-uuid
 ```
 
+### Bind custom unmarshaler
+
+```go
+package main
+
+import (
+  "github.com/gin-gonic/gin"
+  "strings"
+)
+
+type Birthday string
+
+func (b *Birthday) UnmarshalParam(param string) error {
+  *b = Birthday(strings.Replace(param, "-", "/", -1))
+  return nil
+}
+
+func main() {
+  route := gin.Default()
+  var request struct {
+    Birthday Birthday `form:"birthday"`
+  }
+  route.GET("/test", func(ctx *gin.Context) {
+    _ = ctx.BindQuery(&request)
+    ctx.JSON(200, request.Birthday)
+  })
+  route.Run(":8088")
+}
+```
+
+Test it with:
+
+```sh
+curl 'localhost:8088/test?birthday=2000-01-01'
+```
+Result
+```sh
+"2000/01/01"
+```
+
 ### Bind Header
 
 ```go


Test patch:
diff --git a/binding/form_mapping_test.go b/binding/form_mapping_test.go
index 16527eb..ed01a08 100644
--- a/binding/form_mapping_test.go
+++ b/binding/form_mapping_test.go
@@ -5,8 +5,11 @@
 package binding
 
 import (
+	"fmt"
 	"mime/multipart"
 	"reflect"
+	"strconv"
+	"strings"
 	"testing"
 	"time"
 
@@ -323,3 +326,99 @@ func TestMappingIgnoredCircularRef(t *testing.T) {
 	err := mappingByPtr(&s, formSource{}, "form")
 	assert.NoError(t, err)
 }
+
+type customUnmarshalParamHex int
+
+func (f *customUnmarshalParamHex) UnmarshalParam(param string) error {
+	v, err := strconv.ParseInt(param, 16, 64)
+	if err != nil {
+		return err
+	}
+	*f = customUnmarshalParamHex(v)
+	return nil
+}
+
+func TestMappingCustomUnmarshalParamHexWithFormTag(t *testing.T) {
+	var s struct {
+		Foo customUnmarshalParamHex `form:"foo"`
+	}
+	err := mappingByPtr(&s, formSource{"foo": {`f5`}}, "form")
+	assert.NoError(t, err)
+
+	assert.EqualValues(t, 245, s.Foo)
+}
+
+func TestMappingCustomUnmarshalParamHexWithURITag(t *testing.T) {
+	var s struct {
+		Foo customUnmarshalParamHex `uri:"foo"`
+	}
+	err := mappingByPtr(&s, formSource{"foo": {`f5`}}, "uri")
+	assert.NoError(t, err)
+
+	assert.EqualValues(t, 245, s.Foo)
+}
+
+type customUnmarshalParamType struct {
+	Protocol string
+	Path     string
+	Name     string
+}
+
+func (f *customUnmarshalParamType) UnmarshalParam(param string) error {
+	parts := strings.Split(param, ":")
+	if len(parts) != 3 {
+		return fmt.Errorf("invalid format")
+	}
+	f.Protocol = parts[0]
+	f.Path = parts[1]
+	f.Name = parts[2]
+	return nil
+}
+
+func TestMappingCustomStructTypeWithFormTag(t *testing.T) {
+	var s struct {
+		FileData customUnmarshalParamType `form:"data"`
+	}
+	err := mappingByPtr(&s, formSource{"data": {`file:/foo:happiness`}}, "form")
+	assert.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 TestMappingCustomStructTypeWithURITag(t *testing.T) {
+	var s struct {
+		FileData customUnmarshalParamType `uri:"data"`
+	}
+	err := mappingByPtr(&s, formSource{"data": {`file:/foo:happiness`}}, "uri")
+	assert.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")
+	assert.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")
+	assert.NoError(t, err)
+
+	assert.EqualValues(t, "file", s.FileData.Protocol)
+	assert.EqualValues(t, "/foo", s.FileData.Path)
+	assert.EqualValues(t, "happiness", s.FileData.Name)
+}
diff --git a/gin_test.go b/gin_test.go
index 4550a7e..e68f1ce 100644
--- a/gin_test.go
+++ b/gin_test.go
@@ -14,6 +14,7 @@ import (
 	"net/http/httptest"
 	"reflect"
 	"strconv"
+	"strings"
 	"sync/atomic"
 	"testing"
 	"time"
@@ -730,3 +731,26 @@ func TestWithOptionFunc(t *testing.T) {
 	assertRoutePresent(t, routes, RouteInfo{Path: "/test1", Method: "GET", Handler: "github.com/gin-gonic/gin.handlerTest1"})
 	assertRoutePresent(t, routes, RouteInfo{Path: "/test2", Method: "GET", Handler: "github.com/gin-gonic/gin.handlerTest2"})
 }
+
+type Birthday string
+
+func (b *Birthday) UnmarshalParam(param string) error {
+	*b = Birthday(strings.Replace(param, "-", "/", -1))
+	return nil
+}
+
+func TestCustomUnmarshalStruct(t *testing.T) {
+	route := Default()
+	var request struct {
+		Birthday Birthday `form:"birthday"`
+	}
+	route.GET("/test", func(ctx *Context) {
+		_ = ctx.BindQuery(&request)
+		ctx.JSON(200, request.Birthday)
+	})
+	req := httptest.NewRequest("GET", "/test?birthday=2000-01-01", nil)
+	w := httptest.NewRecorder()
+	route.ServeHTTP(w, req)
+	assert.Equal(t, 200, w.Code)
+	assert.Equal(t, `"2000/01/01"`, w.Body.String())
+}