Responses

The second type parameter of a handler is the response body, serialized exactly as returned.

type ItemOut struct {
    ID    string `json:"id" doc:"The item's identifier"`
    Name  string `json:"name" doc:"The item's display name"`
    Owner string `json:"owner,omitzero" doc:"The user the item belongs to"`
}

r.Get("/items/{item_id}", func(ctx *muzak.Context, in ItemParams) (ItemOut, error) {
    return ItemOut{ID: in.ID, Name: "Foo"}, nil
})

There is no wrapper type, no response_model= and no filtering pass at run time. The handler's return type is the response model, which means a field you did not declare cannot leak: it is not part of the type, and the compiler is what tells you, not a bug report.

That is why response models are kept separate from storage types:

// Item is the store's own type, deliberately separate from the response
// models. A field added here does not become visible to clients until a
// handler puts it on a response type.
type Item struct {
    ID   string
    Name string
}

The body is encoded fully into a buffer before anything is written, so a failure part way through encoding still produces a clean error response instead of a truncated body. Content-Type: application/json; charset=utf-8 and Content-Length are set for you. A time.Duration member is written as a string such as "1500ms".

A failure after the response has started, whether a panic or an error returned once the first byte was written, cannot be turned into an error response. Muzak logs it once and aborts the connection, so the client sees a failed transfer and not a body that ends cleanly and looks complete. The access log marks the request aborted=true. See Error Handling.

Status codes

A status that never changes for a route is part of the route.

r.Post("/items/", handlers.CreateItem, muzak.Status(http.StatusCreated))

A status that depends on what happened is set in the handler.

func CreateItem(ctx *muzak.Context, in schemas.ItemCreateIn) (schemas.ItemOut, error) {
    store := muzak.From[*core.ItemStore](ctx)

    if err := store.Create(core.Item{ID: in.ID, Name: in.Name}); err != nil {
        return schemas.ItemOut{}, asHTTPError(err)
    }
    if in.Async {
        ctx.SetStatus(http.StatusAccepted)
    }
    return schemas.ItemOut{ID: in.ID, Name: in.Name}, nil
}

The two are orthogonal. You never have to choose between returning a value and setting a status, and you never declare something dynamic as if it were fixed. When both are used the imperative call wins.

Details worth knowing:

  • Without either, the status is 200.
  • ctx.Status() reports what will be written, which is the route's declared default until SetStatus changes it.
  • Codes outside 100 to 599 are clamped to 500.
  • Calling SetStatus after the body has started writing has no effect and is logged at warn level, because the status line has by then been sent. A Flush counts as starting the response even before the first Write, because it sends an implicit 200.
  • 204 and 304 write no body at all, whatever the handler returned.
func Feed(ctx *muzak.Context, in schemas.FeedIn) (schemas.FeedOut, error) {
    if !in.IfModifiedSince.IsZero() && !feedUpdatedAt.After(in.IfModifiedSince.Time) {
        ctx.SetStatus(http.StatusNotModified)
        return schemas.FeedOut{}, nil
    }
    // ...
}

Headers

ctx.SetHeader("Last-Modified", feedUpdatedAt.UTC().Format(http.TimeFormat))
ctx.SetHeader("Vary", "Save-Data, X-Tag, Cookie")
ctx.AddHeader("Vary", "Accept-Encoding")   // appends rather than replacing

SetHeader replaces any value already set; AddHeader appends, which is what repeated headers such as Set-Cookie and Vary require. Headers must be set before the handler returns: once the response has begun, net/http ignores further changes.

A handler's own Vary adds to the fields the framework recorded for the response; it does not erase them. CORS (Origin, and the preflight request headers), the locale middleware (Accept-Language, Cookie or the locale header, whichever sources are configured) and header or media-type versioning each record the field their answer depends on, and the fields are merged into Vary when the response is written. So a handler doing ordinary content negotiation with ctx.SetHeader("Vary", "Accept") no longer replaces a Vary: Origin and leaves a shared cache free to store a reflected Access-Control-Allow-Origin for every client.

Caching what is behind a guard

A route or a file mount behind a guard, behind a request-scoped provider (Needs), or behind a security scheme it enforces (one built by JWTBearer or APIKeyVerifier) is sent Cache-Control: private, no-cache, so a CDN cannot hand one user's response to another. An enforced scheme also adds to Vary the header its credential is read from: Authorization, or an API key's own header, or Cookie. A route keeps a Cache-Control its handler or a middleware set; a mount replaces one. A singleton alone does not count as a reason, and event streams and WebSocket upgrades are unchanged. A handler serving public data from behind such a route sets its own Cache-Control:

ctx.SetHeader("Cache-Control", "public, max-age=300")

A handler that reads its session is treated the same way: the response varies on Cookie and is sent private, no-cache unless the handler set its own Cache-Control. A response that carries the session cookie is the exception to that exception. It is kept out of shared caches whatever sets Cache-Control, before or after the cookie, the handler and the error renderer included, because a cache that stored it would hand one user's session to the next.

Cookies

ctx.SetCookie(&http.Cookie{
    Name:  "theme",
    Value: in.Theme,
    Path:  "/",
    // HttpOnly keeps the value out of reach of scripts, and SameSite keeps it
    // off cross-site requests. Secure belongs here too once this is served
    // over TLS, which is why it is named rather than omitted.
    HttpOnly: true,
    SameSite: http.SameSiteLaxMode,
    Secure:   false,
    MaxAge:   int((365 * 24 * time.Hour).Seconds()),
})

Muzak does not modify the cookie. Setting Secure, HttpOnly and SameSite appropriately is the caller's job, which is covered in Cookies.

Do not build a session out of a cookie like this one. AppOptions.Sessions gives every request a session through ctx.Session(), kept in an encrypted cookie or a server-side store, written once when the request succeeds, and given a new identity on sign-in with Regenerate. See Sessions.

Responses with no body

muzak.Empty is also usable as an output type. The generated document then describes no response content at all, which is the honest description of a route that answers with nothing.

r.Delete("/items/{item_id}", func(ctx *muzak.Context, in ItemParams) (muzak.Empty, error) {
    store := muzak.From[*core.ItemStore](ctx)
    if _, err := store.Get(in.ID); err != nil {
        return muzak.Empty{}, muzak.NotFound("Item not found")
    }
    return muzak.Empty{}, nil
}, muzak.Status(http.StatusNoContent))

Pair it with muzak.Status(http.StatusNoContent). At 200 an Empty value still encodes as {}, since that is what it serializes to; at 204 nothing is written.

HTML

A handler returning muzak.HTML bypasses JSON encoding entirely. The string is written verbatim under text/html, and the generated document describes the response as text/html rather than as a JSON schema.

func LoginForm(ctx *muzak.Context, _ muzak.Empty) (muzak.HTML, error) {
    return muzak.HTML(`<body>
<form action="/login/" method="post">
<label>Username <input name="username" autocomplete="username"></label>
<label>Password <input name="password" type="password" autocomplete="current-password"></label>
<input type="submit" value="Sign in">
</form>
</body>`), nil
}

Everything else about the route is unchanged, so its status, headers and errors work exactly as they do for a JSON route. The value is written as given: Muzak does not escape it, because it cannot tell markup the handler meant from text it did not. Interpolating anything a client supplied is the handler's job to escape, with html/template or html.EscapeString.

Documenting other outcomes

A handler that reports a failure through an error does not describe that outcome in its return type, so declare it at registration.

r.Get("/items/{item_id}", handlers.ReadItem,
    muzak.WithResponseDoc(http.StatusNotFound, "The item does not exist"))

WithResponseDoc is recorded in the OpenAPI document and has no effect at runtime. Applied to a router, it documents the response for every route beneath it, which is how app.Include(admin, muzak.WithResponseDoc(418, "I'm a teapot")) reads. The body is described as Muzak's error envelope, because that is what a returned error produces.

When the outcome carries a body of its own instead, name the model:

r.Post("/users/", handlers.CreateUser,
    muzak.Status(http.StatusCreated),
    muzak.WithResponseModel[schemas.ErrorOut](http.StatusBadRequest, "The request was malformed"))

One operation can carry a different schema at every status code that way. Response Models covers it in full, including inheritance, Empty and HTML models, and what reaches the document.

Files, streams and redirects

A body that is not JSON has an output type of its own: muzak.Bytes for a body held in memory, muzak.Stream for one copied to the client as it is read, muzak.FileResponse for a file, and muzak.Redirect for a redirect. A download is a FileResponse:

var reports, _ = os.OpenRoot("/var/lib/app/reports")

func Download(ctx *muzak.Context, in FileParams) (muzak.FileResponse, error) {
    return muzak.FileResponse{FS: reports.FS(), Name: in.Name, Download: true}, nil
}

The route keeps everything a JSON route has: its status follows Status and SetStatus, a returned error is rendered as usual, and the generated document describes the body. FileResponse answers ranges and conditional requests with http.ServeContent, answers a Name that would leave the filesystem or names a dotfile with 404, as it would a missing file, and sends X-Content-Type-Options: nosniff. Bytes and Stream take a ContentType, and all three take a Filename and Download to offer the body as a file to save. Files, Streams and Redirects covers the four types, Produces for documenting their media types, and AutoETag.

Writing the response yourself

For a body none of those types describes, such as one written by a library that takes an http.ResponseWriter, take the writer.

func Chart(ctx *muzak.Context, in ChartParams) (muzak.Empty, error) {
    ctx.SetHeader("Content-Type", "image/png")
    if err := charts.Render(ctx.ResponseWriter(), in.Series); err != nil {
        return muzak.Empty{}, err
    }
    return muzak.Empty{}, nil
}

Writing to ctx.ResponseWriter() bypasses Muzak's response encoding: the returned value is not serialized and SetStatus stops having an effect, so return the zero Out value with a nil error afterwards. The writer supports http.ResponseController, so flushing and hijacking work as usual. A handler that hijacks owns the connection from then on: nothing is written after it returns, a returned error is logged but not rendered, and the access log records status 101.

An informational response such as 103 Early Hints, written with ctx.ResponseWriter().WriteHeader(http.StatusEarlyHints) before the final response, no longer swallows the response that follows it.

For streaming a sequence of events rather than one body, reach for Server-Sent Events instead, which keeps the typed contract and the generated documentation.

Where to go next

Dependencies covers how services and resolved values reach a handler, Error Handling covers what happens when a handler returns an error, and JSON covers the encoding in detail. Typed Endpoints shows how a Go client calling the route gets the handler's response type back, decoded, instead of reading the JSON itself.

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