Request Binding
Binding is driven by struct tags. Declare where each field comes from and ezz fills it in before your handler runs.
type SearchRequest struct {
Team string `path:"team"`
Query string `query:"q"`
Tags []string `query:"tags"`
Page int `query:"page" default:"1"`
Limit int `query:"limit" default:"25"`
TraceID string `header:"X-Trace-ID"`
Filters Filters `json:"filters"`
}
Choose a source
| Tag | Reads from | Example |
|---|---|---|
path | A {name} segment of the route pattern | `path:"id"` |
query | The query string | `query:"page"` |
header | A request header | `header:"X-Api-Version"` |
form | Form values, via ParseForm | `form:"email"` |
json | The JSON request body | `json:"name"` |
default | A fallback for missing query parameters | `query:"page" default:"1"` |
A field can carry more than one tag. Fields without any of these tags are left at their zero value unless they appear in the JSON body.
Understand the binding order
Binding happens in two passes:
- If the request has a
Content-Typecontainingapplication/json, the body is decoded into the whole struct. - Then
path,query,header, andformvalues are applied field by field.
Because the second pass runs last, a request value overwrites whatever the body set for the same field. That is useful for canonicalising an ID:
type UpdateUserRequest struct {
ID string `path:"id"` // always wins
Name string `json:"name"`
Email string `json:"email"`
}
A body field is only overwritten when the corresponding request value is present and non-empty, so ?name= does not blank out a name that came from the body.
Decode JSON bodies
The body is decoded when the Content-Type header contains application/json and the request either declares a length or uses chunked encoding. An empty body is not an error, so POST with no body binds nothing and validation decides whether that is acceptable.
type CreateUserRequest struct {
Name string `json:"name" validate:"required"`
Email string `json:"email" validate:"required,email"`
Tags []string `json:"tags"`
Profile *Profile `json:"profile"`
}
Nested structs, slices, maps, and pointers all work, because this pass is encoding/json.
A malformed body returns 400 with a message describing the decode failure:
{
"error": true,
"message": "failed to decode JSON body: invalid character 'x' looking for beginning of value",
"status": 400
}
Supply defaults
default applies to query fields whose parameter is absent or empty. It is not consulted for path, header, form, or body values.
type ListRequest struct {
Page int `query:"page" default:"1"`
Limit int `query:"limit" default:"25" validate:"min=1,max=100"`
Sort string `query:"sort" default:"created_at"`
}
Defaults are also written into the OpenAPI schema, so the docs show them.
Bind lists
A slice field accepts either a comma-separated value or a repeated parameter:
type FilterRequest struct {
Tags []string `query:"tags"`
IDs []int `query:"id"`
}
curl 'localhost:8080/items?tags=go,http&id=1&id=2'
Both forms end up as ["go","http"] and [1,2]. Values are trimmed of surrounding whitespace.
Supported field types
Strings, all signed and unsigned integer widths, floats, booleans, slices of those, and pointers to those. A pointer field is allocated on demand, which lets you tell "absent" from "zero":
type PatchRequest struct {
Active *bool `query:"active"` // nil when the parameter is missing
}
Booleans accept the strconv.ParseBool set: 1, t, T, true, TRUE, True, and their false counterparts. A value that fails to convert returns 400 naming the parameter:
{
"error": true,
"message": "failed to set query parameter limit: strconv.ParseInt: parsing \"abc\": invalid syntax",
"status": 400
}
Unexported fields are skipped. Anything else, such as a time.Time from a query parameter, needs to be taken as a string and parsed in the handler.
Read form submissions
type ContactRequest struct {
Name string `form:"name" validate:"required"`
Email string `form:"email" validate:"required,email"`
Message string `form:"message" validate:"required,min=10"`
}
app.POST("/contact", ezz.H(func(ctx *ezz.Context, req ContactRequest) (*Ack, error) {
return &Ack{Received: true}, nil
})).Public()
ParseForm is called once per request, and only if the struct has at least one form field. It covers application/x-www-form-urlencoded bodies and query values. For file uploads, reach for ctx.Request.FormFile directly.
Bind by hand
The same machinery is exported for cases outside a typed handler, such as a plain http.Handler or a background worker replaying stored requests.
import "github.com/sulv-io/ezz/binding"
var req CreateUserRequest
if err := binding.Bind(r, &req); err != nil {
// 400
}
if err := binding.Validate(req); err != nil {
// 400
}
| Function | Purpose |
|---|---|
binding.Bind(r, target) | Full binding: body, then path, query, header, form |
binding.BindJSON(r, target) | Body only |
binding.BindQuery(values, target) | Query values only, falling back to lowercased field names |
binding.BindPath(r, target) | Path parameters only |
binding.NewBinder(type) | A reusable binder for one type |
binding.NewValidator(type) | A reusable validator for one type |
Reflection metadata for each struct is cached the first time it is seen, so repeated binding of the same type does not re-walk the fields.
Next steps
- Validation for the rules that run after binding.
- OpenAPI for how these tags turn into parameters and schemas.