# Routing & middleware

> Parse path and query parameters safely, organise routes into groups, and compose middleware for logging, recovery and request context.

Backend with Go roadmap · Building APIs · 18 min read · https://levelcode.org/roadmaps/go-backend/routing-and-middleware

Once a service has more than a handful of endpoints, two questions take over: how to turn URLs into validated inputs, and how to apply cross-cutting behaviour (logging, panic recovery, request IDs) to every request without copying it into every handler. This lesson answers both using only the standard library: parsing path and query parameters, grouping routes, writing and chaining middleware, and passing request-scoped values through `context`.

## Path parameters: from strings to valid values

`r.PathValue("id")` always returns a string. Your handler must convert it and reject anything invalid before it reaches the database. Because this repeats in many handlers, give it a helper:

```go
// pathID parses a positive integer wildcard such as {id}.
func pathID(r *http.Request, name string) (int64, error) {
	id, err := strconv.ParseInt(r.PathValue(name), 10, 64)
	if err != nil || id < 1 {
		return 0, fmt.Errorf("%s must be a positive integer", name)
	}
	return id, nil
}

func (a *app) getProduct(w http.ResponseWriter, r *http.Request) {
	id, err := pathID(r, "id")
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	p, ok := a.products[id]
	if !ok {
		http.NotFound(w, r)
		return
	}
	fmt.Fprintf(w, "%d: %s\n", p.ID, p.Name)
}
```

Distinguish the two failures. `/products/abc` can never name a product, so it is a malformed request: `400`. `/products/999` is well-formed but absent: `404`. Some APIs return `404` for both to keep things simple; either is defensible as long as you are consistent.

Values come back already percent-decoded, so `/files/my%20notes.txt` matched by `GET /files/{name}` gives `"my notes.txt"`. If a wildcard ends up in a file path or a query, validate it as strictly as any other user input.

## Query strings

Query parameters carry optional modifiers: filters, sorting, pagination. `r.URL.Query()` parses them into `url.Values`, which is a `map[string][]string`:

```go
// GET /products?q=lamp&tag=desk&tag=office&debug
q := r.URL.Query()   // parses on every call, so call it once
search := q.Get("q") // "lamp"; "" if absent
tags := q["tag"]     // ["desk" "office"]: all values for a repeated key
debug := q.Has("debug")
```

Everything is a string, and every parameter is optional, so you need defaults and bounds. A small helper keeps handlers readable:

```go
func intQuery(q url.Values, key string, def, lo, hi int) (int, error) {
	s := q.Get(key)
	if s == "" {
		return def, nil
	}
	n, err := strconv.Atoi(s)
	if err != nil || n < lo || n > hi {
		return 0, fmt.Errorf("%s must be an integer between %d and %d", key, lo, hi)
	}
	return n, nil
}

limit, err := intQuery(q, "limit", 20, 1, 100)
```

Always cap values like `limit`: without a maximum, `?limit=10000000` asks your database for everything. When you build URLs, for example a "next page" link, let `url.Values` handle escaping: `url.Values{"q": {"go & web"}}.Encode()` produces `q=go+%26+web`.

## Organising routes

Keep all route registrations in one place, a `routes` method on your application struct, so anyone can see the whole API surface at a glance:

```go
func (a *app) routes() http.Handler {
	api := http.NewServeMux()
	api.HandleFunc("GET /products", a.listProducts)
	api.HandleFunc("GET /products/{id}", a.getProduct)

	root := http.NewServeMux()
	root.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "ok")
	})
	root.Handle("/api/v1/", http.StripPrefix("/api/v1", api))
	return root
}
```

The `api` mux is a **route group**. Its patterns are written relative to the group; `http.StripPrefix` removes `/api/v1` from the path before the inner mux sees it, so `/api/v1/products/7` matches `GET /products/{id}`. Because the group is a single `Handler`, you can also wrap the whole group in middleware, for example `root.Handle("/admin/", requireAdmin(http.StripPrefix("/admin", adminMux)))`. For small services, registering full paths on one mux is perfectly fine; reach for groups when a set of routes shares a prefix *and* behaviour.

## Middleware: wrapping handlers

Middleware is a function that takes a handler and returns a new handler that does something before and/or after calling the original:

```go
func serverHeader(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Server", "levelcode-demo") // before
		next.ServeHTTP(w, r)
		// after: next has finished writing the response
	})
}

handler := serverHeader(mux)
```

That is the whole pattern: `func(http.Handler) http.Handler`. Because the mux is a `Handler` and so is the result, middleware nests like layers of an onion. With `a(b(mux))`, a request passes through `a`, then `b`, then the mux and your handler; on the way out it unwinds through `b` and then `a`. Middleware can also **short-circuit** by writing a response and *not* calling `next`, which is how authentication or rate-limiting layers reject requests.

When middleware needs configuration, such as a logger, write a function that returns the middleware:

```go
func logRequests(logger *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			start := time.Now()
			rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
			next.ServeHTTP(rec, r)
			logger.Info("request",
				"method", r.Method,
				"path", r.URL.Path,
				"status", rec.status,
				"duration", time.Since(start),
				"request_id", RequestID(r.Context()),
			)
		})
	}
}
```

`http.ResponseWriter` has no way to read back the status a handler wrote, so the logger passes the handler a wrapper that remembers it:

```go
type statusRecorder struct {
	// Embedded, so Header and Write are promoted unchanged.
	http.ResponseWriter
	status int
}

func (rec *statusRecorder) WriteHeader(code int) {
	rec.status = code
	rec.ResponseWriter.WriteHeader(code)
}

// Unwrap lets http.ResponseController reach the original writer
// (for Flush, SetWriteDeadline and so on).
func (rec *statusRecorder) Unwrap() http.ResponseWriter { return rec.ResponseWriter }
```

The status starts as `200` because a handler that only calls `Write` never calls `WriteHeader` on the wrapper; the underlying writer sends `200` implicitly.

```text
time=2026-10-11T09:12:01.512Z level=INFO msg=request method=GET path=/api/v1/products/7 status=200 duration=84.1µs request_id=MOUQR7NFPAFKGD2WNPSDFA2MJ6
```

Put the logger outside an authentication middleware and you get the onion below. Step through both requests: the first passes every layer and unwinds back out; the second is rejected by the auth layer, which never calls `next`, yet the logger still records it because it wraps everything.

```sequence
title: logRequests(requireAuth(mux)), two requests
participants: Client, Logging, Auth, Handler
Client -> Logging: GET /products/7
Logging -> Logging: Start timer
> The outermost layer runs first. It records start := time.Now() and wraps the ResponseWriter in a statusRecorder before anything is written.
Logging -> Auth: next.ServeHTTP
> The logger calls into the next layer, passing rec instead of the original writer.
Auth -> Auth: Check token
Auth -> Handler: next.ServeHTTP
> Auth calls the next layer only after the check passes. It can also add the authenticated user to the request context here.
Handler --> Auth: 200 + JSON
> The handler writes its response through rec, whose WriteHeader records the status on the way to the real writer. Then it returns, and the call stack unwinds innermost layer first.
Auth --> Logging: return
Logging -> Logging: Log 200
> Code after next.ServeHTTP runs on the way out, so the logger sees the final status (rec.status) and time.Since(start).
Logging --> Client: 200 OK
> net/http finishes and flushes the response once the outermost handler returns.
note Client, Handler: Second request: no valid token
Client -> Logging: GET /products/7
Logging -> Auth: next.ServeHTTP
Auth --> Logging: 401, no next
> Short-circuit: the auth layer writes the response itself and returns. The handler never runs.
Logging -> Logging: Log 401
Logging --> Client: 401 Unauthorized
```

## Recovering from panics

If a handler panics, `net/http` recovers it so the process survives, but the client just sees the connection drop and the stack trace goes to the server's plain error log. A recovery middleware turns the panic into a proper `500` and a structured log entry:

```go
func recoverPanics(logger *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			defer func() {
				v := recover()
				if v == nil {
					return
				}
				if v == http.ErrAbortHandler { // deliberate abort: let net/http handle it
					panic(v)
				}
				logger.Error("panic", "value", v, "stack", string(debug.Stack()))
				w.Header().Set("Connection", "close")
				http.Error(w, "internal server error", http.StatusInternalServerError)
			}()
			next.ServeHTTP(w, r)
		})
	}
}
```

> [!WARNING]
> `recover` only catches panics in the **same goroutine**. If a handler starts its own goroutine and that goroutine panics, no middleware can save you: the whole process crashes. Recover inside such goroutines yourself.

## Chaining middleware

Nesting calls by hand, `a(b(c(mux)))`, gets hard to read. A tiny helper applies a list so that the **first** middleware listed is the **outermost**:

```go
type middleware = func(http.Handler) http.Handler

func chain(h http.Handler, mws ...middleware) http.Handler {
	for _, mw := range slices.Backward(mws) { // wrap from the last to the first
		h = mw(h)
	}
	return h
}

handler := chain(mux,
	withRequestID,         // 1st: runs first, so everything below sees the ID
	logRequests(logger),   // 2nd: logs the final status, including 500s
	recoverPanics(logger), // 3rd: closest to the handlers
)
```

Order is a design decision, not a detail. Here `withRequestID` must wrap the logger so the logger can read the ID, and `recoverPanics` must sit inside the logger so a recovered panic is logged with status `500`. Swap the last two and a panic would bypass the logging code entirely.

Middleware can also be applied to a **single route**, by wrapping just that handler when you register it: `api.Handle("POST /products", requireJSON(http.HandlerFunc(a.createProduct)))`.

## Request context: request-scoped values

Every request carries a `context.Context` from `r.Context()`. You met it as a cancellation signal: the server cancels it when the client disconnects, so pass it to database and outbound HTTP calls. It can also carry **request-scoped values** from middleware to handlers:

```go
type ctxKey int

const requestIDKey ctxKey = iota

func withRequestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := rand.Text() // crypto/rand, Go 1.24+: 26 random base32 characters
		w.Header().Set("X-Request-ID", id)
		ctx := context.WithValue(r.Context(), requestIDKey, id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// RequestID returns the ID stored by withRequestID, or "" if there is none.
func RequestID(ctx context.Context) string {
	id, _ := ctx.Value(requestIDKey).(string)
	return id
}
```

Three details make this robust. The key is an **unexported type**, so no other package can create an equal key and collide with yours. `r.WithContext` returns a **copy** of the request carrying the new context, which is passed on; the original request is untouched. And the typed getter hides the type assertion, so callers get a plain `string`.

> [!TIP]
> Use context values only for data that genuinely belongs to the request and crosses API boundaries: request IDs, the authenticated user, trace spans. Never use them to smuggle optional function parameters such as a database handle or a page size. Those belong in struct fields and function arguments, where the compiler can check them.

## Practice

Extend the product API:

1. Write `requireJSON` middleware that responds `415 Unsupported Media Type` unless the request's `Content-Type` is `application/json` (parameters like `; charset=utf-8` are allowed). Use `mime.ParseMediaType`.
2. Write `listProducts` for `GET /products`, supporting `?limit=` (default 20, range 1–100) and `?sort=` (`name` or `price`, default `name`). Invalid values get `400`. For now, just echo the parsed settings.
3. Register `POST /products` with `requireJSON` applied to that route only, mount the group under `/api/v1/`, and wrap everything with `withRequestID`, `logRequests` and `recoverPanics`.

**Solution**

This builds on `pathID`, `intQuery`, `chain`, `statusRecorder`, `logRequests`, `recoverPanics`, `withRequestID` and `RequestID` from the lesson.

```go
func requireJSON(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
		if err != nil || mt != "application/json" {
			http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType)
			return
		}
		next.ServeHTTP(w, r)
	})
}

func (a *app) listProducts(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	limit, err := intQuery(q, "limit", 20, 1, 100)
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	sort := cmp.Or(q.Get("sort"), "name") // first non-empty value
	if sort != "name" && sort != "price" {
		http.Error(w, "sort must be name or price", http.StatusBadRequest)
		return
	}
	fmt.Fprintf(w, "limit=%d sort=%s\n", limit, sort)
}

func (a *app) createProduct(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusCreated) // decoding the JSON body comes next lesson
}

func (a *app) routes() http.Handler {
	api := http.NewServeMux()
	api.HandleFunc("GET /products", a.listProducts)
	api.HandleFunc("GET /products/{id}", a.getProduct)
	api.Handle("POST /products", requireJSON(http.HandlerFunc(a.createProduct)))

	root := http.NewServeMux()
	root.Handle("/api/v1/", http.StripPrefix("/api/v1", api))

	return chain(root, withRequestID, logRequests(a.logger), recoverPanics(a.logger))
}

type product struct {
	ID   int64
	Name string
}

type app struct {
	logger   *slog.Logger
	products map[int64]product
}

func main() {
	a := &app{
		logger:   slog.New(slog.NewTextHandler(os.Stdout, nil)),
		products: map[int64]product{7: {ID: 7, Name: "Desk lamp"}},
	}
	srv := &http.Server{Addr: ":8080", Handler: a.routes(), ReadHeaderTimeout: 5 * time.Second}
	if err := srv.ListenAndServe(); err != nil {
		a.logger.Error("server stopped", "err", err)
		os.Exit(1)
	}
}
```

```text
$ curl 'localhost:8080/api/v1/products?limit=5&sort=price'
limit=5 sort=price
$ curl 'localhost:8080/api/v1/products?limit=500'
limit must be an integer between 1 and 100
$ curl -i -X POST -d '{}' localhost:8080/api/v1/products
HTTP/1.1 415 Unsupported Media Type
$ curl -i -X POST -H 'Content-Type: application/json' -d '{}' localhost:8080/api/v1/products
HTTP/1.1 201 Created
```

`curl -d` sends `application/x-www-form-urlencoded` by default, which is why the first `POST` is rejected. `cmp.Or` returns its first non-zero argument, a compact way to apply a default.

## Key takeaways

- Convert and validate every path and query parameter; use `400` for malformed input and `404` for well-formed IDs that do not exist.
- Give query parameters defaults and upper bounds, and let `url.Values` handle encoding.
- Middleware is `func(http.Handler) http.Handler`; it can act before and after `next`, or short-circuit by not calling it.
- Order matters: the first middleware in the chain runs first and finishes last.
- Wrap `ResponseWriter` to observe the status, and recover panics in middleware to return a clean `500`.
- Store request-scoped values in the context under an unexported key type, with a typed getter.

## Quiz

1. The server is started with handler := logRequests(recoverPanics(mux)). A handler panics. In what order does the code run?

   A. recoverPanics, then logRequests, then the handler
   B. logRequests, then recoverPanics, then the handler; recoverPanics writes a 500 and logRequests logs it
   C. The handler first, then both middleware in registration order
   D. logRequests never runs, because the panic stops the request

   **Answer: B.** The outermost wrapper runs first: logRequests calls recoverPanics, which calls the mux and handler. recoverPanics catches the panic and writes 500, control returns to logRequests, and it logs the 500.

2. Why use an unexported type such as type ctxKey int for context value keys instead of a plain string like "requestID"?

   A. context.WithValue only accepts integer keys
   B. Strings are slower to compare than integers
   C. Values stored under string keys are lost when the request is copied
   D. No other package can create a key of that type, so keys from different packages can never collide

   **Answer: D.** Context keys are compared by type and value. A private key type guarantees uniqueness; two packages that both use the string "requestID" would overwrite each other's values.

3. A route is registered as GET /products/{id} and a client requests /products/abc. What is the most accurate response?

   A. 400 Bad Request, because the ID is malformed and can never identify a product
   B. 500 Internal Server Error, because strconv.ParseInt failed
   C. 405 Method Not Allowed
   D. The mux rejects the request automatically before your handler runs

   **Answer: A.** The mux matches any segment, so r.PathValue("id") is "abc" and your handler must validate it. A parse failure is the client's fault, not the server's, so 400 fits (some APIs use 404 consistently instead). Never 500 for bad input.

4. Why does a logging middleware wrap http.ResponseWriter in a statusRecorder?

   A. To compress the response body
   B. Because handlers cannot write headers without it
   C. Because ResponseWriter has no method to read back the status code a handler sent, so the wrapper records it in WriteHeader
   D. To make the response writes thread-safe

   **Answer: C.** ResponseWriter only exposes Header, Write and WriteHeader. By embedding it and overriding WriteHeader, the wrapper sees the code on its way through. It defaults to 200 because a handler that only calls Write never calls WriteHeader explicitly.
