Zero dependencies
Nothing in go.mod but Go itself. Nothing to audit,
nothing to upgrade in a hurry, no transitive surprise arriving
through a package you chose for something else.
An open-source library from uRadical · v1.1.0
Go 1.27 · MIT · zero dependencies
Path parameters, query strings, JSON, form and multipart bodies, cookies and headers — onto one struct, in one call. binder does that and nothing else, in one file with nothing in its go.mod but Go itself.
go get uradical.io/go/binder// ...in every handler, again
id := r.PathValue("id")
n, err := strconv.Atoi(id)
if err != nil { /* 400 */ }
name := r.URL.Query().Get("name")
var body RequestBody
if err := json.NewDecoder(r.Body).
Decode(&body); err != nil {
/* 400 */
}
trace := r.Header.Get("X-Request-ID")
// ...and a body someone forgot to cap
type Req struct {
ID int `path:"id"`
Name string `query:"name"`
Body RequestBody `body:"data"`
Trace string `header:"X-Request-ID"`
}
var req Req
if err := binder.Bind(r, &req); err != nil {
http.Error(w, err.Error(),
http.StatusBadRequest)
return
}
// every field filled, body capped,
// one error path
binder only binds. It does not validate, log, route or transform. That refusal is what keeps it small enough to read in one sitting and cheap enough to leave in the hot path.
Nothing in go.mod but Go itself. Nothing to audit,
nothing to upgrade in a hurry, no transitive surprise arriving
through a package you chose for something else.
One file, about 730 lines of code. When binding does something you did not expect, the answer is a short read away rather than three layers into a framework.
105 ns to bind a path parameter, 1.3 µs for a full mixed request. Tags are reflected over once per type, not once per request, and the query string is parsed only if a field asks for it. That is level with the framework binders, and about 1.8× the cost of writing the extraction out by hand.
A field's tag says where its value comes from, and that is the whole model. No conventions inferred from names, no ambient configuration, no behaviour that changes with what else is imported.
Works with your validator, your logger, your router, your framework.
Implement Validate() error and your own checks run as
part of the same call.
The request body is restored after reading, so binding does not consume it. Middleware further down the chain still sees what the client sent.
Each field names where its value comes from. When a field carries more than one tag the first in this order wins, so precedence is something you can read off the page rather than discover in production.
pathPath parameters, read through net/http's own PathValue. binder does not route; it reads what your mux already resolved.
ID int `path:"id"`
queryThe URL query string, parsed once per call and only when a field asks for it. Repeated parameters fill a slice.
Tags []string `query:"tag"`
bodyJSON or form-encoded, chosen by Content-Type — including the +json suffix types. One struct serves an API call and an HTML form post.
Email string `body:"email"`
jsonThe body again, for types already tagged for encoding/json. Prefer body for anything that needs binder's options.
Email string `json:"email"`
cookieRequest cookies by name. A cookie that was not sent leaves the field at its zero value, unless it is tagged required.
Token string `cookie:"session"`
headerRequest headers, matched case-insensitively, so the tag can spell one however it reads best. Repeated headers fill a slice.
Auth string `header:"Authorization"`
A query string has no types; the field decides what its value means.
Numbers, booleans and floats parse, slices fill, pointers tell
“absent” from “sent as zero”, nested structs bind from nested JSON,
and anything implementing encoding.TextUnmarshaler —
including time.Time, net.IP and
uuid.UUID — decides for itself.
A value that will not convert is an error naming the field, the source and the key, not a zero quietly left in place.
type Search struct {
Q string `query:"q,required"`
Page int `query:"page,omitempty"`
Tags []string `query:"tag"`
Since time.Time `query:"since"`
Cursor *string `query:"cursor"`
}
var req = Search{Page: 1} // omitempty keeps the default
err := binder.Bind(r, &req)Binding is where untrusted input first meets your types, so the failure modes matter more than the feature list. Each of these exists because the quiet version of it costs someone an afternoon.
Bodies are capped at 10 MB by default, enforced while reading rather
than trusted from Content-Length. An oversized body is
rejected whole — never truncated, so a handler never sees half a
request.
A body that declares JSON and is not JSON is
ErrMalformedBody, not a struct full of zero values and
a cheerful 200.
An unusable target or an unsettable field is reported as an error. Nothing binder does turns a strange request into a stack trace in your request path.
ErrInvalidTarget means the handler passed something
unbindable and deserves a 500; every other failure is the client's
and deserves a 400. The distinction is in the error, not in the
message text.
Anything that concerns one field arrives as a
*BindError carrying the Go field name, the source it
was read from and the key looked up there — enough to write a useful
400 without guessing which of eleven fields the client got wrong.
Everything else carries a sentinel to match with
errors.Is. Message text is deliberately not part of the
API.
var bindErr *binder.BindError
if errors.As(err, &bindErr) {
log.Printf("field %s from %s %q: %v",
bindErr.Field, bindErr.Source,
bindErr.Name, bindErr)
}
switch {
case errors.Is(err, binder.ErrInvalidTarget):
// our bug: 500
case errors.Is(err, binder.ErrBodyTooLarge):
// 413
case err != nil:
// 400
}The same request bound into the same shape of struct, by each library in turn. Nanoseconds per bind, lower is better, shared scale across the three panels.
Requires Go 1.27 or newer, for native path values and the standard
library uuid package.
go get uradical.io/go/bindertype CreateComment struct {
PostID int `path:"id"`
Author string `body:"author,required"`
Text string `body:"text,required"`
}var req CreateComment
if err := binder.Bind(r, &req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}ErrInvalidTarget a 500 — it is your bug, not theirs.
The documentation covers all six sources, type conversion, options, errors, validation and the compatibility contract — or start with the quick start.