Go (1.22) HTTP Router

Personally, I have found the Go standard library one of the best standard libraries that a programming language has to offer. At least from the perspective of web programming, it always been pretty easy to work with; however, one of the things that was lacking was a router that let you have HTTP method, path, and wild card matching. In most of my projects, I’d reach for an open source library that implemented a router with some lightweight features like chi; however, since Go’s release of version 1.22 earlier this year the net/http package received a pretty sweet update for the router that allows for HTTP method and wild card matching. In this article we will go over some of new things that we can do without a third party library.

ServerMux Path Parameters

One of the new features of ServerMux is path parameter parsing, which gives us a way to parse out URL parameters. In order to have access to path parameters, we’ll need to ensure that we’ve got Go version 1.22 installed and our mod file is using at least 1.22 as well.

package main

import (
	"log"
	"net/http"
)

func main() {
	router := http.NewServeMux()
	router.HandleFunc("/item/{id}", func(w http.ResponseWriter, r *http.Request) {
		id := r.PathValue("id")
		w.Write([]byte("item: " + id))
	})

	server := http.Server{
		Addr:    ":8080",
		Handler: router,
	}

	log.Println("Starting server on port :8080")
	if err := server.ListenAndServe(); err != nil {
		panic(err)
	}
}

Conflicting Paths and Precedence

Every HTTP router could handle overlapping patterns different ways. Per the routing enhancements post on the Go dev blog, since Go has always allowed overlaps and chosen the longer pattern regardless of registration order preserving order-independence was important for backwards compatibility. The router rules for handling overlapping patterns in this update basically determines which pattern is more specific than another if it matches a strict subset of requests. The precedence rule is the most specific pattern wins.

If we have two routes that conflict with one another /item/{id} and /item/latest, when we submit a HTTP call to /post/latest the router would resolve to correct route path due to Go’s most specific precedence routing.

package main

import (
	"log"
	"net/http"
)

func findByID(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    w.Write([]byte("Finding by ID: " + id))
}

func getLatest(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Getting the latest"))
}

func main() {
	router := http.NewServeMux()
	router.HandleFunc("/item/{id}", findByID)
	router.HandleFunc("/item/latest", getLatest)

	server := http.Server{
		Addr:    ":8080",
		Handler: router,
	}

	log.Println("Starting server on port :8080")
	if err := server.ListenAndServe(); err != nil {
		panic(err)
	}
}

There are cases where routing can be a bit ambiguous, if we had /posts/{id} and {resource}/latest as routing paths they would potentially conflict with one another in the scenario where a call to /post/latest was made. The program that attempts to register these routes will create a panic stopping this conflict from happening.

package main

import (
	"log"
	"net/http"
)

func findByID(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	w.Write([]byte("Finding by ID: " + id))
}

func getLatest(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Getting the latest"))
}

func main() {
	router := http.NewServeMux()
	router.HandleFunc("/posts/{id}", findByID)
	router.HandleFunc("/{resource}/latest", getLatest)

	server := http.Server{
		Addr:    ":8080",
		Handler: router,
	}

	log.Println("Starting server on port :8080")
	if err := server.ListenAndServe(); err != nil {
		panic(err)
	}
}

~/dev/go-examples
❯ go run main.go
panic: pattern "/{resource}/latest" (registered at main.go:20)
conflicts with pattern "/posts/{id}" (registered at main.go:19):
/{resource}/latest and /posts/{id} both match some paths, like "/posts/latest".
But neither is more specific than the other.
/{resource}/latest matches "/resource/latest", but /posts/{id} doesn't.
/posts/{id} matches "/posts/id", but /{resource}/latest doesn't.

goroutine 1 [running]:
net/http.(*ServeMux).register(...)
        /usr/local/go/src/net/http/server.go:2738
net/http.(*ServeMux).HandleFunc(0xc0000061c0?, {0x6944e1?, 0x4086eb?}, 0x0?)
        /usr/local/go/src/net/http/server.go:2712 +0x65
main.main()
        main.go:20 +0x59
exit status 2

Method Based Routing

Prior to version 1.22, the handler code would have to check which HTTP method was used explicitly in the code; however, with the new update we get the ability to specify the HTTP method in the route string. If not methods have been defined on a path, then it will handle all methods that haven’t been explicitly defined. One gotcha about the method and route pathing is that you’ll only want a single space between the method and the route; otherwise, the router will no longer be able to match properly.

package main

import (
	"log"
	"net/http"
)

func find(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Finding"))
}

func create(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Creating"))
}

func update(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Updating"))
}

func findByID(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    w.Write([]byte("Finding by ID: " + id))
}

func getLatest(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Getting the latest"))
}

func main() {
	router := http.NewServeMux()
	router.HandleFunc("GET /item", find)
	router.HandleFunc("POST /item", create)
	router.HandleFunc("GET /item/{id}", findByID)
	router.HandleFunc("PUT /item/{id}", update)
	router.HandleFunc("GET /item/latest", getLatest)

	server := http.Server{
		Addr:    ":8080",
		Handler: router,
	}

	log.Println("Starting server on port :8080")
	if err := server.ListenAndServe(); err != nil {
		panic(err)
	}
}

Host Based Routing

Another additional feature is that has been added is the ability to handle host name instead of just a path.

package main

import (
	"log"
	"net/http"
)

func handleOther(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("handle other ..."))
}

func handleDomain(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("handle domain ..."))
}

func main() {
	router := http.NewServeMux()
	router.HandleFunc("/", handleOther)
	router.HandleFunc("hiddenpixel.com/", handleDomain)

	server := http.Server{
		Addr:    ":8080",
		Handler: router,
	}

	log.Println("Starting server on port :8080")
	if err := server.ListenAndServe(); err != nil {
		panic(err)
	}
}

The following curl request can be submitted to test out the host name functionality, curl -H "Host: hiddenpixle.com" localhost:8080.

Middleware

Out of the box, the Go standard library doesn’t provide a way to attach middleware to our routers; however, we can easily implement middleware handling with a little bit of code.

package main

import (
	"log"
	"net/http"
	"time"
)

type wrappedWriter struct {
	http.ResponseWriter
	statusCode int
}

func (w *wrappedWriter) WriteHeader(statusCode int) {
	w.ResponseWriter.WriteHeader(statusCode)
	w.statusCode = 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(wrapped.statusCode,
			r.Method,
			r.URL.Path,
			time.Since(start))
	})
}

func find(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Finding"))
}

func create(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Creating"))
}

func findByID(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	w.Write([]byte("Finding by ID: " + id))
}

func getLatest(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Getting the latest"))
}

func main() {
	router := http.NewServeMux()
	router.HandleFunc("GET /item", find)
	router.HandleFunc("POST /item", create)
	router.HandleFunc("GET /item/{id}", findByID)
	router.HandleFunc("GET /item/latest", getLatest)

	server := http.Server{
		Addr:    ":8080",
		Handler: Logging(router),
	}

	log.Println("Starting server on port :8080")
	if err := server.ListenAndServe(); err != nil {
		panic(err)
	}
}

Since we’re probably going to want to do some kind of middleware chaining, we can also implement that as well.

package main

import (
	"log"
	"net/http"
	"time"
)

type Middleware func(http.Handler) http.Handler

func CreateStack(xs ...Middleware) Middleware {
	return func(next http.Handler) http.Handler {
		for i := len(xs) - 1; i >= 0; i-- {
			x := xs[i]
			next = x(next)
		}
		return next
	}
}

type wrappedWriter struct {
	http.ResponseWriter
	statusCode int
}

func (w *wrappedWriter) WriteHeader(statusCode int) {
	w.ResponseWriter.WriteHeader(statusCode)
	w.statusCode = 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(wrapped.statusCode,
			r.Method,
			r.URL.Path,
			time.Since(start))
	})
}

func EnableCORS(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		log.Println("Enabling CORS")
	})
}

func find(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Finding"))
}

func create(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Creating"))
}

func findByID(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	w.Write([]byte("Finding by ID: " + id))
}

func getLatest(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Getting the latest"))
}

func main() {
	router := http.NewServeMux()
	router.HandleFunc("GET /item", find)
	router.HandleFunc("POST /item", create)
	router.HandleFunc("GET /item/{id}", findByID)
	router.HandleFunc("GET /item/latest", getLatest)

	stack := CreateStack(
		Logging,
		EnableCORS,
	)
	server := http.Server{
		Addr:    ":8080",
		Handler: stack(router),
	}

	log.Println("Starting server on port :8080")
	if err := server.ListenAndServe(); err != nil {
		panic(err)
	}
}

Subrouting

Another feature that we might want would be subrouting, which is the ability to split our routing logic across multiple routers. In this example, we are just wrapping our normal routes to also handle a /v1 prefix; however, we can also split out routes with subrouters in order to apply different middleware to those routes as well.

package main

import (
	"log"
	"net/http"
	"time"
)

type Middleware func(http.Handler) http.Handler

func CreateStack(xs ...Middleware) Middleware {
	return func(next http.Handler) http.Handler {
		for i := len(xs) - 1; i >= 0; i-- {
			x := xs[i]
			next = x(next)
		}
		return next
	}
}

type wrappedWriter struct {
	http.ResponseWriter
	statusCode int
}

func (w *wrappedWriter) WriteHeader(statusCode int) {
	w.ResponseWriter.WriteHeader(statusCode)
	w.statusCode = 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(wrapped.statusCode,
			r.Method,
			r.URL.Path,
			time.Since(start))
	})
}

func EnableCORS(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		log.Println("Enabling CORS")
	})
}

func find(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Finding"))
}

func create(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Creating"))
}

func findByID(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	w.Write([]byte("Finding by ID: " + id))
}

func getLatest(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Getting the latest"))
}

func main() {
	router := http.NewServeMux()
	router.HandleFunc("GET /item", find)
	router.HandleFunc("POST /item", create)
	router.HandleFunc("GET /item/{id}", findByID)
	router.HandleFunc("GET /item/latest", getLatest)

	v1 := http.NewServeMux()
	v1.Handle("/v1/", http.StripPrefix("/v1", router))

	stack := CreateStack(
		Logging,
		EnableCORS,
	)
	server := http.Server{
		Addr:    ":8080",
		Handler: stack(router),
	}

	log.Println("Starting server on port :8080")
	if err := server.ListenAndServe(); err != nil {
		panic(err)
	}
}

EOF

In my opinion, the newly added features to the net/http routing has been a huge win for the Go standard library. It gives developers a easier approach to HTTP routing without having to pull in libraries; however, I’m sure that there are other nice features that routing libraries offer that would be a bit more of a hassle to write on your own. As with all things, there is a good balance be to had in your projects with choosing what is best for the project, but I am glad that Go team implemented these features to give us a standard approach in simple cases.

References