Request Data
A handler's input type is the request. Its fields carry struct tags that say where each value is read from, and the framework compiles that description into a binding plan once, when the route is registered. The per-request path walks a list of precompiled setters and never inspects a type.
type ItemParams struct {
ID string `path:"item_id" doc:"The item to operate on"`
}
r.Get("/items/{item_id}", func(ctx *muzak.Context, in ItemParams) (ItemOut, error) {
return ItemOut{ID: in.ID}, nil
})
A route that reads nothing declares muzak.Empty, and binding is skipped entirely for
it.
Where a field comes from
| Tag | Source | Example |
|---|---|---|
path:"name" | A {name} parameter in the route template | ID string `path:"item_id"` |
query:"name" | The query string | Limit int `query:"limit"` |
header:"Name" | A request header, matched case-insensitively | Host string `header:"Host"` |
cookie:"name" | A cookie sent by the client | SessionID string `cookie:"session_id"` |
form:"name" | A form value in a multipart or urlencoded body | Username string `form:"username"` |
file:"name" | A file in a multipart body | File muzak.File `file:"file"` |
| none of the above | The JSON request body | Name string `json:"name"` |
a muzak.Dep[T] field | The route's provider of T, never the request | User muzak.Dep[CurrentUser] |
A field with no location tag is a member of the JSON body, and its json tag names the
member. That is the rule the whole binder rests on: you never say "this route takes a
body", you declare fields that have nowhere else to come from.
The one exception is a muzak.Dep[T] field, which receives a value a provider resolved. It
is never read from the request: it is not a body member (a body naming it is refused as an
unknown member, or ignored under AllowUnknownFields), it cannot carry a location tag, and it is left out of the OpenAPI document.
See Dependencies.
// Every field here comes from the body, because none of them carries a
// location tag.
type ItemCreateIn struct {
ID string `json:"id" doc:"The identifier to create the item under"`
Name string `json:"name" doc:"The item's display name"`
Async bool `json:"async,omitzero" doc:"Queue the work instead of doing it inline"`
}
The doc tag is the field's description in the generated OpenAPI document. It works on
parameters and on body members alike.
Types that can be bound
A parameter arrives as text, so the binder converts it. These types work anywhere a
path, query, header, cookie or form tag appears:
stringbool, parsed withstrconv.ParseBool, sotrue,1,t,false,0andfall work- every signed and unsigned integer width, and
float32/float64, written as JSON writes them: digits with an optional-, and for a float an optional fraction and exponent. Leading zeros are allowed. Hexadecimal,1_000,.5and a leading+are refused, so a proxy or a firewall reading the number by JSON's rules sees the value the handler does.NaN,InfandInfinityare refused (must be a valid number), because they pass comparisons such asamount > balance. A value outside the type's range names the range:?n=300for anint8readsmust be between -128 and 127 time.Duration, written the way Go writes it:1500ms,2s,1h30m- any type implementing
encoding.TextUnmarshaler, which coverstime.Time(RFC 3339) anduuid.UUID - a slice of any of the above, filled from a repeated parameter
- a pointer to any of the above, which is what makes a field optional
Anything else is a build-time error naming the field, rather than a request that fails mysteriously later.
type FilterIn struct {
Since time.Time `query:"since" doc:"Only entries after this moment, as RFC 3339"`
Within time.Duration `query:"within" default:"24h" doc:"How far back to look"`
Tags []string `query:"tag" doc:"May be repeated"`
OwnerID uuid.UUID `query:"owner_id" doc:"Restrict to one owner"`
}
curl 'http://localhost:8080/entries?tag=go&tag=http&within=6h'
A repeated parameter fills a slice in the order the values arrived. A non-slice query, header
or form parameter sent more than once is a 422, must be given only once: taking the first
value, as the binder used to, while a proxy in front takes the last, lets a request pass a check
with one value and be served with another. A cookie still takes the first value.
A []byte parameter is a slice of numbers like any other: each value is one element, so
?data=65 is []byte{65}, and ?data=aGk= is a 422. A []byte in a JSON body is base64, as
encoding/json has it.
A rejected entry of a repeated parameter is named by its position (entry 2 must be a valid integer) and not quoted back, since the value is the client's own text and quoting it turned
every rejection into an amplifier.
Types the binder does not know
Implement encoding.TextUnmarshaler and the binder accepts your type. A failing
UnmarshalText reads is not in the expected format to the client, because the standard
library's own types write errors that are no fit for one (time.Time names its layout and
quotes the whole input back). To send your own wording, return an *HTTPError, whose
message is written for the client by contract, and phrase it to read after the field name.
// HTTPDate is a time carried in the format HTTP dates use.
//
// It exists because If-Modified-Since is not RFC 3339, which is what a bare
// time.Time parses.
type HTTPDate struct {
time.Time
}
// UnmarshalText parses an HTTP date, as RFC 9110 defines it.
func (d *HTTPDate) UnmarshalText(text []byte) error {
parsed, err := http.ParseTime(string(text))
if err != nil {
return muzak.NewHTTPError(http.StatusUnprocessableEntity,
"must be an HTTP date, such as Wed, 21 Oct 2026 07:28:00 GMT")
}
d.Time = parsed
return nil
}
type ClientHeaders struct {
IfModifiedSince HTTPDate `header:"If-Modified-Since" doc:"Answer 304 when nothing changed since this time"`
}
Required, optional and defaults
The rule differs by location, because the locations differ in what absence means.
| Location | Required by default | How to change it |
|---|---|---|
path | Always. The route matched, so the value exists | Not applicable |
query, header, cookie | No | required:"true" |
form | Yes, unless the field carries a default | required:"false" |
file | Yes | required:"false" |
| JSON body | The body itself is required whenever any field binds it | Enforce individual members with validation rules |
type UserListQuery struct {
// Optional, defaulting to 20 when the client sends nothing.
Limit int `query:"limit" default:"20" doc:"Maximum number of users to return"`
// Optional, and empty when absent.
Cursor string `query:"cursor" doc:"Opaque cursor from a previous page"`
}
type SessionCookies struct {
// Required: the request is refused with 422 when the cookie is missing.
SessionID string `cookie:"session_id" required:"true" doc:"The reader's session"`
}
A field that is both required:"true" and given a default is a build error, because the
two contradict each other.
default also applies to a member of a JSON body. A top-level member, or one promoted from an
embedded struct, that the client omits takes the default before any rule runs, and one it sends
wins. A default that does not parse as its field's type is a build error, and a member whose
type cannot be written from text (a struct, a map) has no default. The generated document
writes a default as the JSON type of the field (10, not "10"), and only where something
applies it: not on a member of a nested model, and not on a response type. Because the default
is written before the rules run, a Required() rule on a member that has one is not a failure
and does not make the document list it as required.
Absent against zero
A pointer field stays nil when the client sent nothing, which is how an absent value is
told from a zero one.
type WSItemIn struct {
ItemID string `path:"item_id" doc:"The item being talked about"`
// Stays nil when the client did not send one.
Q *int `query:"q" doc:"An optional number echoed back with each reply"`
}
if in.Q != nil {
fmt.Fprintf(w, "q is %d", *in.Q)
}
Context.LookupQuery and Context.LookupPath answer the same question without a
pointer, distinguishing ?token= from a missing token.
Sharing groups of fields
Embedding a struct promotes its fields onto the input, so a set of parameters several routes share is declared once.
// ClientHeaders groups the request headers every read endpoint cares about.
type ClientHeaders struct {
Host string `header:"Host" doc:"The authority the request was addressed to"`
SaveData string `header:"Save-Data" default:"off" doc:"Set to on by a client asking for a smaller payload"`
IfModifiedSince HTTPDate `header:"If-Modified-Since" doc:"Answer 304 when nothing changed since this time"`
Traceparent string `header:"traceparent" doc:"W3C trace context of the calling span"`
Tags []string `header:"X-Tag" doc:"Restrict the feed to these tags; may be repeated"`
}
// SessionCookies groups the cookies the browser sends back.
type SessionCookies struct {
SessionID string `cookie:"session_id" required:"true" doc:"The reader's session"`
FatebookTracker string `cookie:"fatebook_tracker" doc:"Third party analytics cookie, if the reader accepted one"`
GoogallTracker string `cookie:"googall_tracker" doc:"Third party analytics cookie, if the reader accepted one"`
}
// FeedIn is two shared groups plus the one parameter this route owns.
type FeedIn struct {
ClientHeaders
SessionCookies
Limit int `query:"limit" default:"20" doc:"How many entries to return"`
}
The handler then reads in.SaveData, in.SessionID and in.Limit as if they were
declared on FeedIn itself, and the names, docs and defaults live in exactly one place.
Tags the binder must be able to reach
A location tag is read at the top level of the input and in structs embedded by value.
A path, query, header, cookie, form or file tag anywhere the binder cannot reach
is a build error: inside an embedded pointer, a named nested struct, or a slice, map or
pointer of one, and on an unexported field. It used to be ignored, so the field silently
became JSON body content and a request could set, for example, the user identifier a gateway
puts in a header.
type Bad struct {
Auth struct {
UserID string `header:"X-User-Id"` // build error: not reachable from here
}
}
Embed the struct by value, move the field to the top level of the input, or export it.
A located field is never a member of the JSON body, whatever its json tag says: a body naming
it is refused as an unknown member, or ignored under AllowUnknownFields. json:"-" on a
located field only keeps it out of the JSON body and the schema. The header or query value, its
default and its required check are all still applied.
The JSON body
Bodies are decoded with encoding/json/v2, and the defaults are strict rather than
forgiving.
- Unknown members are rejected. A client's typo becomes an immediate
422naming the member instead of a value that silently disappears. - Duplicate members are rejected.
- Invalid UTF-8 is rejected.
- A body is required when any field binds it. An empty body reports
{"field": "", "location": "body", "issue": "is required"}. - The media type is checked.
application/jsonand anything ending in+jsonare accepted. Anything else is415, and so is a body sent with noContent-Typeat all. Accepting an untyped body let any page drive a JSON route cross-site without the CORS preflight thatapplication/jsontriggers, by posting an untypedBlob. A request with no body and noContent-Typeis unaffected. SendContent-Type: application/jsonwith every JSON body.
curl -X POST http://localhost:8080/items/ \
-H 'Content-Type: application/json' \
-d '{"id":"foo","nmae":"Foo"}'
{
"error": {
"code": "validation_error",
"message": "The request could not be validated.",
"status": 422,
"details": [
{ "field": "nmae", "location": "body", "issue": "is not a field this endpoint accepts" }
]
},
"request_id": "0611f4b2-2f0a-4b57-9c1a-6e6a2e2f9b31"
}
A member of a kind its field cannot hold at all, a string for an integer, reports
has the wrong type, a string is not accepted here. One of the right kind that does not fit
is described as a parameter of the same type would be: 300 for an int8 reads
must be between -128 and 127, 1.5 for an int reads must be a valid integer, and a time
that does not parse reads is not in the expected format. The field is the member's full path,
addr.zip or items[1].zip, as a validation failure names it.
A time.Duration member is a string such as "1500ms", both in a request and in a response,
as the generated document says.
Where forward compatibility with clients that send extra members matters more than catching typos, relax it for the route or the router:
r.Post("/webhooks/stripe", handlers.StripeWebhook, muzak.AllowUnknownFields())
Duplicate members and invalid UTF-8 stay rejected regardless.
Mixing a body with parameters
An input may take some fields from the request line and others from the body.
// The body can only ever reach Name: binding decodes it into a struct of the
// body members alone, so a crafted body cannot write to ID, or even name it.
type ItemRenameIn struct {
ID string `path:"item_id" doc:"The item to rename"`
Name string `json:"name" doc:"The item's new display name"`
}
That protection is not advisory. When an input has any located field, the body is decoded into
a struct of the body members alone, built when the route is registered, and those are copied
into the input. A request carrying {"ID": "somebody-elses-item", "name": "x"} cannot overwrite
the path parameter, and the ID member is an unknown member like any other: a 422, rather
than a value silently dropped, or ignored under AllowUnknownFields.
Form and file fields exclude a JSON body
An input that binds form or file fields reads a form body, so it cannot also declare
JSON members. A field with no location tag on such an input is a build error that tells
you to tag it with form or move it to the path, query, header or cookie. See
Forms and HTML and
File Uploads.
Body size limits
Request bodies are capped at muzak.DefaultMaxBodySize, one mebibyte, and a body over
the limit is refused with 413, so the server never buffers more than the limit. A body that
declares a Content-Length over the limit is refused before any of it is read, for JSON,
urlencoded and multipart bodies alike. That matters for a client that sent
Expect: 100-continue, which would otherwise have been told to go ahead and only refused
after the body had been read up to the limit. A body that declares no length, as a chunked one
does not, is still bounded as it is read.
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
MaxBodySize: 4 << 20, // the application-wide default
})
r.Post("/documents/", handlers.CreateDocument,
muzak.MaxBodySize(16<<20)) // just this route
A limit that is not positive removes it, which is only appropriate behind a proxy that
imposes its own. A route that declares a file field uses MaxUploadSize instead, which
defaults to 32 mebibytes. A route that binds only form values takes its urlencoded and
its multipart body under MaxBodySize: a login form held under the 32 MiB upload limit made
net/http keep up to 20 MiB of text values (about 49 MiB of heap per request in flight)
before any rule could run. A form-only route that legitimately receives more than a mebibyte
must raise MaxBodySize for it. See File Uploads.
The body as it arrived
Binding gives you the decoded value. A request whose authentication covers its bytes needs the bytes, and by the time a handler runs they are gone: the binder read them to decode the input.
A route whose input binds nothing from the body (muzak.Empty, or only path, query, header and
cookie fields) leaves the body to the handler, which may read ctx.Request().Body itself: a
proxy, an upload streamed to disk, or a signature check over the raw bytes. What the handler
leaves unread is dropped after it returns, up to 4 KiB, which is still what lets a keep-alive
connection be reused when the body was not read. The drain used to run before the handler,
so a handler that read the body itself lost its first 4 KiB without any error. A route that binds
a body is unchanged, an event stream route is treated like one that binds none, and a WebSocket
handshake still drains first: it is refused if it carries a body, and nothing may be left to be
taken for frames.
A guard or a provider runs before the handler, and before binding, so it cannot read the body
without taking it from the handler. CaptureBody keeps the bytes for both.
r.Post("/webhooks/stripe", handlers.Stripe,
muzak.CaptureBody(),
muzak.Needs(core.VerifyStripeSignature))
func VerifyStripeSignature(ctx *muzak.Context) (Verified, error) {
body, ok := ctx.RawBody()
if !ok {
// The route did not declare CaptureBody. Refuse rather than verify
// against nothing.
return Verified{}, muzak.NewHTTPError(500, "this route does not capture its body")
}
mac := hmac.New(sha256.New, secret)
mac.Write(body)
if !hmac.Equal(mac.Sum(nil), signature) {
return Verified{}, muzak.Unauthorized("the signature does not match")
}
return Verified{}, nil
}
This is not a niche case. Stripe, GitHub, Slack and Apple's SKAdNetwork postbacks all sign the raw body, and a signature must be checked against the original. Verifying a re-encoding verifies a different document: a decoder that reorders members, normalises a number or drops insignificant whitespace produces bytes the client never signed, so every signature fails and the reason looks like a bug in the client's signing code.
Four things worth knowing:
- It runs before the dependencies, so a guard or a provider sees the bytes and an unsigned request is refused before anything decodes it. It runs after the rate limit is counted, so a client past its budget never causes the server to buffer anything.
- It composes with binding. The handler still receives its decoded input; the bytes are in addition to it, not instead of it.
- The size limit is the route's own.
MaxBodySizealready bounds the body and there is no second knob. A body over it is refused with413rather than truncated, because a truncated body fails its signature check and that reads as an attack rather than as the oversized request it is. - The second return value matters. A helper shared between a route that captures and one that does not would otherwise verify a signature against an empty body and pass.
It is a shared option, so a router of webhook receivers declares it once:
r := muzak.NewRouter(muzak.CaptureBody(), muzak.WithTags("webhooks"))
Do not reach for it application-wide. Buffering costs memory for every request in flight,
and a route with nothing to verify has nothing to gain. The bytes live as long as the
handler does and are released with the pooled Context, so anything that outlives the
request has to copy them.
Reading the request directly
Binding covers the typed case. When you need the raw request, the context has it.
func Handler(ctx *muzak.Context, _ muzak.Empty) (Out, error) {
ctx.Query("token") // first value, or ""
ctx.LookupQuery("token") // value and whether it was present at all
ctx.QueryValues("tag") // every value, in order
ctx.Header("X-Request-Id") // first value, matched case-insensitively
ctx.Cookie("session_id") // (*http.Cookie, error)
ctx.PathValue("item_id") // captured path parameter, percent-decoded once
ctx.LookupPath("item_id") // value and whether the template declares it
ctx.RawBody() // the body as it arrived, with CaptureBody
ctx.Request() // the underlying *http.Request
ctx.ClientIP() // the address the request is attributed to
ctx.RequestID() // the identifier assigned to this request
ctx.Route() // the route being executed
ctx.Logger() // the request-annotated logger
ctx.Context() // the request's context.Context
return Out{}, nil
}
A path parameter is percent-decoded once, so /files/a%2F..%2Fb binds a/../b: the value can
hold /, .. and a NUL. Validate one before using it as a file name.
ctx.Context() is cancelled when the client disconnects or the request ends. Starting a
shutdown does not cancel it: an ordinary request is left to finish within
ShutdownTimeout, and only when that deadline passes are its connection closed and its context
cancelled. A handler that waits on the context alone therefore holds the shutdown for the whole
timeout. A long-lived response should be an
event stream or a WebSocket
route, whose own contexts and connections are ended when shutdown begins.
A query parameter and a body member with the same name are two fields. A query value that failed to parse no longer hides the failure of a body member called the same, and the other way round.
A Context is pooled and reused across requests. It must not be retained or used after
the handler returns; pass ctx.Context() to anything that outlives the handler.
Where to go next
Validation turns a bound value into a checked one, and Responses covers what happens to what you return. Each location has a page of its own for the details: Headers, Cookies, JSON, Forms and HTML and File Uploads. Typed Endpoints runs the same binding plan the other way: a Go client writes an input value as the request this binder reads back.