Muzak logomuzak
v0.2.00
You are reading the 0.1.1 documentation. The current release is 0.2.0. Read this page for it →

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.

MiddlewareWhat it doesTurn it off with
RequestIDAssigns an identifier, records it in the request context, echoes it in X-Request-IdNot disableable
SecurityHeadersSets X-Content-Type-Options, X-Frame-Options and a referrer policy, never overwriting a value already setDisableSecurityHeaders
RecoveryCatches a panic, logs it with its stack, answers a generic 500Not disableable
AccessLogOne line per request with method, path, status, duration, bytes and identifierDisableAccessLog

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 Write as well as WriteHeader, because a handler that writes a body without setting a status never calls WriteHeader itself.
  • Implement Unwrap() http.ResponseWriter, so http.ResponseController can 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.

Open source under MIT / Apache-2.0 · sustained by the people who ship on it.Built on net/http, and nothing else.