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

HeaderSet byValue
X-Request-IdRequestIDThe identifier assigned to the request
X-Content-Type-OptionsSecurityHeadersnosniff
X-Frame-OptionsSecurityHeadersDENY
Referrer-PolicySecurityHeadersstrict-origin-when-cross-origin
Content-Type, Content-Lengththe response encoderFor the body it wrote
Allowthe routerOn an automatic OPTIONS and on a 405
VaryCompressAccept-Encoding, on every response
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 · sustained by the people who ship on it.Built on net/http, and nothing else.