feat(binding): add support for binding whole request at once

This commit is contained in:
takanuva15 2026-02-10 16:55:24 -05:00
parent d3ffc99852
commit 39b84f00ad
7 changed files with 372 additions and 13 deletions

43
binding/all.go Normal file
View File

@ -0,0 +1,43 @@
package binding
import (
"net/http"
"strings"
)
type allBinding struct{}
var _ BindingMany = allBinding{}
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 package binding
import "net/http" import (
"net/http"
)
// Content-Type MIME of the most common data formats. // Content-Type MIME of the most common data formats.
const ( const (
@ -48,6 +50,15 @@ type BindingUri interface {
BindUri(map[string][]string, any) error 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 // 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 // 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 // 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 // These implement the Binding interface and can be used to bind the data
// present in the request to struct instances. // present in the request to struct instances.
var ( var (
All BindingMany = allBinding{} // NOTE: This combines other bindings and doesn't have its own Content-Type
JSON BindingBody = jsonBinding{} JSON BindingBody = jsonBinding{}
XML BindingBody = xmlBinding{} XML BindingBody = xmlBinding{}
Form Binding = formBinding{} Form Binding = formBinding{}

View File

@ -46,6 +46,15 @@ type BindingUri interface {
BindUri(map[string][]string, any) error 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 // 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 // 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 // of the request. Gin provides a default implementation for this using
@ -71,18 +80,19 @@ var Validator StructValidator = &defaultValidator{}
// These implement the Binding interface and can be used to bind the data // These implement the Binding interface and can be used to bind the data
// present in the request to struct instances. // present in the request to struct instances.
var ( var (
JSON = jsonBinding{} All BindingMany = allBinding{} // NOTE: This combines other bindings and doesn't have its own Content-Type
XML = xmlBinding{} JSON = jsonBinding{}
Form = formBinding{} XML = xmlBinding{}
Query = queryBinding{} Form = formBinding{}
FormPost = formPostBinding{} Query = queryBinding{}
FormMultipart = formMultipartBinding{} FormPost = formPostBinding{}
ProtoBuf = protobufBinding{} FormMultipart = formMultipartBinding{}
YAML = yamlBinding{} ProtoBuf = protobufBinding{}
Uri = uriBinding{} YAML = yamlBinding{}
Header = headerBinding{} Uri = uriBinding{}
TOML = tomlBinding{} Header = headerBinding{}
Plain = plainBinding{} TOML = tomlBinding{}
Plain = plainBinding{}
BSON BindingBody = bsonBinding{} BSON BindingBody = bsonBinding{}
) )

View File

@ -35,6 +35,13 @@ type QueryTest struct {
appkey 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 { type FooStruct struct {
Foo string `msgpack:"foo" json:"foo" form:"foo" xml:"foo" binding:"required,max=32"` 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) 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) { func requestWithBody(method, path, body string) (req *http.Request) {
req, _ = http.NewRequest(method, path, bytes.NewBufferString(body)) req, _ = http.NewRequest(method, path, bytes.NewBufferString(body))
return return

View File

@ -804,6 +804,22 @@ func (c *Context) BindUri(obj any) error {
return nil 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. // MustBindWith binds the passed struct pointer using the specified binding engine.
// It will abort the request with HTTP 400 if any error occurs. // It will abort the request with HTTP 400 if any error occurs.
// See the binding package. // See the binding package.
@ -914,6 +930,22 @@ func (c *Context) ShouldBindUri(obj any) error {
return binding.Uri.BindUri(m, obj) 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. // ShouldBindWith binds the passed struct pointer using the specified binding engine.
// See the binding package. // See the binding package.
func (c *Context) ShouldBindWith(obj any, b binding.Binding) error { func (c *Context) ShouldBindWith(obj any, b binding.Binding) error {

View File

@ -2312,6 +2312,47 @@ func TestContextBindWithTOML(t *testing.T) {
assert.Equal(t, 0, w.Body.Len()) 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) { func TestContextBadAutoBind(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := CreateTestContext(w) c, _ := CreateTestContext(w)
@ -2486,6 +2527,29 @@ func TestContextShouldBindWithTOML(t *testing.T) {
assert.Equal(t, 0, w.Body.Len()) 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) { func TestContextBadAutoShouldBind(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
c, _ := CreateTestContext(w) c, _ := CreateTestContext(w)

View File

@ -44,6 +44,7 @@
- [Bind HTML checkboxes](#bind-html-checkboxes) - [Bind HTML checkboxes](#bind-html-checkboxes)
- [Multipart/Urlencoded binding](#multiparturlencoded-binding) - [Multipart/Urlencoded binding](#multiparturlencoded-binding)
- [Bind form-data request with custom struct](#bind-form-data-request-with-custom-struct) - [Bind form-data request with custom struct](#bind-form-data-request-with-custom-struct)
- [Bind All](#bind-all)
- [Try to bind body into different structs](#try-to-bind-body-into-different-structs) - [Try to bind body into different structs](#try-to-bind-body-into-different-structs)
- [Bind form-data request with custom struct and custom tag](#bind-form-data-request-with-custom-struct-and-custom-tag) - [Bind form-data request with custom struct and custom tag](#bind-form-data-request-with-custom-struct-and-custom-tag)
- [Response Rendering](#response-rendering) - [Response Rendering](#response-rendering)
@ -1541,6 +1542,50 @@ $ curl "http://localhost:8080/getd?field_x=hello&field_d=world"
{"d":"world","x":{"FieldX":"hello"}} {"d":"world","x":{"FieldX":"hello"}}
``` ```
### Bind All
BindAll will bind header, uri, query parameters, and body in one call. Validation is only 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"}
```
### Try to bind body into different structs ### Try to bind body into different structs
The normal methods for binding request body consumes `c.Request.Body` and they The normal methods for binding request body consumes `c.Request.Body` and they