A common Go opinion (idiom, warcry, take your pick) is “don’t use frameworks”. Certainly, when building a simple http service with one or two endpoints, it doesn’t feel like one is necessary.

When your requirements get more complicated, it may feel like you are doing a lot of hard work that a framework can help you with. That is an understandable opinion - the Go standard library in general is very powerful, but the documentation is more “reference” than “guide”.

So this post (and maybe more) is to demonstrate how you get some of the “quality of life” style changes, without leaning on a framework.

As an aside, I’ve used many frameworks before, in both Go and other languages. My opinion is that they work great until the day they do not. When your requirements extend past what the framework offers, you don’t just have to unpick your own code to cope with it, you need to understand the frameworks code too. This is no longer a time saving measure.

The code for all these examples can be found at https://github.com/tardisx/go-mux

Dealing with path values

By “path values” I mean URLs like:

/user/4567/view

The “4567” in this case is a user id.

Let’s look at the naive approach firstly. For the sake of simplifying all these examples, there is a separate package called “db” which I am using to load a user based on an id.

func main() {
	http.HandleFunc("/user/{userId}/view", viewUser)

	slog.Info("starting web service on :8080")
	panic(http.ListenAndServe(":8080", nil))
}

func viewUser(w http.ResponseWriter, r *http.Request) {
	userID := r.PathValue("userId")
	userIDint, err := strconv.Atoi(userID)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte("bad user id"))
		return
	}
	user, err := db.LoadUser(userIDint)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte(fmt.Sprintf("could not load user: %s", err.Error())))
		return
	}

	slog.Info("loaded user")
	// show the user id
	w.Write([]byte(fmt.Sprintf("loaded user %s", user)))
}

In main we setup a route to handle viewing users based on their ID. In the route handler viewUser we must first extract the value for the placeholder {userId} for the incoming request.

It’s a string, so we have to turn it into an integer (including checking for and returning errors).

Then we load the user from the database (again, checking for errors and showing them to the requester if they occur).

Finally we can do something with the user we loaded (in this case just dumping it in the response).

This is a lot of boilerplate. The boilerplate outweighs the “interesting” parts of the code, where we use the user struct to show something to the user.

It’s perhaps not too bad for one single route, but there are no doubt going to be many, like /user/4567/view, /user/4567/details, user/4567/edit, maybe with GET and POST variants. So lets do a simple refactor:

(showing only changed code)


func viewUser(w http.ResponseWriter, r *http.Request) {
	user, err := loadUserFromRequest(r)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte(fmt.Sprintf("could not load user: %s", err.Error())))
		return
	}

	slog.Info("loaded user")
	// show the user id
	w.Write([]byte(fmt.Sprintf("loaded user %s", user)))
}

func loadUserFromRequest(r *http.Request) (db.User, error) {
	userID := r.PathValue("userId")
	userIDint, err := strconv.Atoi(userID)
	if err != nil {
		return db.User{}, err
	}
	user, err := db.LoadUser(userIDint)
	if err != nil {
		return db.User{}, err
	}
	return user, nil
}

Nothing too earth shattering here - we have a helper function to load the user, so we’ve somewhat reduced the boilerplate in the viewUser handler, but it’s still not ideal. We still need to check for errors in the viewUser handler.

We can do better - with a middleware.

var userContextKey = "user"

func main() {
	http.Handle("/user/{userId}/view", getUserFromPath(http.HandlerFunc(viewUser)))

	slog.Info("starting web service on :8080")
	panic(http.ListenAndServe(":8080", nil))
}

func viewUser(w http.ResponseWriter, r *http.Request) {
	user := r.Context().Value(userContextKey).(db.User)

	slog.Info("loaded user")
	// show the user id
	w.Write([]byte(fmt.Sprintf("loaded user %s", user)))
}

func getUserFromPath(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		userID := r.PathValue("userId")
		userIDint, err := strconv.Atoi(userID)
		if err != nil {
			w.WriteHeader(http.StatusBadRequest)
			w.Write([]byte("bad user id"))
			return
		}
		user, err := db.LoadUser(userIDint)
		if err != nil {
			w.WriteHeader(http.StatusBadRequest)
			w.Write([]byte(fmt.Sprintf("could not load user: %s", err.Error())))
			return
		}
		newContext := context.WithValue(r.Context(), userContextKey, user)
		r = r.Clone(newContext)
		next.ServeHTTP(w, r)
	})
}

The first thing to note is that the route setup uses “Handle” instead of “HandleFunc”, and we wrap our call to viewUser with a http.HandlerFunc to preserve that signature.

However these are wrapped in a call to getUserFromPath which acts as a middleware, to extract the user id from the path, load the user into the request context or return a HTTP error where something goes wrong.

Let’s break it down:

http.Handle("/user/{userId}/view", getUserFromPath(http.HandlerFunc(viewUser)))

The code in the middleware is almost identical to what we saw before. If we don’t successfully load the user for some reason, we return an appropriate HTTP status code and some sort of textual error.

The last three lines are the key here:

newContext := context.WithValue(r.Context(), userContextKey, user)
r = r.Clone(newContext)
next.ServeHTTP(w, r)

First we create a new context, based on the existing context, using a known key and the value is our newly loaded user struct. Note that r.Context() gives you the context for this single HTTP request.

Secondly, we have to clone the request using the new context. This is necessary so that we have an updated request struct to pass further down the chain, with our new context.

Lastly, we call next.ServeHTTP to allow the ServeMux to call the next handler in our chain (which we setup in the initial http.Handle("/user.... call).

It’s important to note the other exit paths in this function (that simply return without calling next.ServeHTTP(w, r)) will terminate the chain immediately, our viewUser function will never get called at all.

With all that done, our actual viewUser route has a single line of code to get the user struct:

user := r.Context().Value(userContextKey).(db.User)

Note that using a type assertion like this here is safe, as there is no way our viewUser route could even be executed if the middleware failed to load the user into our context.

Other uses for middleware

Other common uses for this same technique are:

Note that there is no requirement to call next.ServeHTTP at the end of the middleware function. For instance, in the case of the logging scenario, you might do something like this:

t0 := time.Now()
next.ServeHTTP(w, r)
slog.Info(fmt.Sprintf("request took %.3fs", time.Since(t0).Seconds()))

This captures the time at the start of the request, calls the rest of the http.Handler chain, and eventually returns to this middleware to log the total time taken.


Tags: golang  servemux  middleware