Middleware
Middleware in Muzak is an ordinary func(http.Handler) http.Handler, so anything written
for the standard library works unchanged.
type Middleware func(next http.Handler) http.Handler
It operates below the typed layer, on the raw net/http types. Everything typed happens
inside dispatch, at the end of the chain.
What is already installed
Four pieces of middleware are installed for you, in this order, outermost first.
| Middleware | What it does | Turn it off with |
|---|---|---|
RequestID | Assigns an identifier, records it in the request context, echoes it in X-Request-Id | Not disableable |
SecurityHeaders | Sets X-Content-Type-Options, X-Frame-Options and a referrer policy, never overwriting a value already set | DisableSecurityHeaders |
Recovery | Catches a panic, logs it with its stack, answers a generic 500 | Not disableable |
AccessLog | One line per request with method, path, status, duration, bytes and identifier | DisableAccessLog |
Anything you add with App.Use runs inside that chain, so it already has an identifier
available and is already covered by panic recovery. CORS, when configured, runs after
your middleware and before the documentation routes and the router.
RequestID → SecurityHeaders → Recovery → AccessLog → your middleware → CORS → /docs and /openapi.json → routes
Use installs in order, so the first one installed is the outermost. Calls made after the
application has been built have no effect, because the chain is assembled once.
Compression
app.Use(muzak.Compress(muzak.CompressionOptions{}))
One line. It negotiates gzip or deflate from Accept-Encoding, records Vary on every
response either way, and declines what compressing would not help. See
Compression for the options and the security
note that goes with it.
CORS
CORS is configured rather than installed, and the zero value denies everything.
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
CORS: muzak.CORSOptions{
AllowedOrigins: []string{"https://app.example.com"},
AllowCredentials: true,
MaxAge: 10 * time.Minute,
},
})
No CORS header is emitted until a policy names an origin or supplies an
AllowOriginFunc, and a wildcard origin combined with credentials is refused as a build
error rather than served. See CORS.
The access log
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
AccessLogOptions: muzak.AccessLogOptions{
Level: slog.LevelInfo,
SkipPaths: []string{"/healthz"},
},
})
14:32:10.114 INFO [Request] GET /items/ status=200 duration=412µs bytes=181 request_id=0611f4b2
Successful responses are logged at Level. Server errors are always logged at error level
and client errors at warn level, so a quiet production level still surfaces failures.
SkipPaths lists exact paths that produce no line, which keeps a health check polled
every second from drowning out real traffic.
Only fixed, non-sensitive fields are recorded. Query strings, request bodies and headers are deliberately omitted, because each of them routinely carries credentials or personal data that should not be duplicated into a log store.
Request identifiers
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
TrustRequestIDHeader: true,
})
By default an inbound X-Request-Id is ignored and a fresh UUID version 7 is generated,
because an attacker-controlled identifier is an attacker-controlled log field. With the
option on, an inbound value is honoured only if it parses as a UUID, so it can never carry
newlines or control characters into a log line.
Inside a handler the identifier is ctx.RequestID(). In code that has only a
context.Context, such as a repository or a client wrapper, it is
muzak.RequestIDFromContext(ctx).
Writing your own
// ProcessTime reports how long the service spent on a request in an
// X-Process-Time header, in seconds.
func ProcessTime() muzak.Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(&processTimer{ResponseWriter: w, start: time.Now()}, r)
})
}
}
// processTimer stamps the elapsed time onto the response as it starts.
type processTimer struct {
http.ResponseWriter
start time.Time
stamped bool
}
// WriteHeader records the duration and forwards the status.
func (w *processTimer) WriteHeader(status int) {
if !w.stamped {
w.stamped = true
elapsed := time.Since(w.start).Seconds()
w.Header().Set("X-Process-Time", strconv.FormatFloat(elapsed, 'f', 6, 64))
}
w.ResponseWriter.WriteHeader(status)
}
// Write stamps a response whose handler never set a status, which net/http
// treats as a 200.
func (w *processTimer) Write(b []byte) (int, error) {
if !w.stamped {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(b)
}
// Flush passes a flush through to whatever is underneath, so that installing
// this middleware cannot stop a handler from streaming.
func (w *processTimer) Flush() {
if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}
// Unwrap exposes the underlying writer to http.ResponseController.
func (w *processTimer) Unwrap() http.ResponseWriter { return w.ResponseWriter }
The one thing that fails quietly
The obvious shape for that middleware does not work in Go, and it fails without a word. In a framework where the response is an object in memory until it is handed back, you can write:
# FastAPI, for contrast. This works there and has no equivalent here.
response = await call_next(request)
response.headers["X-Process-Time"] = str(time.perf_counter() - start)
Go puts the header block on the wire at the first WriteHeader, so a header set after
the next handler returns is dropped silently. Middleware that reports something only known
at the end has to wrap the writer and fill the value in as the response starts, which is
what processTimer above does.
Two details make a wrapper well behaved:
- Implement
Writeas well asWriteHeader, because a handler that writes a body without setting a status never callsWriteHeaderitself. - Implement
Unwrap() http.ResponseWriter, sohttp.ResponseControllercan still reach the real writer. Without it, a wrapper breaks flushing, hijacking and the deadline handling that WebSockets and event streams depend on.
Middleware and the typed layer
Middleware runs before routing, so it cannot see the matched route, the bound input or the resolved dependencies. Anything that needs those belongs in a guard or a provider, which run per route and can refuse the request with a typed error. See Dependencies.
Where to go next
Lifecycle covers the resources a request depends on, and Compression covers the one piece of middleware most applications add.