HTTP middleware in Go, when done right, isn’t just about adding checks; it’s about composing independent, single-purpose functions that build up your request pipeline.
Let’s see it in action with a simple net/http server and then with the Chi router, which offers a more structured approach.
First, a basic net/http example. Imagine we want to log every incoming request and then, if a specific header is present, add another header to the response.
package main
import (
"fmt"
"log"
"net/http"
"time"
)
// loggingMiddleware logs the incoming request details.
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
log.Printf("Request: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
// Call the next handler in the chain
next.ServeHTTP(w, r)
log.Printf("Response processed in %s", time.Since(start))
})
}
// customHeaderMiddleware adds a custom header if a specific request header is found.
func customHeaderMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Add-Custom-Header") == "true" {
w.Header().Set("X-My-Custom-Header", "Added-By-Middleware")
}
next.ServeHTTP(w, r)
})
}
func main() {
// Our actual handler
helloHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("Hello, World!"))
})
// Chain the middleware
finalHandler := loggingMiddleware(customHeaderMiddleware(helloHandler))
http.Handle("/", finalHandler)
fmt.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
When you send a GET / request to http://localhost:8080, you’ll see logs like:
2023/10/27 10:00:00 Request: GET / from 127.0.0.1:54321
2023/10/27 10:00:00 Response processed in 123.456µs
If you send GET / with the header X-Add-Custom-Header: true, the response will include X-My-Custom-Header: Added-By-Middleware.
The core idea here is that middleware functions wrap an http.Handler and return a new http.Handler. The next http.Handler argument is crucial; it’s the next piece of logic in the chain, whether that’s another middleware or the final request handler. You must call next.ServeHTTP(w, r) to ensure the request continues its journey.
Now, let’s look at Chi, a popular router that makes this pattern much cleaner.
package main
import (
"fmt"
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
r := chi.NewRouter()
// Use Chi's built-in middleware first
r.Use(middleware.Logger) // Equivalent to our loggingMiddleware
r.Use(middleware.Recoverer) // Catches panics and returns 500
// Our custom middleware
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Add-Custom-Header") == "true" {
w.Header().Set("X-My-Custom-Header", "Added-By-Chi-Middleware")
}
next.ServeHTTP(w, r)
})
})
// Define routes
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello from Chi!"))
})
fmt.Println("Chi server starting on :8081")
log.Fatal(http.ListenAndServe(":8081", r))
}
With Chi, r.Use() registers middleware that will run for all subsequent routes defined on that router. You can also apply middleware to specific route groups. The middleware.Logger and middleware.Recoverer are provided by Chi, showcasing its ecosystem.
The mental model is a pipeline. Incoming requests enter at one end and flow through each middleware function. Each function has the opportunity to:
- Inspect the request (
r *http.Request). - Modify the request (though this is less common and often discouraged for clarity).
- Short-circuit the request (e.g., return an error immediately without calling
next.ServeHTTP). - Call
next.ServeHTTP(w, r)to pass the request along to the next handler/middleware. - Inspect or modify the
http.ResponseWriter(w http.ResponseWriter) afternext.ServeHTTPreturns, allowing for post-processing of the response (like modifying headers, logging response times, etc.).
One common pattern is to use a context.Context to pass request-scoped values between middleware and handlers. For example, you might extract a user ID from a JWT in an early middleware and then make that ID available to your handler via r.Context().Value("userID"). This avoids polluting the http.Request object with custom fields.
The next step in mastering Go’s HTTP handling is understanding how to properly manage context.Context cancellation and deadlines across your middleware and handlers, especially in distributed systems.