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

HeaderSet byValue
X-Request-IdRequestIDThe identifier assigned to the request
X-Content-Type-OptionsSecurityHeadersnosniff
X-Frame-OptionsSecurityHeadersDENY
Referrer-PolicySecurityHeadersstrict-origin-when-cross-origin
Strict-Transport-SecuritySecurityHeadersmax-age=31536000, on a response to a request that arrived over TLS, not for localhost or an address
Content-Type, Content-Lengththe response encoderFor the body it wrote
Allowthe routerOn an automatic OPTIONS and on a 405
VaryCompress, the CORS policy, Locale, versioning, sessionsAccept-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-Controlthe router, file mounts and sessionsprivate, 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 limiterOn 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.

Open source under MIT / Apache-2.0.Built on net/http, and nothing else.