Advanced HTTP Routing in Go 1.22: Beyond the Basics
Since Go 1.22 was released, the net/http
package is now all you need for advanced HTTP routing. But knowing how to use the HTTP ServeMux type can be rather elusive, especially for advanced features such as middleware, subrouting, path parameters, HTTP methods and passing through context.
In this comprehensive guide, we're going to look at how to implement each of these features using only the Go standard library.
Path Parameters
The first implementation we're going to look at is path parameters. To add a path parameter to a route is pretty similar to other frameworks, such as Gorilla Mux or Chi, and involves wrapping the path component you want to parametize in braces with the name of the parameter inside.
package main
import (
"fmt"
"net/http"
)
func main() {
mux := http.NewServeMux()
// Define a route with a path parameter
mux.HandleFunc("/item/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "Item ID: %s", id)
})
http.ListenAndServe(":8080", mux)
}
In this example, we've added the path parameter of id
to our /item
path. With our parameter defined, we can then pull this out inside of our handler by using the PathValue
method of the request type.
If we run this code and send the following requests, our endpoint is now returning the last path component to us, which is what's being captured in the id
path parameter:
curl http://localhost:8080/item/123
# Returns: Item ID: 123
curl http://localhost:8080/item/abc
# Returns: Item ID: abc
Important Version Requirement
One thing to note, however, is that in order to have access to path parameters, you'll need to make sure you're using Go 1.22 and that you have Go 1.22 specified in your go.mod
file. Any earlier versions won't have access to this feature.
// go.mod
module your-project
go 1.22
Path Conflicts and Precedence
Whilst setting up path parameters is rather simple, there is one caveat to be aware of: conflicting paths and precedence. For example, here I have two routes that conflict with one another:
mux.HandleFunc("/item/{id}", handleItem)
mux.HandleFunc("/item/latest", handleLatest)
Despite this, however, if I send a request to the path ending in /latest
, it'll still be routed to the correct handler, even though both of the registered paths match. This works because Go determines which path is correct based on a precedence ordering of most specific wins. In our case, that's the path that ends in /latest
.
In rare cases, however, it's difficult for Go to determine which is the more specific path. Take the following two paths as an example:
mux.HandleFunc("/posts/{category}", handleCategory)
mux.HandleFunc("/posts/{id}", handlePost)
If I sent a request to /posts/latest
, which one would resolve? In this case, they're both as specific as each other, each having one path parameter. If we try and run this code, however, Go will detect the conflict and panic when we try to register our paths. Ultimately, this is a good thing, as it prevents any requests being routed to the wrong handler.
HTTP Method Routing
The next feature for advanced HTTP routing is the ability to easily handle different HTTP methods. Before version 1.22, this was done by having to perform a check on the request inside of the HTTP handler. Whilst it worked, it was pretty tedious.
Now, however, it's pretty easy. All we have to do is define the method at the start of the matcher string:
func main() {
mux := http.NewServeMux()
// Method-specific routing
mux.HandleFunc("POST /monsters", createMonster)
mux.HandleFunc("GET /monsters/{id}", getMonster)
mux.HandleFunc("PUT /monsters/{id}", updateMonster)
mux.HandleFunc("DELETE /monsters/{id}", deleteMonster)
http.ListenAndServe(":8080", mux)
}
func createMonster(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Creating a new monster")
}
func getMonster(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "Getting monster with ID: %s", id)
}
By adding the POST method to the start of the path, the createMonster
handler will only be invoked for requests that contain a POST method. Method-based routing can also be set up for the other HTTP methods, such as PUT, GET, DELETE, PATCH and OPTIONS.
Method Fallback Behavior
If a path has no explicit method defined, then it will handle any methods that haven't been explicitly defined for that path. For example:
mux.HandleFunc("PUT /monsters/{id}", updateMonster)
mux.HandleFunc("/monsters/{id}", handleAnyOtherMethod)
Here I have two entries to the /monsters/{id}
endpoint. The first is set up to explicitly handle a PUT request. However, the second has no explicit method defined, and therefore will be routed to for any HTTP method that isn't a PUT. In this example, you can see the handler is being called for GET, POST, DELETE and even PATCH.
To limit an endpoint to a specific method, you'll need to explicitly define it:
mux.HandleFunc("GET /monsters/{id}", getMonster)
// Now only GET requests will work, others will return "Method Not Allowed"
Important Notes
- When defining a method for your path, it requires a single space after it. Anything more than a single space will cause the route to no longer match.
- As was the case with path parameters, method-based routing also requires Go 1.22 to be specified inside of the project's
go.mod
file. If an earlier version of Go is specified, then your expected endpoints will return a 404.
Hostname-Based Routing
The next advanced routing feature is to perform handling based on a hostname, rather than just a path. We can achieve this by passing in the host domain that we want the router to handle:
func main() {
mux := http.NewServeMux()
// Hostname-specific routing
mux.HandleFunc("dreamsofcode.foo/api/monsters", handleMonsters)
http.ListenAndServe(":8080", mux)
}
In this case, I'm setting this to be dreamsofcode.foo
which will handle any requests sent to that host. We can test this locally with curl by passing in the host header:
curl -H "Host: dreamsofcode.foo" http://localhost:8080/api/monsters
Middleware Implementation
The next feature is actually the one that was most requested: middleware, and how to add it with http.ServeMux
. On initial thoughts it may seem that this feature is lacking, but this is where the beauty of the net/http
package really shines.
Let's look at how to do this by first adding a simple logging middleware:
package middleware
import (
"log"
"net/http"
"time"
)
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Println(r.Method, r.URL.Path, time.Since(start))
})
}
To create middleware, we first create a new function that accepts an http.Handler
as its parameter and returns an http.Handler
as its result. The http.Handler
type is actually an interface in the standard library, which describes any type that has the function ServeHTTP
. These are the building blocks of HTTP routing when it comes to Go.
Inside of our middleware function, we can then return a new http.HandlerFunc
. This type allows us to easily wrap a closure, conforming it to the http.Handler
interface.
Now we need to add it into our routing stack:
package main
import (
"net/http"
"your-project/middleware"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /monsters", getMonsters)
mux.HandleFunc("POST /monsters", createMonster)
// Wrap the entire router with logging middleware
wrappedMux := middleware.Logging(mux)
http.ListenAndServe(":8080", wrappedMux)
}
As the http.ServeMux
router conforms to the http.Handler
interface, we can parse it as the argument to our middleware function. This essentially creates a new router that is wrapped in the logging middleware.
Enhanced Logging with Status Codes
One improvement we can make is to also log the HTTP status of the response as well. However, if we try to do this, we run into an issue. The http.ResponseWriter
type doesn't provide us a method to read the HTTP status code. Fortunately, because this is an interface, there is a way to expose it:
package middleware
import (
"log"
"net/http"
"time"
)
type WrappedWriter struct {
http.ResponseWriter
StatusCode int
}
func (w *WrappedWriter) WriteHeader(statusCode int) {
w.StatusCode = statusCode
w.ResponseWriter.WriteHeader(statusCode)
}
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &WrappedWriter{
ResponseWriter: w,
StatusCode: http.StatusOK,
}
next.ServeHTTP(wrapped, r)
log.Println(r.Method, r.URL.Path, wrapped.StatusCode, time.Since(start))
})
}
Inside of our middleware package, we create a new type called a WrappedWriter
, which itself extends an http.ResponseWriter
, but also contains a status code property. We then implement the WriteHeader
method in order to intercept and capture the given HTTP status code.
Middleware Chaining
When building API services, you'll often use multiple pieces of middleware in your stack. As this stack starts to grow, your code will look less like Go and more like Lisp:
// This gets unwieldy quickly
wrappedMux := middleware.CORS(middleware.Auth(middleware.Logging(mux)))
To tidy this up, we can use something called middleware chaining, which turns our code into something more readable:
package middleware
type Middleware func(http.Handler) http.Handler
func CreateStack(middlewares ...Middleware) Middleware {
return func(next http.Handler) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
next = middlewares[i](next)
}
return next
}
}
Now, if we head over to our main function, we can refactor our previously wrapped code using this CreateStack
function:
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /monsters", getMonsters)
mux.HandleFunc("POST /monsters", createMonster)
stack := middleware.CreateStack(
middleware.Logging,
middleware.CORS,
middleware.Auth,
)
wrappedMux := stack(mux)
http.ListenAndServe(":8080", wrappedMux)
}
Subrouting
The next feature to implement is subrouting, which enables us to split our routing logic across multiple routers. Let's start with this example, which is a simple router that has some paths already configured to perform CRUD operations on a monster resource:
func main() {
// Create a subrouter for monster operations
monsterRouter := http.NewServeMux()
monsterRouter.HandleFunc("GET /monsters", getMonsters)
monsterRouter.HandleFunc("POST /monsters", createMonster)
monsterRouter.HandleFunc("GET /monsters/{id}", getMonster)
monsterRouter.HandleFunc("PUT /monsters/{id}", updateMonster)
monsterRouter.HandleFunc("DELETE /monsters/{id}", deleteMonster)
// Main router with versioning
mainRouter := http.NewServeMux()
mainRouter.Handle("/v1/", http.StripPrefix("/v1", monsterRouter))
http.ListenAndServe(":8080", mainRouter)
}
Let's say we receive a ticket that specifies that this API should be versioned, under a /v1
path prefix. Instead of adding this prefix to each path manually, we can use another router to achieve this.
To do so, we call the Handle
function on our new router, passing in the /v1/
path prefix. In order for this to work, it needs to have the trailing slash as well. For the handler, we need to use the http.StripPrefix
function to remove the /v1
from the path before it's sent to our next router.
Now when we run this code and send curl requests to the /v1/monsters
endpoints:
curl http://localhost:8080/v1/monsters
curl -X POST http://localhost:8080/v1/monsters
curl http://localhost:8080/v1/monsters/123
We can see that our requests are being handled as expected.
Middleware with Subrouters
As well as nesting paths, subrouters are also useful when it comes to middleware. For example, we can have two different routers, one that's intended to be used by anybody, and the other containing routes that are restricted to admins only:
func main() {
// Public routes
publicRouter := http.NewServeMux()
publicRouter.HandleFunc("GET /monsters", getMonsters)
// Admin routes
adminRouter := http.NewServeMux()
adminRouter.HandleFunc("POST /monsters", createMonster)
adminRouter.HandleFunc("DELETE /monsters/{id}", deleteMonster)
// Main router
mainRouter := http.NewServeMux()
mainRouter.Handle("/", publicRouter)
mainRouter.Handle("/admin/", http.StripPrefix("/admin",
middleware.EnsureAdmin(adminRouter)))
http.ListenAndServe(":8080", mainRouter)
}
In order to require authorization to our admin routes, we can add the handler to our router, wrapping the admin routes in the EnsureAdmin
middleware. Our GET request doesn't require an admin credential, but our POST requests do.
Passing Data Through Context
The last feature we'll look at is how to pass data down through your routing stack. For example, let's say we have an isAuthenticated
middleware that will pull out and validate the user's information from an authorization header. Let's improve this middleware by making the user ID available to any downstream handlers.
We can achieve this by making use of the context
type from the context
package:
package middleware
import (
"context"
"net/http"
"strings"
)
type contextKey string
const UserIDKey contextKey = "userID"
func IsAuthenticated(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Authorization header required", http.StatusUnauthorized)
return
}
// Extract user ID from auth header (simplified example)
userID := strings.TrimPrefix(authHeader, "Bearer ")
// Add user ID to context
ctx := context.WithValue(r.Context(), UserIDKey, userID)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
Every HTTP request has an associated context, which we can easily extend and override. We first define a unique key that we can use to both set and get this value from our request's context. Then we call the WithValue
method of the context package, taking in the request's context, our new key, and our user ID value.
This method returns a new child context, which contains the key-value pair inside. We need to assign this new context value to our request using the WithContext
method, passing in our new context value.
Now we need to pull the user ID out of the context in the handler function:
func getMonster(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// Extract user ID from context
userID, ok := r.Context().Value(middleware.UserIDKey).(string)
if !ok {
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "Getting monster %s for user %s", id, userID)
}
Because context.Context
stores values in a type-unsafe manner, we need to perform a type cast. We can verify that this type cast was okay by capturing a second return value, which is a boolean describing whether or not the cast was successful.
Now if we run this code and send a curl request with an authorization header:
curl -H "Authorization: Bearer user123" http://localhost:8080/monsters/456
Our handler is able to access the user ID contained due to the middleware extracting the value.
Conclusion
With that, we've managed to implement advanced routing capabilities by only using the standard library. The Go 1.22 net/http
package now includes:
- Path parameters with automatic extraction
- Method-based routing with explicit HTTP verb handling
- Hostname-based routing for multi-tenant applications
- Flexible middleware patterns with easy chaining
- Subrouting capabilities for modular route organization
- Context passing for sharing data between middleware and handlers
For some things, you may still want to consider using a third-party package like Gorilla Mux, Chi, or Gin. However, if you decide you want to stick with the standard library, the net/http
package now has everything you need to build robust HTTP APIs.