72. Capstone: a tested JSON API
Time to put the module together: a complete REST service with a concurrency-safe store, handlers that own their dependencies, honest status codes, and the tests that keep it that way.
Nothing here is new. It's modules 4 through 9, 11 and 12 arranged into the shape a Go service actually takes.
The store
Start below HTTP. The store knows nothing about requests, which is what makes it testable on its own:
package main
import (
"fmt"
"sort"
"sync"
)
type Item struct {
ID int `json:"id"`
Name string `json:"name"`
Price int `json:"price_cents"`
}
type Store struct {
mu sync.RWMutex
items map[int]Item
nextID int
}
func NewStore() *Store {
return &Store{items: make(map[int]Item), nextID: 1}
}
func (s *Store) Add(name string, price int) Item {
s.mu.Lock()
defer s.mu.Unlock()
it := Item{ID: s.nextID, Name: name, Price: price}
s.items[it.ID] = it
s.nextID++
return it
}
func (s *Store) Get(id int) (Item, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
it, ok := s.items[id]
return it, ok
}
func (s *Store) List() []Item {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Item, 0, len(s.items))
for _, it := range s.items {
out = append(out, it)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func (s *Store) Delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.items[id]; !ok {
return false
}
delete(s.items, id)
return true
}
func main() {
st := NewStore()
st.Add("bolt", 250)
st.Add("washer", 75)
st.Add("nut", 40)
fmt.Println("all:", st.List())
it, ok := st.Get(2)
fmt.Println("get 2:", it, ok)
_, ok = st.Get(99)
fmt.Println("get 99 found:", ok)
fmt.Println("delete 2:", st.Delete(2))
fmt.Println("delete 2 again:", st.Delete(2))
fmt.Println("after delete:", st.List())
}
all: [{1 bolt 250} {2 washer 75} {3 nut 40}]
get 2: {2 washer 75} true
get 99 found: false
delete 2: true
delete 2 again: false
after delete: [{1 bolt 250} {3 nut 40}]
Four decisions worth naming:
An HTTP server handles requests concurrently, so the store is shared mutable
state and needs the mutex from module 9. RWMutex because reads dominate:
List and Get take RLock and can run simultaneously, while Add and
Delete take the exclusive Lock.
List sorts before returning. Go randomises map iteration order
deliberately, so without the sort this endpoint would return items in a
different order on every call — and the tests below would fail intermittently,
which is the worst kind of failing test.
Get returns (Item, bool), the comma-ok shape from module 4, so "not
found" isn't confused with a zero-valued item. Delete returns a bool for the
same reason — the handler needs to know whether to answer 204 or 404.
List never returns nil. make([]Item, 0, ...) means an empty store
encodes as [] rather than null, which every client is happier about.
The service
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sort"
"strconv"
"strings"
"sync"
)
type Item struct {
ID int `json:"id"`
Name string `json:"name"`
Price int `json:"price_cents"`
}
type Store struct {
mu sync.RWMutex
items map[int]Item
nextID int
}
func NewStore() *Store {
return &Store{items: make(map[int]Item), nextID: 1}
}
func (s *Store) Add(name string, price int) Item {
s.mu.Lock()
defer s.mu.Unlock()
it := Item{ID: s.nextID, Name: name, Price: price}
s.items[it.ID] = it
s.nextID++
return it
}
func (s *Store) Get(id int) (Item, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
it, ok := s.items[id]
return it, ok
}
func (s *Store) List() []Item {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Item, 0, len(s.items))
for _, it := range s.items {
out = append(out, it)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func (s *Store) Delete(id int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.items[id]; !ok {
return false
}
delete(s.items, id)
return true
}
type Server struct {
store *Store
}
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 writeErr(w http.ResponseWriter, status int, code string) {
writeJSON(w, status, map[string]string{"error": code})
}
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.store.List())
}
func (s *Server) handleGet(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeErr(w, http.StatusBadRequest, "bad_id")
return
}
it, ok := s.store.Get(id)
if !ok {
writeErr(w, http.StatusNotFound, "not_found")
return
}
writeJSON(w, http.StatusOK, it)
}
func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) {
var in struct {
Name string `json:"name"`
Price int `json:"price_cents"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeErr(w, http.StatusBadRequest, "invalid_json")
return
}
if in.Name == "" {
writeErr(w, http.StatusBadRequest, "name_required")
return
}
it := s.store.Add(in.Name, in.Price)
w.Header().Set("Location", fmt.Sprintf("/items/%d", it.ID))
writeJSON(w, http.StatusCreated, it)
}
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeErr(w, http.StatusBadRequest, "bad_id")
return
}
if !s.store.Delete(id) {
writeErr(w, http.StatusNotFound, "not_found")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /items", s.handleList)
mux.HandleFunc("POST /items", s.handleCreate)
mux.HandleFunc("GET /items/{id}", s.handleGet)
mux.HandleFunc("DELETE /items/{id}", s.handleDelete)
return mux
}
func call(h http.Handler, method, path, body string) {
var req *http.Request
if body == "" {
req = httptest.NewRequest(method, path, nil)
} else {
req = httptest.NewRequest(method, path, strings.NewReader(body))
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
fmt.Printf("%-6s %-12s %d %s", method, path, rec.Code, rec.Body.String())
if rec.Body.Len() == 0 {
fmt.Println()
}
}
func main() {
srv := &Server{store: NewStore()}
h := srv.Routes()
call(h, "POST", "/items", `{"name":"bolt","price_cents":250}`)
call(h, "POST", "/items", `{"name":"washer","price_cents":75}`)
call(h, "POST", "/items", `{"price_cents":10}`)
call(h, "GET", "/items", "")
call(h, "GET", "/items/1", "")
call(h, "GET", "/items/99", "")
call(h, "GET", "/items/abc", "")
call(h, "DELETE", "/items/1", "")
call(h, "GET", "/items", "")
}
POST /items 201 {"id":1,"name":"bolt","price_cents":250}
POST /items 201 {"id":2,"name":"washer","price_cents":75}
POST /items 400 {"error":"name_required"}
GET /items 200 [{"id":1,"name":"bolt","price_cents":250},{"id":2,"name":"washer","price_cents":75}]
GET /items/1 200 {"id":1,"name":"bolt","price_cents":250}
GET /items/99 404 {"error":"not_found"}
GET /items/abc 400 {"error":"bad_id"}
DELETE /items/1 204
GET /items 200 [{"id":2,"name":"washer","price_cents":75}]
That's a working REST API, and the interesting parts are structural.
Handlers are methods on Server. handleGet reaches its store through the
receiver, so there are no globals and nothing to initialise in an init. Swap
the in-memory store for a database-backed one implementing the same methods and
not one handler changes — which is the interface lesson from module 6 paying
off at the scale of a whole program.
Routes() returns an http.Handler, not a *ServeMux. The caller doesn't
need to know how routing happens, and the return type is exactly what the
server, the middleware, and the tests all want.
Every path parameter is untrusted input. /items/abc is a 400 because
strconv.Atoi failed, not a 500 from something panicking downstream. The
distinction from lesson 3: the client can fix this one.
204 No Content for the delete — success, and deliberately no body, which
is why that line has nothing after the status.
Wrap the whole thing in the middleware from lessons 4 and 5 and hand it to the server from lesson 6, and it's deployable:
func main() {
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
srv := &Server{store: NewStore()}
handler := recovery(requestLogger(log, srv.Routes()))
httpSrv := &http.Server{
Addr: ":8080",
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
// ... and the rest of the timeouts
}
// ... signal.NotifyContext, ListenAndServe, Shutdown
}
Testing it
Here's the payoff for having used httptest since lesson 1: the tests look
exactly like the code you've already been writing. Routes() returns a
handler, and a handler can be driven directly — no server, no ports, no
fixtures, nothing to clean up.
// main_test.go, next to the service
func newTestServer() http.Handler {
return (&Server{store: NewStore()}).Routes()
}
func TestGetItem(t *testing.T) {
srv := &Server{store: NewStore()}
srv.store.Add("bolt", 250)
h := srv.Routes()
tests := []struct {
name string
path string
wantStatus int
}{
{"existing item", "/items/1", http.StatusOK},
{"missing item", "/items/99", http.StatusNotFound},
{"not a number", "/items/abc", http.StatusBadRequest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", tt.path, nil))
if rec.Code != tt.wantStatus {
t.Errorf("GET %s = %d; want %d", tt.path, rec.Code, tt.wantStatus)
}
})
}
}
func TestCreateItem(t *testing.T) {
h := newTestServer()
body := strings.NewReader(`{"name":"bolt","price_cents":250}`)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("POST", "/items", body))
if rec.Code != http.StatusCreated {
t.Fatalf("POST /items = %d; want %d", rec.Code, http.StatusCreated)
}
var got Item
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decoding response: %v", err)
}
if got.Name != "bolt" || got.ID == 0 {
t.Errorf("POST /items returned %+v; want a bolt with a non-zero id", got)
}
}
The table-driven shape is module 12's, unchanged. Three things specific to testing HTTP:
- Give each test its own
Serverwith a freshNewStore(). Tests that share a store depend on execution order, andgo testis free to reorder or parallelise them. - Decode the body, don't string-compare it.
Encodeadds a newline, key order is an implementation detail, and comparing structs gives a far better failure message than comparing JSON text. - Assert the status first, with
Fatalf. If the status is wrong the body is usually an error envelope, and decoding it into anItemproduces a confusing second failure that hides the first.
Test binaries can't run in this lesson player, so the box above is
reference-only. The same checks can run here without the testing package —
which is exactly what module 12's first lesson did by hand:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
func newMux() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
})
mux.HandleFunc("POST /echo", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Content-Type") != "application/json" {
http.Error(w, "want json", http.StatusUnsupportedMediaType)
return
}
w.WriteHeader(http.StatusCreated)
})
return mux
}
func main() {
h := newMux()
cases := []struct {
name string
method string
path string
contentType string
wantStatus int
}{
{"health ok", "GET", "/health", "", 200},
{"echo needs json", "POST", "/echo", "text/plain", 415},
{"echo accepts json", "POST", "/echo", "application/json", 201},
{"unknown route", "GET", "/nope", "", 404},
}
failed := 0
for _, c := range cases {
req := httptest.NewRequest(c.method, c.path, strings.NewReader(""))
if c.contentType != "" {
req.Header.Set("Content-Type", c.contentType)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != c.wantStatus {
failed++
fmt.Printf("--- FAIL: %s\n %s %s = %d; want %d\n",
c.name, c.method, c.path, rec.Code, c.wantStatus)
continue
}
fmt.Printf("--- PASS: %s\n", c.name)
}
if failed > 0 {
fmt.Printf("FAIL (%d of %d)\n", failed, len(cases))
return
}
fmt.Println("PASS")
}
--- PASS: health ok
--- PASS: echo needs json
--- PASS: echo accepts json
--- PASS: unknown route
PASS
Change a wantStatus and watch it fail — a test you've never seen fail is a
test you don't know works.
The rules, collected
- Handlers are methods on a struct that holds their dependencies. No
globals, no
init. Routes()returnshttp.Handler, so routing stays an implementation detail and the whole service composes.- Validate every input from the path, query and body — and answer
4xx, not5xx, when the client can fix it. - One
writeJSONhelper. Header, then status, then body, in that order, once, everywhere. - Shared state is behind a mutex, because the server is concurrent whether you thought about it or not.
- Sort anything derived from a map before returning it.
- Test through
Routes()withhttptest— real routing, real status codes, no network.
Your turn
Add a search endpoint. GET /search?q=<term> responds 200 with
{"query":"<term>","matches":[...]}, matching any name containing the term.
With no q, respond 400 with {"error":"q_required"}.
Search the fixed list in the code. strings.Contains is all you need — and
start found as an empty slice, not nil, so no matches encodes as [].
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
type Server struct {
items map[string]int
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
// no "q" -> 400 {"error":"q_required"}
// else -> 200 {"query":<q>,"matches":[names containing q]}
// search: "bolt", "washer", "nut", "bolt cutter"
}
func main() {
srv := &Server{}
mux := http.NewServeMux()
mux.HandleFunc("GET /search", srv.handleSearch)
for _, u := range []string{"/search?q=bolt", "/search?q=zzz", "/search"} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", u, nil))
fmt.Printf("%-18s %d %s", u, rec.Code, rec.Body.String())
}
}
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
type Server struct {
items map[string]int
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
if q == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "q_required"})
return
}
found := []string{}
for _, name := range []string{"bolt", "washer", "nut", "bolt cutter"} {
if strings.Contains(name, q) {
found = append(found, name)
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{"query": q, "matches": found})
}
func main() {
srv := &Server{}
mux := http.NewServeMux()
mux.HandleFunc("GET /search", srv.handleSearch)
for _, u := range []string{"/search?q=bolt", "/search?q=zzz", "/search"} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", u, nil))
fmt.Printf("%-18s %d %s", u, rec.Code, rec.Body.String())
}
}
That's the module, and the course. You can write a Go service that routes, speaks JSON, logs usefully, survives a panic, shuts down without dropping a request, and has tests that prove it.