Custom HTTP Handlers (Part 2)
2022-10-08
In the last post I covered a way to pass data to http handlers without using context.WithValue()context.WithValue(). I saw another interesting way to do a type of dependency injection in a package from Jeremy called Mixer. If you own the server implementation and don’t want to import a 3rd party package you can do something similar. This doesn’t rely on generics so it is backwards compatible with older versions of Go.
First create a struct to hold our data we would like to pass to the handlers including the same DataStore implementation from the last post:
type DataStore interface { GetUser(id string) *user}type memoryStore struct { users map[string]*user}type user struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Role string `json:"role,omitempty"`}type AppContext struct { r *http.Request w http.ResponseWriter user *user err error datastore DataStore}
type DataStore interface { GetUser(id string) *user}type memoryStore struct { users map[string]*user}type user struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Role string `json:"role,omitempty"`}type AppContext struct { r *http.Request w http.ResponseWriter user *user err error datastore DataStore}
The one downside to this approach is if you don’t wrap your handlers with a function, the signature of your function ends up looking like this func (a *AppContext)func (a *AppContext). Luckily we can wrap this and return an http.HandlerFunchttp.HandlerFunc.
Let’s define our app function:
type appFunc func(a *AppContext)
type appFunc func(a *AppContext)
Now that we have our function layout, here’s where the first part of the magic happens. We take an appFuncappFunc and do some work to create our app context. Then we will return an http.HandleFunchttp.HandleFunc which the standard library knows how to handle (no pun intended).
func (s *Server) ToHandler(fn appFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { a := &AppContext{ w: w, r: r, datastore: s.datastore, } for _, v := range s.before { v(a) } fn(a) for _, v := range s.after { v(a) } }}
func (s *Server) ToHandler(fn appFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { a := &AppContext{ w: w, r: r, datastore: s.datastore, } for _, v := range s.before { v(a) } fn(a) for _, v := range s.after { v(a) } }}
We create an app context and set the response writer and request to the HandleFunc response writer and request. We also add the datastore to the AppContext so we can query the datastore later.
The before and after ranges are there for any middleware we want to set up. There lies the second (minor) downside with this approach, the middleware won’t accept a standard http.Handler like most of us are used to. We just pass the app context to the before and after middleware functions. Let’s create the struct to hold the middlewares and our DataStore:
type Server struct { before []appFunc after []appFunc datastore DataStore}
type Server struct { before []appFunc after []appFunc datastore DataStore}
Similarly to what we did in the last post, let’s create some helpers to build our routes:
type route struct { handler func(http.ResponseWriter, *http.Request, *user) error method string path string}func getRoutes() []route { return []route{ { path: "/getUser", handler: getUser, method: http.MethodGet, }, }}
type route struct { handler func(http.ResponseWriter, *http.Request, *user) error method string path string}func getRoutes() []route { return []route{ { path: "/getUser", handler: getUser, method: http.MethodGet, }, }}
Remember earlier when I said this was the first part of the magic? Well here’s the second part. If all we did was set up the function above, our handlers would still look like the appFuncappFunc type. This function adds our routes to our muxer. It takes the handler in our route and maps the AppContextAppContext fields to the handlers arguments. Now we can make our typical looking handlers with a responsewriter, request, and whatever other arguments:
func (s *Server) buildRouter(routes []route, m *http.ServeMux) *http.ServeMux { for _, v := range routes { m.Handle(v.path, s.ToHandler(func(a *AppContext) { a.err = v.handler(a.w, a.r, a.user) })) } return m}
func (s *Server) buildRouter(routes []route, m *http.ServeMux) *http.ServeMux { for _, v := range routes { m.Handle(v.path, s.ToHandler(func(a *AppContext) { a.err = v.handler(a.w, a.r, a.user) })) } return m}
Now we can write our handler which just takes an http.ResponseWriterhttp.ResponseWriter, *http.Request*http.Request, and a *user*user:
func getUser(w http.ResponseWriter, r *http.Request, u *user) error { if u == nil { return fmt.Errorf("user found not") } w.Header().Add("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(u); err != nil { return err } return nil}
func getUser(w http.ResponseWriter, r *http.Request, u *user) error { if u == nil { return fmt.Errorf("user found not") } w.Header().Add("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(u); err != nil { return err } return nil}
All we need to do now is wire it up in a main()main() and add our datastore implementation. This doesn’t have custom error handling like the last post, but that could easily be added in the error handling middleware. As before, you should be able to copy paste this and run it:
package mainimport ( "encoding/json" "fmt" "log" "net/http")type DataStore interface { GetUser(id string) *user}type memoryStore struct { users map[string]*user}type user struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Role string `json:"role,omitempty"`}// Server holds any before and after middleware. It also holds our // datastore interface.type Server struct { before []appFunc after []appFunc datastore DataStore}type appFunc func(a *AppContext)// AppContext is the context passed around to our handlerstype AppContext struct { r *http.Request w http.ResponseWriter user *user err error datastore DataStore}// route holds the information for a route in our muxertype route struct { handler func(http.ResponseWriter, *http.Request, *user) error method string path string}// newMemDS creates a new in memory user storefunc newMemDS() *memoryStore { return &memoryStore{ users: map[string]*user{ "123": { ID: "123", Name: "test user", Role: "admin", }, }, }}// GetUser returns the user associated with the supplied IDfunc (m *memoryStore) GetUser(id string) *user { return m.users[id]}// getUser is our http handler for getting user informationfunc getUser(w http.ResponseWriter, r *http.Request, u *user) error { if u == nil { return fmt.Errorf("user found not") } w.Header().Add("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(u); err != nil { return err } return nil}// injectUser queries the datastore for a user and if found // adds it to our appContext.func injectUser(a *AppContext) { id := a.r.URL.Query().Get("id") a.user = a.datastore.GetUser(id)}// handleErrors is just middleware to print out any errorsfunc handleErrors(a *AppContext) { if a.err != nil { a.w.WriteHeader(500) a.w.Write([]byte(a.err.Error())) }}// ToHandler takes an appFunc and returns an http.HandlerFunc. It maps // the AppContext writer, request to the http.HandlerFunc incoming writer and request.// It also runs any before/after middleware set on the server.func (s *Server) ToHandler(fn appFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { a := &AppContext{ w: w, r: r, datastore: s.datastore, } for _, v := range s.before { v(a) } fn(a) for _, v := range s.after { v(a) } }}// get routes returns a slice of routesfunc getRoutes() []route { return []route{ { path: "/getUser", handler: getUser, method: http.MethodGet, }, }}// buildRouter takes an array of routes and a muxer and attaches the routes to the// muxer while mapping the writer, request, and user to the handler. It also sets // the error from the function to the app context error field.func (s *Server) buildRouter(routes []route, m *http.ServeMux) *http.ServeMux { for _, v := range routes { m.Handle(v.path, s.ToHandler(func(a *AppContext) { a.err = v.handler(a.w, a.r, a.user) })) } return m}func main() { mux := http.NewServeMux() m := newMemDS() s := Server{ before: []appFunc{injectUser}, after: []appFunc{handleErrors}, datastore: m, } routes := getRoutes() router := s.buildRouter(routes, mux) log.Fatal(http.ListenAndServe(":8888", router))}
package mainimport ( "encoding/json" "fmt" "log" "net/http")type DataStore interface { GetUser(id string) *user}type memoryStore struct { users map[string]*user}type user struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Role string `json:"role,omitempty"`}// Server holds any before and after middleware. It also holds our // datastore interface.type Server struct { before []appFunc after []appFunc datastore DataStore}type appFunc func(a *AppContext)// AppContext is the context passed around to our handlerstype AppContext struct { r *http.Request w http.ResponseWriter user *user err error datastore DataStore}// route holds the information for a route in our muxertype route struct { handler func(http.ResponseWriter, *http.Request, *user) error method string path string}// newMemDS creates a new in memory user storefunc newMemDS() *memoryStore { return &memoryStore{ users: map[string]*user{ "123": { ID: "123", Name: "test user", Role: "admin", }, }, }}// GetUser returns the user associated with the supplied IDfunc (m *memoryStore) GetUser(id string) *user { return m.users[id]}// getUser is our http handler for getting user informationfunc getUser(w http.ResponseWriter, r *http.Request, u *user) error { if u == nil { return fmt.Errorf("user found not") } w.Header().Add("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(u); err != nil { return err } return nil}// injectUser queries the datastore for a user and if found // adds it to our appContext.func injectUser(a *AppContext) { id := a.r.URL.Query().Get("id") a.user = a.datastore.GetUser(id)}// handleErrors is just middleware to print out any errorsfunc handleErrors(a *AppContext) { if a.err != nil { a.w.WriteHeader(500) a.w.Write([]byte(a.err.Error())) }}// ToHandler takes an appFunc and returns an http.HandlerFunc. It maps // the AppContext writer, request to the http.HandlerFunc incoming writer and request.// It also runs any before/after middleware set on the server.func (s *Server) ToHandler(fn appFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { a := &AppContext{ w: w, r: r, datastore: s.datastore, } for _, v := range s.before { v(a) } fn(a) for _, v := range s.after { v(a) } }}// get routes returns a slice of routesfunc getRoutes() []route { return []route{ { path: "/getUser", handler: getUser, method: http.MethodGet, }, }}// buildRouter takes an array of routes and a muxer and attaches the routes to the// muxer while mapping the writer, request, and user to the handler. It also sets // the error from the function to the app context error field.func (s *Server) buildRouter(routes []route, m *http.ServeMux) *http.ServeMux { for _, v := range routes { m.Handle(v.path, s.ToHandler(func(a *AppContext) { a.err = v.handler(a.w, a.r, a.user) })) } return m}func main() { mux := http.NewServeMux() m := newMemDS() s := Server{ before: []appFunc{injectUser}, after: []appFunc{handleErrors}, datastore: m, } routes := getRoutes() router := s.buildRouter(routes, mux) log.Fatal(http.ListenAndServe(":8888", router))}