Headers
A header is bound like any other parameter. Tag a field with header:"Name" and it is read
from the request, converted to the field's type, and documented.
type ClientHeaders struct {
// Host is the authority the client addressed, which is what absolute URLs
// in the response are built from.
Host string `header:"Host" doc:"The authority the request was addressed to"`
// SaveData is the client's data saver preference. It is the string "on"
// when set, not a boolean, so it is bound as one.
SaveData string `header:"Save-Data" default:"off" doc:"Set to on by a client asking for a smaller payload"`
// Traceparent carries the caller's trace context, echoed back so a client
// can correlate its span with this response.
Traceparent string `header:"traceparent" doc:"W3C trace context of the calling span"`
// Tags filter the feed. The header may be repeated or written as a
// comma-separated list, and every value is bound in the order it arrived.
Tags []string `header:"X-Tag" doc:"Restrict the feed to these tags; may be repeated"`
}
Names are matched case-insensitively, as HTTP requires, so traceparent and Traceparent
name the same header.
A header is optional unless the field says required:"true", and a default supplies the
value when the client sends nothing.
type PagingHeaders struct {
APIVersion string `header:"X-API-Version" required:"true" doc:"The API version this client was written against"`
PageSize int `header:"X-Page-Size" default:"50"`
}
Repeated headers
Declare a slice, and every value arrives in the order it was sent. A header bound to a slice is split on its commas as well as across its lines, because RFC 9110 says a list written as several lines and one written as a comma-separated line mean the same thing. The spaces around each element are dropped, an empty element is skipped, and a comma inside a quoted string, such as an entity tag, stays part of its element.
curl 'http://localhost:8080/feed' -H 'X-Tag: go' -H 'X-Tag: http'
curl 'http://localhost:8080/feed' -H 'X-Tag: go, http'
in.Tags // []string{"go", "http"}, either way
A []string header whose values contain commas therefore gets several elements; declare a
string field to read the line whole. A non-slice header sent on more than one line is a
422, must be given only once, rather than one of its values being chosen, since a proxy in
front may have read a different one.
Headers that are not plain text
A header whose format is not one Go already parses gets a type of its own. Implementing
encoding.TextUnmarshaler is all it takes.
// 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"`
}
An *HTTPError returned from the method is the issue the client sees, so phrase its message to
read after the field name. Any other error reads is not in the expected format, because a
parser's own error is no fit for a client:
{ "field": "If-Modified-Since", "location": "header",
"issue": "must be an HTTP date, such as Wed, 21 Oct 2026 07:28:00 GMT" }
Validating a header
Rules bind to the field, not to the location, so a header is checked exactly as a query parameter is and its failure is reported against the header name the client sent.
func (in *FeedIn) Validate(v *muzak.Validation) {
v.String(&in.SaveData).Trim().Lower().OneOf("on", "off")
v.String(&in.Traceparent).Matches(`^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$`).
Message("must be a W3C trace context, such as 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01")
v.Slice(&in.Tags).MaxItems(5).Each(validate.String().MaxLen(24))
}
Transforms run before the checks, which is what lets Save-Data be compared against a
single spelling in the handler afterwards.
Reading a header directly
ctx.Header("X-Request-Id") // first value, or "" when absent
ctx.Request().Header.Values("X-Tag") // every value, through net/http
Prefer binding. A bound header is typed, documented, validated and reported the same way every other parameter is; a header read by hand is none of those things.
Setting response headers
func Feed(ctx *muzak.Context, in schemas.FeedIn) (schemas.FeedOut, error) {
// The caller's trace context is echoed so its span and this response can be
// tied together, and the request identifier is what ties it to our log.
if in.Traceparent != "" {
ctx.SetHeader("traceparent", in.Traceparent)
}
// The response varies by all three, so caches must be told. This adds to
// whatever the framework recorded for the response, such as Origin.
ctx.SetHeader("Vary", "Save-Data, X-Tag, Cookie")
ctx.SetHeader("Last-Modified", feedUpdatedAt.UTC().Format(http.TimeFormat))
// ...
}
Echoing traceparent only hands the caller's value back. Tracing itself is
AppOptions.Tracing, which parses the header, continues the caller's trace and needs no field
for it; see Observability.
SetHeader replaces any value already present. AddHeader appends, which is what repeated
headers require:
ctx.AddHeader("Vary", "Accept-Encoding")
ctx.AddHeader("Link", `</items?page=2>; rel="next"`)
Headers must be set before the handler returns. Once the response has begun, net/http
ignores further changes, which is the same rule that governs
middleware.
Conditional responses
func Feed(ctx *muzak.Context, in schemas.FeedIn) (schemas.FeedOut, error) {
// A conditional request is answered without a body when nothing changed.
// The header was parsed into a time by the binder, so this is a comparison
// rather than a parse that could fail here.
if !in.IfModifiedSince.IsZero() && !feedUpdatedAt.After(in.IfModifiedSince.Time) {
ctx.SetStatus(http.StatusNotModified)
return schemas.FeedOut{}, nil
}
// ...
}
304 writes no body at all, whatever the handler returned. Document the outcome so it
appears in the generated reference, with the model that says the response is empty:
r.Get("/feed", handlers.Feed,
muzak.WithResponseModel[muzak.Empty](http.StatusNotModified, "The feed has not changed since If-Modified-Since"))
WithResponseDoc would have described that status as the error envelope, which is right
for an outcome a handler reports by returning an error and wrong for this one. See
Response Models.
Headers Muzak sets for you
| Header | Set by | Value |
|---|---|---|
X-Request-Id | RequestID | The identifier assigned to the request |
X-Content-Type-Options | SecurityHeaders | nosniff |
X-Frame-Options | SecurityHeaders | DENY |
Referrer-Policy | SecurityHeaders | strict-origin-when-cross-origin |
Strict-Transport-Security | SecurityHeaders | max-age=31536000, on a response to a request that arrived over TLS, not for localhost or an address |
Content-Type, Content-Length | the response encoder | For the body it wrote |
Allow | the router | On an automatic OPTIONS and on a 405 |
Vary | Compress, the CORS policy, Locale, versioning, sessions | Accept-Encoding; Origin (and the preflight request headers on OPTIONS) under a named-origin policy; the header or cookie that chose the language; the version header or Accept; Cookie when the handler read its session. A handler's own Vary adds to these |
Cache-Control | the router, file mounts and sessions | private, no-cache behind a guard or a request-scoped provider, or when the handler read its session, unless the handler set one, and on a response carrying the session cookie in place of any value that would let a shared cache keep it. no-store on an error |
Retry-After, RateLimit-* | the rate limiter | On a refusal, and on every counted request |
SecurityHeaders never overwrites a value already set, so a handler or a later middleware
can opt out per response.
// This one page is allowed to be framed by the parent site.
ctx.SetHeader("X-Frame-Options", "SAMEORIGIN")
Turn the whole set off with AppOptions.DisableSecurityHeaders if something in front of
the service sets them instead.
Forwarding headers
X-Forwarded-For and its relatives are not believed by default, because a header any
client can write is an identity any client can claim. Naming the proxy is what makes it
believable. See Behind a Proxy.
Where to go next
Cookies covers the other half of what a browser
sends, and Compression covers the Vary header
that goes with it.