๐ŸŒฌ๏ธ Breeze docs

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:

SourceStruct tagReads from
JSONBody(body []byte)jsonthe request body
Query(url.Values)form, then querythe query string
Form(url.Values)form, then querya parsed form body
Path(map[string]string)paramroute 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:

RuleArgumentPasses when
requiredโ€”the field is not its zero value
min=Nnumbera number is >= N; a string, slice or map has len >= N
max=Nnumbera 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 cspace-separatedthe 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.

Generated documentation for the Breeze framework ยท built with SvelteKit, fully static.