Muzak logomuzak
v0.1.10

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

TagSourceExample
path:"name"A {name} parameter in the route templateID string `path:"item_id"`
query:"name"The query stringLimit int `query:"limit"`
header:"Name"A request header, matched case-insensitivelyHost string `header:"Host"`
cookie:"name"A cookie sent by the clientSessionID string `cookie:"session_id"`
form:"name"A form value in a multipart or urlencoded bodyUsername string `form:"username"`
file:"name"A file in a multipart bodyFile muzak.File `file:"file"`
none of the aboveThe JSON request bodyName 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:

  • string
  • bool, parsed with strconv.ParseBool, so true, 1, t, false, 0 and f all 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 covers time.Time (RFC 3339) and uuid.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.

LocationRequired by defaultHow to change it
pathAlways. The route matched, so the value existsNot applicable
query, header, cookieNorequired:"true"
formYes, unless the field carries a defaultrequired:"false"
fileYesrequired:"false"
JSON bodyThe body itself is required whenever any field binds itEnforce 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 422 naming 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 missing Content-Type are accepted. Anything else is 415.
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.

Open source under MIT / Apache-2.0 · sustained by the people who ship on it.Built on net/http, and nothing else.