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 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.
// 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 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 field takes the first value.
Types the binder does not know
Implement encoding.TextUnmarshaler and the binder accepts your type. The error your
method returns is the issue the client sees, so 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 errors.New("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.
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.
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/json, anything ending in+json, and a missingContent-Typeare accepted. Anything else is415.
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 the wrong type reports has the wrong type, a string is not accepted here.
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 into a scratch value and
// copies out the body-bound fields alone, so a crafted body cannot write to ID.
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 scratch value of the same type and only the body-bound fields are copied out, so a
request carrying {"id": "somebody-elses-item", "name": "x"} cannot overwrite the path
parameter.
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 while it is being read, so the server never buffers more
than the limit.
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 negative value removes the limit, which is only appropriate behind a proxy that imposes
its own. Routes that bind form or file fields use MaxUploadSize instead, which
defaults to 32 mebibytes.
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
ctx.LookupPath("item_id") // value and whether the template declares it
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 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.