Wiring the Server
Every lesson so far has built one handler at a time, and every test so far
has called that handler directly — construct a server, call
listTasks(w, r) or getTask(w, r), check the response. That's the right
way to test a handler in isolation, but it skips the one piece no handler
provides for itself: how a request for GET /tasks/7 finds its way to
getTask in the first place, and how r.PathValue("id") ends up holding
"7" when it does. This lesson closes that gap. Nothing here is new
mechanics — http.ServeMux and method-and-pattern registration were covered
in the Fundamentals HTTP chapter — what's new is doing it for a server with
real, working handlers behind it.
routes() as the composition point
routes is a method on *server, not a free function, because the router
has to close over the same server value the handlers are methods on —
s.listTasks, s.getTask, and so on are all method values bound to s,
which is exactly the store that was passed in when the server was built.
Making routes a method keeps that binding explicit and keeps the mux
construction next to the type it serves, instead of scattering wiring logic
into main or a package-level init.
func (s *server) routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks", s.listTasks)
mux.HandleFunc("POST /tasks", s.createTask)
mux.HandleFunc("GET /tasks/{id}", s.getTask)
mux.HandleFunc("PUT /tasks/{id}", s.updateTask)
mux.HandleFunc("DELETE /tasks/{id}", s.deleteTask)
return mux
}
Five handlers, five lines, one pattern each. The shape should look familiar from Chapter 1: a method verb, a space, a path pattern. What's different is that these patterns now route to handlers that actually read and write a store, validate input, and return the full range of status codes this API promises — 200, 201, 204, 400, 404, 422 — rather than to placeholder functions.
Two patterns, one distinction
/tasks and /tasks/{id} aren't the same route with an optional suffix —
they're two different resources. /tasks is the collection: list it with
GET, add to it with POST. /tasks/{id} is a single member of that
collection: read it, replace it, or remove it by identifying which one.
Go's net/http mux treats these as entirely separate registrations, which
is why routes has five HandleFunc calls rather than some kind of
conditional dispatch inside one handler — the mux does the dispatching, by
method and by path shape, before any handler code runs.
Where {id} actually comes from
The {id} segment in "GET /tasks/{id}" is not decoration — it's what
makes r.PathValue("id") return anything at all. getTask, updateTask,
and deleteTask all call r.PathValue("id") to know which task they're
operating on; that call only succeeds because the pattern registered here
told the mux to treat that path segment as a named parameter and capture it.
Every earlier lesson that exercised those handlers directly worked around
this by setting PathValue on the test request by hand — a shortcut for
testing a handler in isolation, not something a real client can do. Wiring
the mux is what makes PathValue populate itself from the actual URL a
client sent.
main: constructing and serving
func main() {
http.ListenAndServe(":8080", (&server{store: NewTaskStore()}).routes())
}
One line: build a server around a fresh TaskStore, call routes() on
it, and hand the result to ListenAndServe as the handler for the whole
process. Nothing about main needs to know how many routes there are or
what any of them do — that's routes's job, and it's now the only place a
new endpoint would ever need to be added.
The payoff
With routes wired, a client — or a test using httptest.NewRecorder
against the mux instead of against one handler — can now POST a task,
GET the list to see it appear, GET it by ID, PUT an update, and
DELETE it, all through the same entry point a real HTTP server would
expose. That's the shift this lesson makes: from handlers that work when
called directly to an API that works when called.
Practice
1. Wiring the Server
IntermediateYou've written all five handlers. Now assemble them into a router so real requests reach them — and so
{id}path values actually get populated.Implement
(s *server) routes() *http.ServeMuxregistering:GET /tasks→s.listTasksPOST /tasks→s.createTaskGET /tasks/{id}→s.getTaskPUT /tasks/{id}→s.updateTaskDELETE /tasks/{id}→s.deleteTask
This is the moment the pieces become an API: the hidden test drives a full create → list → get → update → delete flow through your router. The
{id}routes only work because the mux parses the pattern and fillsPathValue— which is exactly why the handlers readr.PathValue("id").package main import ( "encoding/json" "net/http" "strconv" "sync" ) type Task struct { ID string `json:"id"` Title string `json:"title"` Done bool `json:"done"` Priority int `json:"priority"` } // Store is the persistence seam the HTTP layer depends on. *TaskStore // satisfies it; a database-backed store could too, without touching handlers. type Store interface { Create(title string, priority int) Task Get(id string) (Task, bool) List() []Task Update(id, title string, done bool, priority int) (Task, bool) Delete(id string) bool } type TaskStore struct { mu sync.Mutex items map[string]Task order []string nextID int } func NewTaskStore() *TaskStore { return &TaskStore{items: make(map[string]Task), nextID: 1} } func (s *TaskStore) Create(title string, priority int) Task { s.mu.Lock() defer s.mu.Unlock() id := strconv.Itoa(s.nextID) s.nextID++ t := Task{ID: id, Title: title, Done: false, Priority: priority} s.items[id] = t s.order = append(s.order, id) return t } func (s *TaskStore) Get(id string) (Task, bool) { s.mu.Lock() defer s.mu.Unlock() t, ok := s.items[id] return t, ok } func (s *TaskStore) List() []Task { s.mu.Lock() defer s.mu.Unlock() out := make([]Task, 0, len(s.order)) for _, id := range s.order { out = append(out, s.items[id]) } return out } func (s *TaskStore) Update(id, title string, done bool, priority int) (Task, bool) { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.items[id]; !ok { return Task{}, false } t := Task{ID: id, Title: title, Done: done, Priority: priority} s.items[id] = t return t, true } func (s *TaskStore) Delete(id string) bool { s.mu.Lock() defer s.mu.Unlock() if _, ok := s.items[id]; !ok { return false } delete(s.items, id) for i, oid := range s.order { if oid == id { s.order = append(s.order[:i], s.order[i+1:]...) break } } return true } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() if err := dec.Decode(dst); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return false } return true } type server struct { store Store } func (s *server) listTasks(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.store.List()) } func (s *server) getTask(w http.ResponseWriter, r *http.Request) { t, ok := s.store.Get(r.PathValue("id")) if !ok { writeError(w, http.StatusNotFound, "task not found") return } writeJSON(w, http.StatusOK, t) } // validate returns a list of human-readable problems with the input, // or an empty slice if it is valid. func validate(title string, priority int) []string { var problems []string if title == "" { problems = append(problems, "title must not be empty") } if priority < 1 || priority > 3 { problems = append(problems, "priority must be between 1 and 3") } return problems } func (s *server) createTask(w http.ResponseWriter, r *http.Request) { var in struct { Title string `json:"title"` Priority int `json:"priority"` } if !decodeJSON(w, r, &in) { return } if problems := validate(in.Title, in.Priority); len(problems) > 0 { writeJSON(w, http.StatusUnprocessableEntity, map[string][]string{"errors": problems}) return } t := s.store.Create(in.Title, in.Priority) w.Header().Set("Location", "/tasks/"+t.ID) writeJSON(w, http.StatusCreated, t) } func (s *server) updateTask(w http.ResponseWriter, r *http.Request) { var in struct { Title string `json:"title"` Done bool `json:"done"` Priority int `json:"priority"` } if !decodeJSON(w, r, &in) { return } t, ok := s.store.Update(r.PathValue("id"), in.Title, in.Done, in.Priority) if !ok { writeError(w, http.StatusNotFound, "task not found") return } writeJSON(w, http.StatusOK, t) } func (s *server) deleteTask(w http.ResponseWriter, r *http.Request) { if !s.store.Delete(r.PathValue("id")) { writeError(w, http.StatusNotFound, "task not found") return } w.WriteHeader(http.StatusNoContent) } func (s *server) routes() *http.ServeMux { mux := http.NewServeMux() // TODO: register the five routes on s return mux } func main() { http.ListenAndServe(":8080", (&server{store: NewTaskStore()}).routes()) }Sign in to write and run your solution in the browser.
Sign in for hints and the solution.