List & Get Handlers
Part 1 built TaskStore: a mutex-guarded map with Create, Get, List,
Update, and Delete methods. Chapter 1's Fundamentals HTTP work already
covered writing JSON responses and decoding request bodies with
encoding/json. This lesson connects the two — the first handlers that turn
TaskStore methods into HTTP responses.
The server struct, and why it holds an interface
Every handler in this book is a method on server:
type server struct {
store Store
}
Note the field type: Store, not *TaskStore. Store is an interface —
Create, Get, List, Update, Delete — and *TaskStore happens to
satisfy it. That distinction is easy to skim past, but it's the whole reason
this chapter is structured the way it is. A handler that calls
s.store.Get(id) doesn't know or care whether store is a map behind a
mutex, a fake you built for a test, or a Postgres-backed type you write in
six months. It only knows the interface's method set. Accept interfaces,
return concrete types — you've likely met that guideline before; this is
where it pays for itself. Later chapters can swap the store's
implementation, or a test can hand server a stub that returns
canned data, and not one handler signature changes.
For now newServer (in the hidden tests) wires up the real thing —
&server{store: NewTaskStore()} — but the handlers themselves are written
against Store, never against *TaskStore directly.
listTasks: the whole handler is one call
func (s *server) listTasks(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.store.List())
}
That's it. s.store.List() already returns a []Task, writeJSON already
knows how to set the header, write the status, and encode a value — the
handler's only job is to wire the two together. There's no branching because
there's nothing that can go wrong here: listing tasks always succeeds, even
when there are none.
That "even when there are none" case is worth pausing on. Look back at
TaskStore.List from Part 1:
out := make([]Task, 0, len(s.order))
make([]Task, 0, ...) — zero length, but non-nil. If List had instead
started from var out []Task, an empty store would return a nil slice, and
encoding/json marshals a nil slice as null, not []. To an API client,
null and [] mean very different things — "something went wrong" versus
"there's nothing here yet." That distinction was decided in Part 1, at the
store, not here in the handler. listTasks gets the right behavior for
free because the store was built correctly. It's another small payoff of
keeping the store as a clean seam: get the contract right once, and every
caller downstream inherits the correct behavior without having to think
about it.
getTask: reading the path, mapping absence to 404
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)
}
r.PathValue("id") reads the {id} segment out of the request's URL — the
routing chapter in Fundamentals covered registering patterns like
GET /tasks/{id} on a mux; this lesson only needs the value that produces,
not the routing itself. Everything after that is the comma-ok pattern
TaskStore.Get was built to support: ok tells getTask whether the
lookup succeeded, and the handler's entire job is translating that boolean
into an HTTP response. A hit becomes 200 with the task as JSON; a miss
becomes 404 via writeError, which wraps the message in the same JSON
error shape every handler in this book will use.
Notice what getTask does not do: it doesn't decide what "not found"
means at the storage layer — that was TaskStore.Get's job, and it already
returned a clean (Task, bool). The handler's only responsibility is the
HTTP-shaped translation of that result. Keep that boundary in mind as the
next lessons add the write side: createTask, updateTask, and
deleteTask will each take the same shape — call the store, translate the
result into a status code — with the interesting decisions (validation,
what counts as invalid) living in the handler because, unlike existence,
they're specific to HTTP.
Practice
1. List & Get Handlers
IntermediateTime to put HTTP in front of the store. Handlers hang off a
serverstruct that holds aStore— note it's the interface, not*TaskStore, so the handlers never care how tasks are stored (that's the point of Part 1's seam).Implement two methods:
(s *server) listTasks(w, r)— respond200with the JSON array froms.store.List(). An empty list must serialize as[], notnull(the store already returns a non-nil slice, sowriteJSONdoes the rest).(s *server) getTask(w, r)— read the id withr.PathValue("id"), look it up, and respond200with the task or404(viawriteError) if it's not there.
Use the
writeJSON/writeErrorhelpers from Chapter 1 — they're already here.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) { // TODO: respond 200 with the JSON array from s.store.List() } func (s *server) getTask(w http.ResponseWriter, r *http.Request) { // TODO: look up r.PathValue("id") in s.store; 200 + task, or 404 via writeError } func main() {}Sign in to write and run your solution in the browser.
Sign in for hints and the solution.