Step 09 of 24

Routing & middleware

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

18 min 9 sections

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.

DiagramlogRequests(requireAuth(mux)), two requests
ClientLoggingAuthHandler1GET /products/72Start timer3next.ServeHTTP4Check token5next.ServeHTTP6200 + JSON7return8Log 2009200 OK10Second request: no valid token11GET /products/712next.ServeHTTP13401, no next14Log 40115401 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)
		})
	}
}

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.

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.
Show 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.

Check yourself

Quiz

0/4 correct · 4 left

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

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

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

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

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.