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, 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 non-slice field takes the first value.
curl 'http://localhost:8080/feed' -H 'X-Tag: go' -H 'X-Tag: http'
in.Tags // []string{"go", "http"}
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 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"`
}
The error the method returns is the issue the client sees, so phrase it to read after the field name:
{ "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.
ctx.SetHeader("Vary", "Save-Data, X-Tag, Cookie")
ctx.SetHeader("Last-Modified", feedUpdatedAt.UTC().Format(http.TimeFormat))
// ...
}
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 |
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 | Accept-Encoding, on every response |
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.