Repository: gin-gonic/gin
Pull request #3522 (2023-03-04 13:20:57)

refactor(binding): use strings.Cut to replace strings.Index

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/gin-gonic/gin/pull/3522?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.17%. 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 [(`e04015f`)](https://app.codecov.io/gh/gin-gonic/gin/pull/3522?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    #3522      +/-   ##
==========================================
- Coverage   99.21%   99.17%   -0.05%     
==========================================
  Files          42       40       -2     
  Lines        3182     2664     -518     
==========================================
- Hits         3157     2642     -515     
+ Misses         17       12       -5     
- Partials        8       10       +2     
```

| [Flag](https://app.codecov.io/gh/gin-gonic/gin/pull/3522/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/3522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `?` | |
| [-tags nomsgpack](https://app.codecov.io/gh/gin-gonic/gin/pull/3522/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/3522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `99.09% <100.00%> (-0.03%)` | :arrow_down: |
| [go-1.19](https://app.codecov.io/gh/gin-gonic/gin/pull/3522/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%> (-0.05%)` | :arrow_down: |
| [go-1.20](https://app.codecov.io/gh/gin-gonic/gin/pull/3522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `?` | |
| [go-1.21](https://app.codecov.io/gh/gin-gonic/gin/pull/3522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `?` | |
| [macos-latest](https://app.codecov.io/gh/gin-gonic/gin/pull/3522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=gin-gonic) | `?` | |
| [ubuntu-latest](https://app.codecov.io/gh/gin-gonic/gin/pull/3522/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%> (-0.05%)` | :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/3522?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/.goreleaser.yaml b/.goreleaser.yaml
index ac2b462..99b66fe 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -54,12 +54,3 @@ changelog:
       order: 4
     - title: Others
       order: 999
-
-  filters:
-    # Commit messages matching the regexp listed here will be removed from
-    # the changelog
-    # Default is empty
-    exclude:
-      - "^docs"
-      - "CICD"
-      - typo
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7968520..de47c75 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,8 +1,82 @@
 # Gin ChangeLog
 
+## Gin v1.10.0
+
+### Features
+
+* feat(auth): add proxy-server authentication (#3877) (@EndlessParadox1)
+* feat(bind): ShouldBindBodyWith shortcut and change doc (#3871) (@RedCrazyGhost)
+* feat(binding): Support custom BindUnmarshaler for binding. (#3933) (@dkkb)
+* feat(binding): support override default binding implement (#3514) (@ssfyn)
+* feat(engine): Added `OptionFunc` and `With` (#3572) (@flc1125)
+* feat(logger): ability to skip logs based on user-defined logic (#3593) (@palvaneh)
+
+### Bug fixes
+
+* Revert "fix(uri): query binding bug (#3236)" (#3899) (@appleboy)
+* fix(binding): binding error while not upload file (#3819) (#3820) (@clearcodecn)
+* fix(binding): dereference pointer to struct (#3199) (@echovl)
+* fix(context): make context Value method adhere to Go standards (#3897) (@FarmerChillax)
+* fix(engine): fix unit test (#3878) (@flc1125)
+* fix(header): Allow header according to RFC 7231 (HTTP 405) (#3759) (@Crocmagnon)
+* fix(route): Add fullPath in context copy (#3784) (@KarthikReddyPuli)
+* fix(router): catch-all conflicting wildcard (#3812) (@FirePing32)
+* fix(sec): upgrade golang.org/x/crypto to 0.17.0 (#3832) (@chncaption)
+* fix(tree): correctly expand the capacity of params (#3502) (@georgijd-form3)
+* fix(uri): query binding bug (#3236) (@illiafox)
+* fix: Add pointer support for url query params (#3659) (#3666) (@omkar-foss)
+* fix: protect Context.Keys map when call Copy method (#3873) (@kingcanfish)
+ 
+### Enhancements
+
+* chore(CI): update release args (#3595) (@qloog)
+* chore(IP): add TrustedPlatform constant for Fly.io. (#3839) (@ab)
+* chore(debug): add ability to override the debugPrint statement (#2337) (@josegonzalez)
+* chore(deps): update dependencies to latest versions (#3835) (@appleboy)
+* chore(header): Add support for RFC 9512: application/yaml (#3851) (@vincentbernat)
+* chore(http): use white color for HTTP 1XX (#3741) (@viralparmarme)
+* chore(optimize): the ShouldBindUri method of the Context struct (#3911) (@1911860538)
+* chore(perf): Optimize the Copy method of the Context struct (#3859) (@1911860538)
+* chore(refactor): modify interface check way (#3855) (@demoManito)
+* chore(request): check reader if it's nil before reading (#3419) (@noahyao1024)
+* chore(security): upgrade Protobuf for CVE-2024-24786 (#3893) (@Fotkurz)
+* chore: refactor CI and update dependencies (#3848) (@appleboy)
+* chore: refactor configuration files for better readability (#3951) (@appleboy)
+* chore: update GitHub Actions configuration (#3792) (@appleboy)
+* chore: update changelog categories and improve documentation (#3917) (@appleboy)
+* chore: update dependencies to latest versions (#3694) (@appleboy)
+* chore: update external dependencies to latest versions (#3950) (@appleboy)
+* chore: update various Go dependencies to latest versions (#3901) (@appleboy)
+
+### Build process updates
+
+* build(codecov): Added a codecov configuration (#3891) (@flc1125)
+* ci(Makefile): vet command add .PHONY (#3915) (@imalasong)
+* ci(lint): update tooling and workflows for consistency (#3834) (@appleboy)
+* ci(release): refactor changelog regex patterns and exclusions (#3914) (@appleboy)
+* ci(testing): add go1.22 version (#3842) (@appleboy)
+
+### Documentation updates
+
+* docs(context): Added deprecation comments to BindWith (#3880) (@flc1125)
+* docs(middleware): comments to function `BasicAuthForProxy` (#3881) (@EndlessParadox1)
+* docs: Add document  to constant `AuthProxyUserKey` and  `BasicAuthForProxy`. (#3887) (@EndlessParadox1)
+* docs: fix typo in comment (#3868) (@testwill)
+* docs: fix typo in function documentation (#3872) (@TotomiEcio)
+* docs: remove redundant comments (#3765) (@WeiTheShinobi)
+* feat: update version constant to v1.10.0 (#3952) (@appleboy)
+
+### Others
+
+* Upgrade golang.org/x/net -> v0.13.0 (#3684) (@cpcf)
+* test(git): gitignore add develop tools (#3370) (@demoManito)
+* test(http): use constant instead of numeric literal (#3863) (@testwill)
+* test(path): Optimize unit test execution results (#3883) (@flc1125)
+* test(render): increased unit tests coverage (#3691) (@araujo88)
+
 ## Gin v1.9.1
 
-### BUG FIXES 
+### BUG FIXES
 
 * fix Request.Context() checks [#3512](https://github.com/gin-gonic/gin/pull/3512)
 
diff --git a/binding/form_mapping.go b/binding/form_mapping.go
index 77a1bde..108606f 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)
 	}
 }
@@ -377,11 +397,8 @@ func setTimeDuration(val string, value reflect.Value) error {
 }
 
 func head(str, sep string) (head string, tail string) {
-	idx := strings.Index(str, sep)
-	if idx < 0 {
-		return str, ""
-	}
-	return str[:idx], str[idx+len(sep):]
+	head, tail, _ = strings.Cut(str, sep)
+	return head, tail
 }
 
 func setFormMap(ptr any, form map[string][]string) error {
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
diff --git a/gin.go b/gin.go
index 1633fe1..8f32378 100644
--- a/gin.go
+++ b/gin.go
@@ -316,7 +316,7 @@ func (engine *Engine) Use(middleware ...HandlerFunc) IRoutes {
 	return engine
 }
 
-// With returns a new Engine instance with the provided options.
+// With returns a Engine with the configuration set in the OptionFunc.
 func (engine *Engine) With(opts ...OptionFunc) *Engine {
 	for _, opt := range opts {
 		opt(engine)
diff --git a/go.mod b/go.mod
index 44e02a3..3e94e50 100644
--- a/go.mod
+++ b/go.mod
@@ -5,15 +5,15 @@ go 1.20
 require (
 	github.com/bytedance/sonic v1.11.6
 	github.com/gin-contrib/sse v0.1.0
-	github.com/go-playground/validator/v10 v10.19.0
+	github.com/go-playground/validator/v10 v10.20.0
 	github.com/goccy/go-json v0.10.2
 	github.com/json-iterator/go v1.1.12
 	github.com/mattn/go-isatty v0.0.20
-	github.com/pelletier/go-toml/v2 v2.2.0
+	github.com/pelletier/go-toml/v2 v2.2.2
 	github.com/stretchr/testify v1.9.0
 	github.com/ugorji/go/codec v1.2.12
-	golang.org/x/net v0.22.0
-	google.golang.org/protobuf v1.33.0
+	golang.org/x/net v0.25.0
+	google.golang.org/protobuf v1.34.1
 	gopkg.in/yaml.v3 v3.0.1
 )
 
@@ -31,8 +31,8 @@ require (
 	github.com/modern-go/reflect2 v1.0.2 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
-	golang.org/x/arch v0.7.0 // indirect
-	golang.org/x/crypto v0.21.0 // indirect
-	golang.org/x/sys v0.18.0 // indirect
-	golang.org/x/text v0.14.0 // indirect
+	golang.org/x/arch v0.8.0 // indirect
+	golang.org/x/crypto v0.23.0 // indirect
+	golang.org/x/sys v0.20.0 // indirect
+	golang.org/x/text v0.15.0 // indirect
 )
diff --git a/go.sum b/go.sum
index 2028e88..ce905e7 100644
--- a/go.sum
+++ b/go.sum
@@ -18,8 +18,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
 github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
 github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
 github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4=
-github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
+github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
+github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
 github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
 github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
 github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
@@ -39,8 +39,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
 github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo=
-github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
+github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
+github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -60,21 +60,21 @@ github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2
 github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
 github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
 golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
-golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
-golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
-golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
-golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
-golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
-golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
+golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
+golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
+golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
-golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
-golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
-google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
-google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
+google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/version.go b/version.go
index 85462e5..93ad965 100644
--- a/version.go
+++ b/version.go
@@ -5,4 +5,4 @@
 package gin
 
 // Version is the current gin framework's version.
-const Version = "v1.9.1"
+const Version = "v1.10.0"


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())
+}