Compare commits

...

2 Commits

Author SHA1 Message Date
takanuva15
96d79df85d feat(binding): add support for binding whole request at once 2026-02-23 16:51:29 -05:00
dependabot[bot]
81dba46872
chore(deps): bump github.com/go-playground/validator/v10 (#4509)
Bumps [github.com/go-playground/validator/v10](https://github.com/go-playground/validator) from 10.28.0 to 10.30.1.
- [Release notes](https://github.com/go-playground/validator/releases)
- [Commits](https://github.com/go-playground/validator/compare/v10.28.0...v10.30.1)

---
updated-dependencies:
- dependency-name: github.com/go-playground/validator/v10
  dependency-version: 10.30.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-21 22:39:56 +08:00
8 changed files with 355 additions and 7 deletions

41
binding/all.go Normal file
View File

@ -0,0 +1,41 @@
package binding
import (
"net/http"
"strings"
)
type allBinding struct{}
func (allBinding) Name() string {
return "all"
}
func (allBinding) BindMany(req *http.Request, uriParams map[string][]string, obj any) error {
// from binding.Header
if err := mapHeader(obj, req.Header); err != nil {
return err
}
// from binding.Uri
if err := mapURI(obj, uriParams); err != nil {
return err
}
// from binding.Query
values := req.URL.Query()
if err := mapForm(obj, values); err != nil {
return err
}
// from context.Bind (for body/post-form/anything else)
contentType := req.Header.Get("Content-Type")
// trim contentType parameters, e.g. "application/json; charset=utf-8" -> "application/json"
contentTypeLastIdx := strings.IndexAny(contentType, " ;")
if contentTypeLastIdx != -1 {
contentType = contentType[:contentTypeLastIdx]
}
b := Default(req.Method, contentType)
// final validation done by whatever binding is selected here
return b.Bind(req, obj)
}

View File

@ -6,7 +6,9 @@
package binding
import "net/http"
import (
"net/http"
)
// Content-Type MIME of the most common data formats.
const (
@ -48,6 +50,15 @@ type BindingUri interface {
BindUri(map[string][]string, any) error
}
// BindingMany adds BindMany method to Binding. BindingMany is similar to Binding,
// but it has full access to all request aspects to bind multiple parts simultaneously.
//
// NOTE: External projects should NOT depend on this interface as it is for Gin's internal binding logic
type BindingMany interface {
Name() string
BindMany(req *http.Request, uriParams map[string][]string, obj any) error
}
// StructValidator is the minimal interface which needs to be implemented in
// order for it to be used as the validator engine for ensuring the correctness
// of the request. Gin provides a default implementation for this using
@ -74,6 +85,7 @@ var Validator StructValidator = &defaultValidator{}
// These implement the Binding interface and can be used to bind the data
// present in the request to struct instances.
var (
All BindingMany = allBinding{} // NOTE: This uses below bindings and doesn't have its own Content-Type
JSON BindingBody = jsonBinding{}
XML BindingBody = xmlBinding{}
Form Binding = formBinding{}

View File

@ -35,6 +35,13 @@ type QueryTest struct {
appkey
}
type FooStructHeaderUriQueryBody struct {
Limit int `header:"limit" binding:"required"`
ID string `uri:"id" binding:"required,numeric"`
Foo bool `form:"foo" binding:"required"`
Bar string `form:"bar" xml:"bar" binding:"required"`
}
type FooStruct struct {
Foo string `msgpack:"foo" json:"foo" form:"foo" xml:"foo" binding:"required,max=32"`
}
@ -1428,6 +1435,152 @@ func testProtoBodyBindingFail(t *testing.T, b Binding, name, path, badPath, body
require.Error(t, err)
}
func TestBindingAll(t *testing.T) {
b := All
assert.Equal(t, "all", b.Name())
}
func TestBindingAllHeader(t *testing.T) {
b := All
type tHeader struct {
Limit int `header:"limit"`
}
var theader tHeader
req := requestWithBody(http.MethodGet, "/", "")
req.Header.Add("limit", "1000")
require.NoError(t, b.BindMany(req, nil, &theader))
assert.Equal(t, 1000, theader.Limit)
// fail case
type failStruct struct {
Fail map[string]any `header:"fail"`
}
req = requestWithBody(http.MethodGet, "/", "")
req.Header.Add("fail", `{fail:fail}`)
err := b.BindMany(req, nil, &failStruct{})
require.Error(t, err)
}
func TestBindingAllUri(t *testing.T) {
b := All
type Tag struct {
Name string `uri:"name"`
}
var tag Tag
req := requestWithBody(http.MethodGet, "/thinkerou", "")
m := map[string][]string{"name": {"thinkerou"}}
require.NoError(t, b.BindMany(req, m, &tag))
assert.Equal(t, "thinkerou", tag.Name)
// fail case
type NotSupportStruct struct {
Name map[string]any `uri:"name"`
}
var not NotSupportStruct
require.Error(t, b.BindMany(req, m, &not))
assert.Equal(t, map[string]any(nil), not.Name)
}
func TestBindingAllQuery(t *testing.T) {
b := All
obj := FooBarStruct{}
req := requestWithBody(http.MethodGet, "/?foo=bar&bar=foo", "")
err := b.BindMany(req, nil, &obj)
require.NoError(t, err)
assert.Equal(t, "bar", obj.Foo)
assert.Equal(t, "foo", obj.Bar)
// fail case
obj2 := FooStructForBoolType{}
req = requestWithBody(http.MethodGet, "/?bool_foo=fasl", "")
err = b.BindMany(req, nil, &obj2)
require.Error(t, err)
}
func TestBindingAllBody(t *testing.T) {
b := All
obj := FooStruct{}
req := requestWithBody(http.MethodPost, "/", `{"foo": "bar"}`)
req.Header.Set("Content-Type", MIMEJSON+"; charset=utf-8")
err := b.BindMany(req, nil, &obj)
require.NoError(t, err)
assert.Equal(t, "bar", obj.Foo)
// fail case
obj2 := FooStruct{}
req = requestWithBody(http.MethodPost, "/", `{"bar": "foo"}`)
req.Header.Set("Content-Type", MIMEJSON)
err = b.BindMany(req, nil, &obj2)
require.Error(t, err)
}
func TestBindingAllHeaderUriQueryBody(t *testing.T) {
b := All
obj := FooStructHeaderUriQueryBody{}
req := requestWithBody(http.MethodPost, "/?foo=true", `bar=spam`)
req.Header.Set("Limit", "100")
req.Header.Set("Content-Type", MIMEPOSTForm)
m := map[string][]string{"id": {"1234"}}
err := b.BindMany(req, m, &obj)
require.NoError(t, err)
assert.Equal(t, 100, obj.Limit)
assert.Equal(t, "1234", obj.ID)
assert.True(t, obj.Foo)
assert.Equal(t, "spam", obj.Bar)
}
func TestBindingAllHeaderUriQueryBody_FailWhenHeaderMissing(t *testing.T) {
b := All
obj := FooStructHeaderUriQueryBody{}
req := requestWithBody(http.MethodPost, "/?foo=true", `bar=spam`)
req.Header.Set("Content-Type", MIMEPOSTForm)
m := map[string][]string{"id": {"1234"}}
err := b.BindMany(req, m, &obj)
require.ErrorContains(t, err, "validation for 'Limit' failed")
}
func TestBindingAllHeaderUriQueryBody_FailWhenUriInvalid(t *testing.T) {
b := All
obj := FooStructHeaderUriQueryBody{}
req := requestWithBody(http.MethodPost, "/?foo=true", `bar=spam`)
req.Header.Set("Limit", "100")
req.Header.Set("Content-Type", MIMEPOSTForm)
m := map[string][]string{"id": {"123x"}}
err := b.BindMany(req, m, &obj)
require.ErrorContains(t, err, "validation for 'ID' failed on the 'numeric' tag")
}
func TestBindingAllHeaderUriQueryBody_FailWhenQueryMissing(t *testing.T) {
b := All
obj := FooStructHeaderUriQueryBody{}
req := requestWithBody(http.MethodPost, "/", `bar=spam`)
req.Header.Set("Limit", "100")
req.Header.Set("Content-Type", MIMEPOSTForm)
m := map[string][]string{"id": {"1234"}}
err := b.BindMany(req, m, &obj)
require.ErrorContains(t, err, "validation for 'Foo' failed")
}
func TestBindingAllHeaderUriQueryBody_FailWhenBodyMissing(t *testing.T) {
b := All
obj := FooStructHeaderUriQueryBody{}
req := requestWithBody(http.MethodPost, "/?foo=true", `xxx=spam`)
req.Header.Set("Limit", "100")
req.Header.Set("Content-Type", MIMEPOSTForm)
m := map[string][]string{"id": {"1234"}}
err := b.BindMany(req, m, &obj)
require.ErrorContains(t, err, "validation for 'Bar' failed")
}
func requestWithBody(method, path, body string) (req *http.Request) {
req, _ = http.NewRequest(method, path, bytes.NewBufferString(body))
return

View File

@ -804,6 +804,22 @@ func (c *Context) BindUri(obj any) error {
return nil
}
// BindAll binds the passed struct pointer using all available binding engines.
// It will abort the request with HTTP 400 if any error occurs.
//
// Note:
// - Caller must tag struct fields appropriately for the desired binding (eg `header:"xxx"` vs `uri:"xxx"`)
// - Caller must ensure no duplication between field names (else use separate binding engines instead)
// - Caller must provide Content-Type header to select the correct body binding (eg "application/json" for JSON binding)
// - Binding validation tags are verified after all request parts have been bound
func (c *Context) BindAll(obj any) error {
if err := c.ShouldBindAll(obj); err != nil {
c.AbortWithError(http.StatusBadRequest, err).SetType(ErrorTypeBind) //nolint: errcheck
return err
}
return nil
}
// MustBindWith binds the passed struct pointer using the specified binding engine.
// It will abort the request with HTTP 400 if any error occurs.
// See the binding package.
@ -914,6 +930,22 @@ func (c *Context) ShouldBindUri(obj any) error {
return binding.Uri.BindUri(m, obj)
}
// ShouldBindAll binds the passed struct pointer using all the available binding engines.
// Like c.Bind() but this method does not set the response status code to 400 or abort if input is not valid.
//
// Note:
// - Caller must tag struct fields appropriately for the desired binding (eg `header:"xxx"` vs `uri:"xxx"`)
// - Caller must ensure no duplication between field names (else use separate binding engines instead)
// - Caller must provide Content-Type header to select the correct body binding (eg "application/json" for JSON binding)
// - Binding validation tags are verified after all request parts have been bound
func (c *Context) ShouldBindAll(obj any) error {
uriParams := make(map[string][]string, len(c.Params))
for _, v := range c.Params {
uriParams[v.Key] = []string{v.Value}
}
return binding.All.BindMany(c.Request, uriParams, obj)
}
// ShouldBindWith binds the passed struct pointer using the specified binding engine.
// See the binding package.
func (c *Context) ShouldBindWith(obj any, b binding.Binding) error {

View File

@ -2284,6 +2284,47 @@ func TestContextBindWithTOML(t *testing.T) {
assert.Equal(t, 0, w.Body.Len())
}
func TestContextBindAll(t *testing.T) {
w := httptest.NewRecorder()
c, _ := CreateTestContext(w)
c.Request, _ = http.NewRequest(http.MethodPost, "/1234?foo=true", strings.NewReader(`{"bar":"spam"}`))
c.Params = []Param{{Key: "id", Value: "1234"}}
c.Request.Header.Add("Content-Type", MIMEJSON) // set fake content-type
c.Request.Header.Add("Limit", "100")
var obj struct {
Limit int `header:"limit" binding:"required"`
ID string `uri:"id" binding:"required,numeric"`
Foo bool `form:"foo" binding:"required"`
Bar string `form:"bar" xml:"bar" binding:"required"`
}
require.NoError(t, c.BindAll(&obj))
assert.Equal(t, 100, obj.Limit)
assert.Equal(t, "1234", obj.ID)
assert.Equal(t, "true", c.Request.FormValue("foo"))
assert.Equal(t, "spam", obj.Bar)
assert.Empty(t, c.Errors)
}
func TestContextBindAll_400OnError(t *testing.T) {
w := httptest.NewRecorder()
c, _ := CreateTestContext(w)
c.Request, _ = http.NewRequest(http.MethodPost, "/1234?foo=true", strings.NewReader(`{"bar":"spam"}`))
c.Request.Header.Add("Content-Type", MIMEJSON) // set fake content-type
c.Request.Header.Add("Limit", "100")
var obj struct {
Limit int `header:"limit" binding:"required"`
ID string `uri:"id" binding:"required,numeric"`
Foo bool `form:"foo" binding:"required"`
Bar string `form:"bar" xml:"bar" binding:"required"`
}
err := c.BindAll(&obj)
require.ErrorContains(t, err, "Field validation for 'ID' failed")
}
func TestContextBadAutoBind(t *testing.T) {
w := httptest.NewRecorder()
c, _ := CreateTestContext(w)
@ -2458,6 +2499,29 @@ func TestContextShouldBindWithTOML(t *testing.T) {
assert.Equal(t, 0, w.Body.Len())
}
func TestContextShouldBindAll(t *testing.T) {
w := httptest.NewRecorder()
c, _ := CreateTestContext(w)
c.Request, _ = http.NewRequest(http.MethodPost, "/1234?foo=true", strings.NewReader(`{"bar":"spam"}`))
c.Params = []Param{{Key: "id", Value: "1234"}}
c.Request.Header.Add("Content-Type", MIMEJSON) // set fake content-type
c.Request.Header.Add("Limit", "100")
var obj struct {
Limit int `header:"limit" binding:"required"`
ID string `uri:"id" binding:"required,numeric"`
Foo bool `form:"foo" binding:"required"`
Bar string `form:"bar" xml:"bar" binding:"required"`
}
require.NoError(t, c.ShouldBindAll(&obj))
assert.Equal(t, 100, obj.Limit)
assert.Equal(t, "1234", obj.ID)
assert.Equal(t, "true", c.Request.FormValue("foo"))
assert.Equal(t, "spam", obj.Bar)
assert.Empty(t, c.Errors)
}
func TestContextBadAutoShouldBind(t *testing.T) {
w := httptest.NewRecorder()
c, _ := CreateTestContext(w)

View File

@ -1240,6 +1240,52 @@ Test it with:
curl -X POST -v --form name=user --form "avatar=@./avatar.png" http://localhost:8080/profile
```
### Bind All
BindAll will bind header, uri, query parameters, and body in one call. Validation is applied after request parts are bound.
```go
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
type Person struct {
Age int `header:"x-age" binding:"required"`
ID string `uri:"id" binding:"required,uuid"`
Name string `form:"name" binding:"required"`
Addresses [2]string `json:"addresses" binding:"required"`
}
func main() {
route := gin.Default()
route.POST("/:id", func(c *gin.Context) {
var person Person
if err := c.ShouldBindAll(&person); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"msg": err.Error()})
return
}
c.JSON(http.StatusOK, person)
})
route.Run(":8080")
}
```
Test it with:
```sh
curl -X POST -H "x-age:25" -H "Content-Type: application/json" -d '{"addresses":["foo","bar"]}' 'http://localhost:8080/987fbc97-4bed-5078-9f07-9141ba07c9f3?name=Bob'
# {"Age":25,"ID":"987fbc97-4bed-5078-9f07-9141ba07c9f3","Name":"Bob","addresses":["foo","bar"]}
curl -X POST -H "x-age:25" -H "Content-Type: application/json" -d '{"addresses":["foo","bar"]}' 'http://localhost:8080/not-uuid'
# {"msg":"Key: 'Person.ID' Error:Field validation for 'ID' failed on the 'uuid' tag\nKey: 'Person.Name' Error:Field validation for 'Name' failed on the 'required' tag"}
```
### XML, JSON, YAML, TOML and ProtoBuf rendering
```go

4
go.mod
View File

@ -7,7 +7,7 @@ toolchain go1.24.7
require (
github.com/bytedance/sonic v1.15.0
github.com/gin-contrib/sse v1.1.0
github.com/go-playground/validator/v10 v10.28.0
github.com/go-playground/validator/v10 v10.30.1
github.com/goccy/go-json v0.10.5
github.com/goccy/go-yaml v1.19.2
github.com/json-iterator/go v1.1.12
@ -29,7 +29,7 @@ require (
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect

8
go.sum
View File

@ -10,8 +10,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@ -20,8 +20,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.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=