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 untilSetStatuschanges it.- Codes outside 100 to 599 are clamped to 500.
- Calling
SetStatusafter the body has started writing has no effect and is logged at warn level, because the status line has by then been sent. AFlushcounts as starting the response even before the firstWrite, because it sends an implicit200. 204and304write 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.