diff --git a/binding/all.go b/binding/all.go new file mode 100644 index 00000000..1069863f --- /dev/null +++ b/binding/all.go @@ -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) +} diff --git a/binding/binding.go b/binding/binding.go index eced0ae2..84f4d866 100644 --- a/binding/binding.go +++ b/binding/binding.go @@ -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 combines other bindings and doesn't have its own Content-Type JSON BindingBody = jsonBinding{} XML BindingBody = xmlBinding{} Form Binding = formBinding{} diff --git a/binding/binding_nomsgpack.go b/binding/binding_nomsgpack.go index ae364d79..735d0e23 100644 --- a/binding/binding_nomsgpack.go +++ b/binding/binding_nomsgpack.go @@ -46,6 +46,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 @@ -71,18 +80,19 @@ 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 ( - JSON = jsonBinding{} - XML = xmlBinding{} - Form = formBinding{} - Query = queryBinding{} - FormPost = formPostBinding{} - FormMultipart = formMultipartBinding{} - ProtoBuf = protobufBinding{} - YAML = yamlBinding{} - Uri = uriBinding{} - Header = headerBinding{} - TOML = tomlBinding{} - Plain = plainBinding{} + All BindingMany = allBinding{} // NOTE: This combines other bindings and doesn't have its own Content-Type + JSON = jsonBinding{} + XML = xmlBinding{} + Form = formBinding{} + Query = queryBinding{} + FormPost = formPostBinding{} + FormMultipart = formMultipartBinding{} + ProtoBuf = protobufBinding{} + YAML = yamlBinding{} + Uri = uriBinding{} + Header = headerBinding{} + TOML = tomlBinding{} + Plain = plainBinding{} BSON BindingBody = bsonBinding{} ) diff --git a/binding/binding_test.go b/binding/binding_test.go index f90488cd..f880cd93 100644 --- a/binding/binding_test.go +++ b/binding/binding_test.go @@ -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, ¬)) + 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 diff --git a/context.go b/context.go index 5174033e..8eadec26 100644 --- a/context.go +++ b/context.go @@ -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 { diff --git a/context_test.go b/context_test.go index ef60379d..f0ea2e8c 100644 --- a/context_test.go +++ b/context_test.go @@ -2312,6 +2312,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) @@ -2486,6 +2527,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) diff --git a/docs/doc.md b/docs/doc.md index d1c33b87..33256fca 100644 --- a/docs/doc.md +++ b/docs/doc.md @@ -44,6 +44,7 @@ - [Bind HTML checkboxes](#bind-html-checkboxes) - [Multipart/Urlencoded binding](#multiparturlencoded-binding) - [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) - [Bind form-data request with custom struct and custom tag](#bind-form-data-request-with-custom-struct-and-custom-tag) - [Response Rendering](#response-rendering) @@ -1541,6 +1542,50 @@ $ curl "http://localhost:8080/getd?field_x=hello&field_d=world" {"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 The normal methods for binding request body consumes `c.Request.Body` and they