/ 9 min read
How to handle basic routing using the Go standard library
Introduction
In the last post, we learnt how to create a simple web server in go and how to handle a /ping
request that pong
s.
Today, we’re going to see how we can handle some basic routing, as well as describe more complex, nested routes using the net/http
package and ServeMux
.
Defining Routes
We learnt last time that you can define routes using the Handle
and HandleFunc
methods on ServeMux
.
package main
import ( "fmt" "net/http")
func main() { router := http.NewServeMux() server:= http.Server{ Addr:":8080", Handler: router }
router.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "pong") })
server.ListenAndServe()}
That’s it. That’s all you need to know how to define basic routes in Go. Simply define the absolute route, and give it a Handler
function.
You could also define routes routes with multiple segments this way, but that’s inefficient. Which is why it’s best to define complex routes through nesting.
Defining Nested Routes
Nested routes in Go are tricky.
Nested Routes in Node.js
If you were using something like express
in Node.js
and wanted to define a /users/first
router, you’d do something like:
import express from 'express';
const app = express()const router = express.Router()
app.use('/users', router);
router.get('/first', (req, res) => { res.status(200).send("First");})
app.listen(8000, () => { console.log('Listening on port 8000')})
And it would work!
Making a request to http://localhost:8000/users/first
would return a status 200
and First
.
Let’s try doing thing same thing in Go.
But we’re going to define two routes instead of just /users/first
First route is going to be /users
that will act as the control, and simply return Base Users
.
And the second is going to be /users/first
that will return First
.
func main() { router := http.NewServeMux() http.Handle("/users", router)
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Base Users") })
router.HandleFunc("/first", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "First") })
http.ListenAndServe(":8080", nil)}
Great! And now let’s try making that request.
iwr http://localhost:8080/users/first
HTTP/1.1 404 Not FoundContent-Type: text/plain; charset=utf-8X-Content-Type-Options: nosniffDate: Tue, 14 Jan 2025 10:30:11 GMTContent-Length: 19
404 page not found
Hmm… 404. I wonder why.
Trailing Slash
Looking at the docs here, you’ll see that due to the way Go’s route pattern matching works, you need a trailing slash at the end of your route to act as a catch-all wildcard. Without this, Go thinks your route ends at /users
and that /users/first
doesn’t exist. So, it throws a 404!
Let’s try adding that trailing slash to /users
and see if this thing works.
func main() { userRouter := http.NewServeMux();
userRouter.HandleFunc("/first", func (w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "First") })
http.Handle("/users/", userRouter)
http.ListenAndServe(":8080", nil)}
Let’s go ahead and make that request again:
iwr http://localhost:8080/users/first
HTTP/1.1 200 OKDate: Tue, 14 Jan 2025 11:19:10 GMTContent-Length: 10Content-Type: text/plain; charset=utf-8
Base Users
Okay, it… worked? We definitely got something. But it seems like the request got routed to /users
instead of /users/first
Debugging The Router
Let’s debug this. We know the Handle
function takes a path string and a Handler
. The path string is fine, but let’s see if we can get more information if we write our own handler.
Digging into the source code, we find that the net/http
package matches the path (called pattern
in the source) we define in the Handle
and HandleFunc
methods against the URL
field in the http.Request
object. So if the URL
and Pattern
match, it should trigger the Handler
.
Let’s update our main.go
file with our debug handler:
type DebugHandler struct { handler http.Handler}
func (d DebugHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { fmt.Printf("[DEBUGGER]\n") fmt.Printf("Pattern\t=> %q\n", r.Pattern) fmt.Printf("URL\t=> %q\n", r.URL) d.handler.ServeHTTP(w, r)}
func debugFunc(w http.ResponseWriter, r *http.Request) { fmt.Println() fmt.Printf("[HANDLER]\n") fmt.Printf("Pattern\t=> %q\n", r.Pattern) fmt.Printf("URL\t=> %q\n", r.URL)}
func main() { router := http.NewServeMux() http.Handle("/users/", DebugHandler{router})
router.HandleFunc("/", debugFunc) router.HandleFunc("/first", debugFunc)
http.ListenAndServe(":8080", nil)}
Let’s go over this debugging setup.
First, we’ve created a Handler
for debugging called DebugHandler
that takes in a http.ServeMux
. This handler implements the http.Handler
interface, which means it implements the ServeHTTP
function.
Second, we know that the URL
of the incoming http.Request
is matched against the pattern
so we log them both to the screen.
Then we’ve also defined a debugging function that will handle the actual route: debugFunc
and also log the information to the screen.
Now, if we make a request to /users/first
, we get this in the console:
[DEBUGGER]Pattern => "/users/"URL => "/users/first"
[HANDLER]Pattern => "/"URL => "/users/first"
So, first the URL
matches against the /users/
pattern and due to the trailing slash acting as wildcard. Next, it gets passed to the router
in which it should ideally match against the /first
handler, but instead, it matches against the /
despite /first
being more precise, and taking precedence (Read here).
It seems like whatever pattern we define, we need to make sure it accounts for the entire url, and not just nested routes.
So, potentially, we could define our route to be /users/first
in our router
and have it work.
The Quick Fix
We update the route in our file to:
// ...router.HandleFunc("/users/first", debugFunc)// ...
And making request to the modified router:
[DEBUGGER]Pattern => "/users/"URL => "/users/first"
[HANDLER]Pattern => "/users/first"URL => "/users/first"
It works!
But it also defeats the purpose of having nested routers. We would have to keep track of all the places we have defined nested routes, and update them all incase things ever change.
And this is just a mistake waiting to happen.
Let’s see if we can fix this.
The Verbose Fix
It seems like the problem occurs because the URL
in our http.Request
keeps the whole string intact throughout it’s nested Handler
journey.
Going over the docs, we find a function called StripPrefix
that removes a specified string from the start of the URL and invokes the Handler
.
Incorporating that into our file, we get:
// ... removeUsersPrefix := http.StripPrefix("/users/", DebugHandler{userRouter}) http.Handle("/users/", DebugHandler{removeUsersPrefix})// ...
We’ve also wrapped the removeUsersPrefix
handler in a DebugHandler
so we can see what’s going on inside.
And now if we make a request to /users/first
:
HTTP/1.1 200 OKDate: Tue, 14 Jan 2025 11:19:10 GMTContent-Length: 10Content-Type: text/plain; charset=utf-8
Base Users
Okay so it’s still behaving like it was before. Our routes still aren’t working.
Let’s look at our console to see if our debugger is any help.
[DEBUGGER] (1)Pattern => "/users/"URL => "/users/first"[DEBUGGER] (2)Pattern => "/users/"URL => "first"
- This is the statement captured before the request is Piped to the prefix remover
- This is after it’s been piped.
It matches against /users/
in (1)
and then our http.StripPrefix
handler removes the /users/
from the URL (/users/first
), leaving only first
.
And that isn’t able to match against /first
Making a small change to our StripPrefix
, we should be able to solve this issue. We’ll remove the trailing slash from /users/
so that a valid URL remains
// ...removeUsersPrefix := http.StripPrefix("/users", DebugHandler{router})// ...
Making the request again, and looking at our console, we can see that it worked! Our /first
route is being triggered.
[DEBUGGER]Pattern => "/users/"URL => "/users/first"[DEBUGGER]Pattern => "/users/"URL => "/first"
[HANDLER]Pattern => "/first"URL => "/first"
Clean Up
But this is too many things to get right just get a basic subrouter going. Let’s see if we can make this easier for us in the future.
removeUsersPrefix := http.StripPrefix("/users", DebugHandler{router})http.Handle("/users/", DebugHandler{removeUsersPrefix})
Looking at the code, there’s a couple of things we need to get right to have the subrouter work:
- We need to define our route in the
Handle
function with a trailing slash - We need to strip the URL off that route before we pass that to sub handlers
- We need to strip the route without the trailing slash
- And then finally, we need to attach our actual sub router to our
StripPrefix
handler
Perhaps we can make a wrapper around http.ServeMux
(since that handles the routing) that let’s us do all these without having to think about it again and again.
We’ll create a router.go
file in pkgs/router/
package router
import "net/http"
type Router struct { *http.ServerMux}
func NewRouter() Router { return Router{http.NewServeMux()}}
func (router *Router) HandleSubroute(route string, handler http.Handler) { router.Handle(route + "/", http.StripPrefix(route, handler))}
We embedded the ServeMux
type in a custom struct, giving us access to all methods that ServeMux
has including Handle
and HandleFunc
without having to write our own wrappers for them.
We create two functions: NewRouter
that returns a new Router
; and HandleSubroute
method on that Router
that handles all the trailing slash, and prefix stripping for us.
Note how we used *http.Handler
for the subroute handler instead of ServeMux
this is to make it as compatible and composable possible with net/http
.
Let’s rewrite our main.go
using this newly created package.
package main
import ( "bamboo/basic-routing/pkgs/router" "fmt" "net/http")
func main() { base := router.NewRouter() users := router.NewRouter()
base.HandleSubroute("/users", users)
// Handles /users users.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Base Users") })
// Handles /users/first users.HandleFunc("/first", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "First") })
http.ListenAndServe(":8080", base)}
We did have to introduce a base
router that we can pass into our server, but other than that, this looks a lot cleaner! And it makes it easy to define nested routes.
If you try to make a request to /users/first
now, you’ll get back First
.
Plus, our structure makes it really easy to define further nested subroutes as well.
We’ll create a route /users/posts/get
that returns Got User Post
package main
import ( "bamboo/basic-routing/pkgs/router" "fmt" "net/http")
func main(){ base := router.NewRouter() users := router.NewRouter()
base.HandleSubroute("/users", users)
// Handles /users/first users.HandleFunc("/first", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "First") })
// Handles /users users.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Base Users") })
// New router to handle /users/posts/* posts := router.NewRouter()
// Handles /users/posts/* users.HandleSubroute("/posts", posts)
// Handle /users/posts/get posts.HandleFunc("/get", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Got User Post!") })
http.ListenAndServe(":8080", base)}
Et voila! We can now handle basic nested routes in Go!
I hope you learnt something new, and had as much fun reading this as I had writing it.
If you wanna know about upcoming posts on this topic, you can sign up for my newsletter!
Happy Hacking!