Compare commits

...

3 Commits

Author SHA1 Message Date
takanuva15
aa9026e044
Merge 3a5ca23133c9515843e9d8b6096ab14859ba0ead into db309081bc5c137b2aa15701ef53f7f19788da25 2026-02-27 16:56:25 -05:00
takanuva15
3a5ca23133 feat(binding): add support for binding whole request at once 2026-02-27 16:56:13 -05:00
Jacob McSwain
db309081bc
chore(logger): allow skipping query string output (#4547)
This is useful for APIs that might have sensitive information in the query string, such as API keys.

This patch does not change the default behavior of the code unless the new `SkipQueryString` config option is passed in.

The "skip" term is a bit of a misnomer here, as this doesn't actually skip that log, but modifies the output. I'm open to suggestions for a more appropriate name.

Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2026-02-27 23:33:46 +08:00
9 changed files with 408 additions and 14 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
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{}

View File

@ -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{}
)

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

@ -22,6 +22,7 @@
- [How to write log file](#how-to-write-log-file)
- [Custom Log Format](#custom-log-format)
- [Controlling Log output coloring](#controlling-log-output-coloring)
- [Avoid logging query strings](#avoid-loging-query-strings)
- [Model binding and validation](#model-binding-and-validation)
- [Custom Validators](#custom-validators)
- [Only Bind Query String](#only-bind-query-string)
@ -592,6 +593,20 @@ func main() {
}
```
### Avoid logging query strings
```go
func main() {
router := gin.New()
// SkipQueryString indicates that the logger should not log the query string.
// For example, /path?q=1 will be logged as /path
loggerConfig := gin.LoggerConfig{SkipQueryString: true}
router.Use(gin.LoggerWithConfig(loggerConfig))
}
```
### Model binding and validation
To bind a request body into a type, use model binding. We currently support binding of JSON, XML, YAML, TOML and standard form values (foo=bar&boo=baz).
@ -1240,6 +1255,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

View File

@ -48,6 +48,11 @@ type LoggerConfig struct {
// Optional.
SkipPaths []string
// SkipQueryString indicates that query strings should not be written
// for cases such as when API keys are passed via query strings.
// Optional. Default value is false.
SkipQueryString bool
// Skip is a Skipper that indicates which logs should not be written.
// Optional.
Skip Skipper
@ -298,7 +303,7 @@ func LoggerWithConfig(conf LoggerConfig) HandlerFunc {
param.BodySize = c.Writer.Size()
if raw != "" {
if raw != "" && !conf.SkipQueryString {
path = path + "?" + raw
}

View File

@ -471,3 +471,17 @@ func TestForceConsoleColor(t *testing.T) {
// reset console color mode.
consoleColorMode = autoColor
}
func TestLoggerWithConfigSkipQueryString(t *testing.T) {
buffer := new(strings.Builder)
router := New()
router.Use(LoggerWithConfig(LoggerConfig{
Output: buffer,
SkipQueryString: true,
}))
router.GET("/logged", func(c *Context) { c.Status(http.StatusOK) })
PerformRequest(router, "GET", "/logged?a=21")
assert.Contains(t, buffer.String(), "200")
assert.NotContains(t, buffer.String(), "a=21")
}