Request binding & validation
binding turns an HTTP request into a validated Go struct. One call, four
sources, and a failure that is already an RFC 9457 response body.
type CreateUser struct {
Name string `json:"name" validate:"required,min=2,max=50"`
Email string `json:"email" validate:"required,email"`
Role string `json:"role" validate:"oneof=admin user viewer"`
Page int `form:"page"`
ID string `param:"id"`
}
router.Handle(breeze.POST, "/users/:id", func(ctx *breeze.Context) error {
var in CreateUser
if err := ctx.Bind(&in); err != nil {
return nil // the response is already written โ see below
}
return ctx.JSON(in)
}) Why this package exists
Decode, validate, and report โ three things every handler that accepts input
must do. Doing them separately means the twentieth handler writes those
twenty lines slightly differently, so one endpoint replies {"error": "..."}" and the next replies {"errors": [...]}. Bind does all
three and reports failures in one shape: RFC 9457 problem details.
ctx.Bind vs binding.Bind
Two entry points, and the difference matters.
ctx.Bind(&dst) is what a handler calls. It picks sources from the request
and on failure writes the response itself โ 422 with a problem+json body
for a validation error, 400 for anything else. A non-nil return means "the
response is already written", so the handler returns immediately.
binding.Bind(&dst, sources...) is the library call, with no Context and
no response โ for input that is not an HTTP request (a CLI flag set, a queue
message, a test), or when you want to choose sources yourself:
err := binding.Bind(&in,
binding.JSONBody(body),
binding.Query(values),
) The four sources
Sources apply in the order given; a later source overwrites a field an
earlier one set. ctx.Bind applies them in this order, skipping any that
are empty:
| Source | Struct tag | Reads from |
|---|---|---|
JSONBody(body []byte) | json | the request body |
Query(url.Values) | form, then query | the query string |
Form(url.Values) | form, then query | a parsed form body |
Path(map[string]string) | param | route parameters (/users/:id) |
Query and Form are the same function under two names โ a field bound
from ?page=2 and the same field bound from a form post are the same field,
so tagging it twice would buy nothing. The tag fallback (form first, then query) means one tag covers both, and query stays available for a field
that must differ between them.
Untagged fields are matched by lowercased field name, so Page binds ?page=2 with no tag โ a convenience for small structs, not a contract; add
the tag when the wire name matters.
Supported field types
string, bool, all sizes of int/uint, float32/float64, and
pointers to any of those. A pointer distinguishes absent from zero: ?count=0 sets *int to a pointer to 0, while omitting it leaves the
pointer nil.
Anything else โ a []string, a nested struct โ is skipped rather than
rejected, left for JSONBody to fill, since the query and path sources have
no syntax for them.
Validation rules
Comma-separated in a validate:"..." tag. All five:
| Rule | Argument | Passes when |
|---|---|---|
required | โ | the field is not its zero value |
min=N | number | a number is >= N; a string, slice or map has len >= N |
max=N | number | a number is <= N; a string, slice or map has len <= N |
email | โ | the value contains one @ with a non-empty local part, and a . in the domain |
oneof=a b c | space-separated | the value equals one of the listed values |
min/max are deliberately overloaded on kind โ min=2 on a string is a
length, on an int a bound โ because that is what a reader expects from validate:"min=2" on a name field.
email is a shape check, not an RFC 5322 parser or a deliverability check:
it catches a missing @ or a bare hostname and lets the rest through, since
the only real way to know an address works is to send to it.
Every rule is checked against every field before an error is returned, so a client fixing a form gets the whole list rather than one error per round trip. The validation plan is compiled per struct type and cached, so tag parsing happens once per type, not once per request.
The error shape
type FieldError struct {
Field string `json:"field"`
Rule string `json:"rule"`
Message string `json:"message"`
} ToProblemJSON() renders a *binding.ValidationError as RFC 9457. This is
what ctx.Bind writes with a 422:
{
"type": "about:blank",
"status": 422,
"title": "Validation Failed",
"errors": [
{"field": "Name", "rule": "required", "message": "Name is required"},
{"field": "Email", "rule": "email", "message": "Email must be a valid email"}
]
} breeze.Error recognises a *binding.ValidationError, so a handler that
would rather return than write directly can:
func create(ctx *breeze.Context) error {
var in CreateUser
if err := binding.Bind(&in, binding.JSONBody(ctx.Req.Body)); err != nil {
return err // the framework renders the 422 problem+json
}
return ctx.JSON(in)
} Zero-copy and the request body
JSONBody does not retain the slice it is given. ctx.Req.Body points into
a pooled read buffer reused by the next request on that connection, so a
source that kept it would hand the next request's bytes to this one's
struct. The decoded struct is yours โ string fields are copies. Only the
input slice is borrowed, and only for the duration of the Bind call.