The standard library now has all you need for advanced routing in Go.

by Dreams of Code
Share:
The standard library now has all you need for advanced routing in Go.

Advanced HTTP Routing in Go: Everything You Need from the Standard Library

Since Go 1.22 was released, the net/http package has evolved into a comprehensive routing solution that eliminates the need for third-party dependencies. However, mastering advanced features like middleware, subrouting, path parameters, HTTP methods, and context passing can be challenging. In this article, we'll explore how to implement each of these features using only the Go standard library.

Path Parameters

Adding path parameters to routes is similar to other frameworks like Gorilla Mux or Chi. You simply wrap the path component you want to parameterize in braces with the parameter name inside.

go
// 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)
})

With our parameter defined, we can extract it inside our handler using the PathValue method of the request type. When you send requests to this endpoint, it will return the captured path component.

Important Notes About Path Parameters

Version Requirement: Path parameters require Go 1.22 and must be specified in your go.mod file. Earlier versions won't have access to this feature.

Conflicting Paths and Precedence: Be aware of conflicting paths. Go determines the correct path based on precedence ordering where "most specific wins."

go
// These routes work fine - Go knows /item/latest is more specific
mux.HandleFunc("/item/{id}", handleItem)
mux.HandleFunc("/item/latest", handleLatest)

// These routes will cause a panic - both are equally specific
mux.HandleFunc("/posts/{category}", handleCategory) // ❌ Conflicts
mux.HandleFunc("/posts/{id}", handlePost)           // ❌ Conflicts

Go will detect conflicts and panic when registering conflicting paths, which prevents requests from being routed to the wrong handler.

Method-Based Routing

Before version 1.22, handling different HTTP methods required checking the request method inside the HTTP handler. Now it's much simpler - just define the method at the start of the matcher string.

go
// Handle POST requests only
mux.HandleFunc("POST /monster", createMonsterHandler)

// Handle other HTTP methods
mux.HandleFunc("PUT /monster/{id}", updateMonsterHandler)
mux.HandleFunc("GET /monster/{id}", getMonsterHandler)
mux.HandleFunc("DELETE /monster/{id}", deleteMonsterHandler)

Method Routing Rules

  • If a path has no explicit method defined, it handles any methods that haven't been explicitly defined for that path
  • To limit an endpoint to specific methods, you must explicitly define them
  • Method definitions require a single space after the method name
  • Like path parameters, method-based routing requires Go 1.22
go
// This handles PATCH requests specifically
mux.HandleFunc("PATCH /monster/{id}", patchMonsterHandler)

// This handles any method that isn't PATCH for the same path
mux.HandleFunc("/monster/{id}", genericMonsterHandler)

Host-Based Routing

You can perform routing based on hostname rather than just path by specifying the host domain in your route definition.

go
// Handle requests for a specific host
mux.HandleFunc("dreamsofcode.foo/api/monsters", handleMonsters)

You can test this locally with curl by passing in the host header:

bash
curl -H "Host: dreamsofcode.foo" http://localhost:8080/api/monsters

For production use, you'll want to set up proper DNS records and SSL certificates. The example uses a .foo domain from Porkbun with Let's Encrypt SSL certificates.

Middleware Implementation

Middleware might seem lacking at first glance, but this is where the beauty of the net/http package really shines. Let's implement a simple logging middleware:

go
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.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
    })
}

The http.Handler type is an interface that describes any type with a ServeHTTP method. Our middleware function accepts an http.Handler and returns an http.Handler, allowing us to wrap the original handler with additional functionality.

Capturing Response Status

To also log the HTTP status code, we need to create a wrapper for the response writer:

go
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.Printf("%s %s %d %v", r.Method, r.URL.Path, wrapped.StatusCode, time.Since(start))
    })
}

Middleware Chaining

As your middleware stack grows, your code can become unwieldy. Middleware chaining helps organize multiple middleware functions:

go
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 you can use it like this:

go
// Instead of this nested mess:
// handler = middleware.Logging(middleware.Auth(middleware.CORS(router)))

// Use this clean syntax:
stack := middleware.CreateStack(
    middleware.Logging,
    middleware.Auth,
    middleware.CORS,
)

handler = stack(router)

Subrouting

Subrouting enables you to split routing logic across multiple routers. This is particularly useful for API versioning and organizing routes by functionality.

API Versioning Example

go
// Create a main router
mainRouter := http.NewServeMux()

// Create a V1 API router
v1Router := http.NewServeMux()
v1Router.HandleFunc("GET /monsters", getMonsters)
v1Router.HandleFunc("POST /monsters", createMonster)
v1Router.HandleFunc("GET /monsters/{id}", getMonster)

// Mount the V1 router under the /v1 prefix
mainRouter.Handle("/v1/", http.StripPrefix("/v1", v1Router))

Middleware with Subrouters

Subrouters are also useful for applying different middleware to different route groups:

go
// Public routes (no authentication required)
publicRouter := http.NewServeMux()
publicRouter.HandleFunc("GET /monsters", getMonsters)

// Admin routes (authentication required)
adminRouter := http.NewServeMux()
adminRouter.HandleFunc("POST /monsters", createMonster)
adminRouter.HandleFunc("DELETE /monsters/{id}", deleteMonster)

// Apply middleware only to admin routes
mainRouter.Handle("/", publicRouter)
mainRouter.Handle("/admin/", middleware.EnsureAdmin(
    http.StripPrefix("/admin", adminRouter),
))

Context for Passing Data

The context package allows you to pass data down through your routing stack. This is particularly useful for passing user information from authentication middleware to handlers.

Setting Context Values in Middleware

go
package middleware

import (
    "context"
    "net/http"
)

type contextKey string

const UserIDKey contextKey = "userID"

func IsAuthenticated(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Extract user ID from authorization header (simplified)
        authHeader := r.Header.Get("Authorization")
        userID := extractUserID(authHeader) // Your auth logic here

        // Add user ID to context
        ctx := context.WithValue(r.Context(), UserIDKey, userID)
        r = r.WithContext(ctx)

        next.ServeHTTP(w, r)
    })
}

Retrieving Context Values in Handlers

go
func handleProtectedResource(w http.ResponseWriter, r *http.Request) {
    userID, ok := r.Context().Value(middleware.UserIDKey).(string)
    if !ok {
        http.Error(w, "Unauthorized", http.StatusUnauthorized)
        return
    }

    fmt.Fprintf(w, "Hello, user %s!", userID)
}

Key Takeaways

The Go 1.22 standard library now provides everything you need for advanced HTTP routing:

  • Path Parameters: Use {param} syntax and r.PathValue()
  • Method Routing: Prefix routes with HTTP methods
  • Host Routing: Specify hostnames in route patterns
  • Middleware: Leverage the http.Handler interface for clean middleware chains
  • Subrouting: Use http.StripPrefix() for mounting sub-routers
  • Context: Pass data through the request pipeline using context.WithValue()

All of these features work together seamlessly and require only Go 1.22+ specified in your go.mod file. While third-party packages may still be useful for specific use cases, the standard library now covers the vast majority of routing needs for modern Go applications.

The evolution of Go's HTTP routing capabilities demonstrates the language's commitment to providing powerful tools in the standard library while maintaining simplicity and performance.

Share:
The standard library now has all you need for advanced routing in Go. | Dreams of Code