69. Middleware

📖 Reading · 16 min
💡 Most code boxes below are live — edit one and hit Run. Boxes without a Run button are reference-only (they can't run in your browser).

Every handler in a real service needs the same handful of things done around it: log the request, check the caller is allowed in, don't let a panic kill the process. Copying that into forty handlers is how services rot.

Middleware is the alternative, and in Go it needs no framework — just the http.Handler interface and a closure.

A middleware is a handler that wraps a handler

package main

import (
    "fmt"
    "net/http"
    "net/http/httptest"
)

func logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Printf("--> %s %s\n", r.Method, r.URL.Path)
        next.ServeHTTP(w, r)
        fmt.Printf("<-- %s %s done\n", r.Method, r.URL.Path)
    })
}

func main() {
    var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "the real work")
    })

    h = logging(h)

    rec := httptest.NewRecorder()
    h.ServeHTTP(rec, httptest.NewRequest("GET", "/items", nil))
    fmt.Println("body:", rec.Body.String())
}
--> GET /items
<-- GET /items done
body: the real work

That signature is the whole convention:

func(next http.Handler) http.Handler

Take a handler, return a new one that does something extra and calls the original. Because the wrapper is a Handler, the thing you wrapped can't tell, and neither can whatever you hand the result to. That's the closure work from module 3 and the interface work from module 6 meeting at a right angle.

The three positions available to you:

  • before next.ServeHTTP — inspect or reject the request
  • after — observe the result
  • instead — don't call next at all, and the inner handler never runs

Rejecting is just returning early without calling next.

Seeing the status code

The obvious logging middleware can't log what it most wants to: the status. By the time next.ServeHTTP returns, the code has gone to the client and the ResponseWriter won't tell you what it was. So wrap that too:

package main

import (
    "fmt"
    "net/http"
    "net/http/httptest"
)

type statusWriter struct {
    http.ResponseWriter
    status int
}

func (s *statusWriter) WriteHeader(code int) {
    s.status = code
    s.ResponseWriter.WriteHeader(code)
}

func logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
        next.ServeHTTP(sw, r)
        fmt.Printf("%s %s -> %d\n", r.Method, r.URL.Path, sw.status)
    })
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /ok", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "fine")
    })
    mux.HandleFunc("GET /missing", func(w http.ResponseWriter, r *http.Request) {
        http.Error(w, "nope", http.StatusNotFound)
    })

    h := logging(mux)
    for _, p := range []string{"/ok", "/missing"} {
        h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", p, nil))
    }
}
GET /ok -> 200
GET /missing -> 404

statusWriter embeds http.ResponseWriter — the composition from module 5. It gets every method of the interface for free, and overrides exactly one. Anything it doesn't define falls through to the wrapped writer.

Two details that are easy to get wrong:

  • The default must be 200. A handler that never calls WriteHeader still sends 200 implicitly, so a zero-valued status would log 0 for every successful request.
  • WriteHeader needs a pointer receiver to record anything. On a value receiver you'd mutate a copy and always log the default.

Notice we wrapped the whole mux, not each route — a mux is a handler, so one wrap covers every route registered on it.

Chaining, and which end is which

package main

import (
    "fmt"
    "net/http"
    "net/http/httptest"
)

func tag(name string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            fmt.Println("enter", name)
            next.ServeHTTP(w, r)
            fmt.Println("exit ", name)
        })
    }
}

func chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
    for i := len(mw) - 1; i >= 0; i-- {
        h = mw[i](h)
    }
    return h
}

func main() {
    handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Println("      handler")
    })

    h := chain(handler, tag("outer"), tag("middle"), tag("inner"))
    h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
}
enter outer
enter middle
enter inner
      handler
exit  inner
exit  middle
exit  outer

Middleware nests like an onion: first in the list is outermost, sees the request first and the response last. chain builds it backwards — wrapping the last entry first leaves the first entry on the outside, which is what makes the argument order read the way you'd say it out loud.

Order is a correctness question, not a style one. Recovery has to be outermost or it can't catch panics from the middleware inside it. Authentication belongs before anything expensive. Logging usually goes near the outside so it measures everything.

Passing values down the chain

Middleware often learns something the handler needs — who the caller is, a request ID. It can't pass an argument, since the signature is fixed. It attaches the value to the request's context instead:

package main

import (
    "context"
    "fmt"
    "net/http"
    "net/http/httptest"
)

type ctxKey string

const userKey ctxKey = "user"

func authenticate(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if token != "Bearer secret" {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        ctx := context.WithValue(r.Context(), userKey, "ada")
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func whoami(w http.ResponseWriter, r *http.Request) {
    user, ok := r.Context().Value(userKey).(string)
    if !ok {
        http.Error(w, "no user in context", http.StatusInternalServerError)
        return
    }
    fmt.Fprintf(w, "you are %s", user)
}

func main() {
    h := authenticate(http.HandlerFunc(whoami))

    for _, token := range []string{"Bearer secret", "Bearer wrong"} {
        req := httptest.NewRequest("GET", "/me", nil)
        req.Header.Set("Authorization", token)
        rec := httptest.NewRecorder()
        h.ServeHTTP(rec, req)
        fmt.Printf("%-14s %d %q\n", token, rec.Code, rec.Body.String())
    }
}
Bearer secret  200 "you are ada"
Bearer wrong   401 "unauthorized\n"

The rejection path returns without calling next, so whoami never runs.

ctxKey is a defined type, not a plain string, and that matters. Context keys are compared by type as well as value, so an unexported ctxKey("user") cannot collide with a "user" key set by a library you imported. Using a bare string here is a real bug waiting to happen, and go vet will tell you so.

Read the value back with the comma-ok assertion from module 6. Value returns any, and it returns nil when the key is absent — so a bare .(string) would panic on any request that skipped the middleware.

Keep context values for request-scoped data that middleware produced. It is not a general-purpose bag for passing arguments: the compiler can't check what's in there, so anything you can pass as a normal function parameter should be.

Recovering from panics

A panic in a handler doesn't just fail one request. Left alone it unwinds through the server goroutine, and module 7's rule applies — an unrecovered panic takes the whole process with it. One bad request would end the service for everyone.

package main

import (
    "fmt"
    "net/http"
    "net/http/httptest"
)

func recovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                fmt.Println("recovered from:", err)
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

func main() {
    boom := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        panic("database connection lost")
    })

    h := recovery(boom)
    rec := httptest.NewRecorder()
    h.ServeHTTP(rec, httptest.NewRequest("GET", "/boom", nil))
    fmt.Printf("status %d body %q\n", rec.Code, rec.Body.String())

    ok := recovery(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "no panic here")
    }))
    rec2 := httptest.NewRecorder()
    ok.ServeHTTP(rec2, httptest.NewRequest("GET", "/fine", nil))
    fmt.Printf("status %d body %q\n", rec2.Code, rec2.Body.String())
}
24:3: panic: main.main.func(...)
recovered from: database connection lost
status 500 body "internal server error\n"
status 200 body "no panic here"

That first line is the in-browser interpreter announcing the panic it saw; compiled Go doesn't print it, and you can ignore it. Everything below it is what real Go produces: the recover() returned exactly the value passed to panic, the client got a clean 500 instead of a dropped connection, and the next request was unaffected.

defer runs on the way out of the wrapper whether or not anything panicked, which is the only reason this works — the deferred closure is already scheduled before next.ServeHTTP is ever called.

Two rules for real recovery middleware. Log the panic, with a stack trace (runtime/debug.Stack()) — a swallowed panic is worse than a crash, because the bug is now invisible. And never put the panic value in the response; it routinely contains internal detail. The client gets a generic 500, your logs get everything.

Note that net/http's own server already recovers panics per connection, so a panicking handler will not usually kill the process. Write this middleware anyway: the built-in behaviour drops the connection with no response at all, which the client sees as a broken pipe rather than a 500.

Your turn

Write a requireAPIKey middleware that lets a request through only if the X-API-Key header is exactly letmein, and otherwise responds 403 with the body forbidden — without running the wrapped handler.

package main

import (
    "fmt"
    "net/http"
    "net/http/httptest"
)

// requireAPIKey should let the request through only when
// the X-API-Key header is "letmein", else 403 "forbidden".
func requireAPIKey(next http.Handler) http.Handler {
    return nil
}

func main() {
    secret := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "the secret")
    })

    h := requireAPIKey(secret)
    for _, key := range []string{"letmein", "guess", ""} {
        req := httptest.NewRequest("GET", "/secret", nil)
        if key != "" {
            req.Header.Set("X-API-Key", key)
        }
        rec := httptest.NewRecorder()
        h.ServeHTTP(rec, req)
        fmt.Printf("key=%-9q %d %q\n", key, rec.Code, rec.Body.String())
    }
}
package main

import (
    "fmt"
    "net/http"
    "net/http/httptest"
)

func requireAPIKey(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Header.Get("X-API-Key") != "letmein" {
            http.Error(w, "forbidden", http.StatusForbidden)
            return
        }
        next.ServeHTTP(w, r)
    })
}

func main() {
    secret := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "the secret")
    })

    h := requireAPIKey(secret)
    for _, key := range []string{"letmein", "guess", ""} {
        req := httptest.NewRequest("GET", "/secret", nil)
        if key != "" {
            req.Header.Set("X-API-Key", key)
        }
        rec := httptest.NewRecorder()
        h.ServeHTTP(rec, req)
        fmt.Printf("key=%-9q %d %q\n", key, rec.Code, rec.Body.String())
    }
}

Next: those fmt.Printf log lines are not what you want in production.