A Books API in One File: The Standard Library, Assembled

Building and running a small JSON REST service on the 1.22 ServeMux — GET/POST/GET-by-id/DELETE, an in-memory store behind an RWMutex, logging and panic-recovery middleware, request-scoped context, and go:embed serving a static page. Table-driven httptest tests, run. Compiled and run against Go 1.26.5.

Everything in this track so far has been a piece: routing, JSON, middleware, mutexes, testing. This last chapter puts them in one place and runs the result. We’ll build a small JSON REST service for a collection of books — list, create, fetch, delete — backed by an in-memory store, wrapped in logging and panic-recovery middleware, serving a static page from an embedded file, and covered by tests you can actually run. No web framework, no router library, no ORM. Just the standard library, which is enough. Every line of code, every response, and every test result below was compiled and run against Go 1.26.5.

The store, and why it needs a lock

The data lives in a map, and an HTTP server handles requests concurrently — each connection gets its own goroutine. That’s the exact setup the concurrency series warned about: multiple goroutines reaching for the same map, at least one of them writing. A map is not safe for concurrent use, and Go’s race detector will say so. So the store carries a lock.

We reach for a sync.RWMutex rather than a plain Mutex because reads dominate: listing and fetching books happen far more than creating or deleting them, and an RWMutex lets any number of readers hold it at once while a writer takes it alone.

type Store struct {
	mu     sync.RWMutex
	books  map[int]Book
	nextID int
}

func (s *Store) List() []Book {
	s.mu.RLock()
	defer s.mu.RUnlock()
	out := make([]Book, 0, len(s.books))
	for _, b := range s.books {
		out = append(out, b)
	}
	return out
}

func (s *Store) Add(b Book) Book {
	s.mu.Lock()
	defer s.mu.Unlock()
	b.ID = s.nextID
	s.nextID++
	s.books[b.ID] = b
	return b
}

RLock/RUnlock for the reads (List, Get), the exclusive Lock/Unlock for the writes (Add, Delete). Each method takes the lock, defers the unlock, and touches the map only inside — the discipline that keeps the data race-free. Add also owns ID assignment, which is why it holds the write lock: incrementing nextID and inserting must be one indivisible step.

Routing on the standard-library mux

Since Go 1.22 the built-in http.ServeMux understands methods and path wildcards, which is all this API needs. Each route names an HTTP method and a pattern; {id} captures a path segment you read back with r.PathValue.

func NewServer(s *Store) http.Handler {
	mux := http.NewServeMux()
	mux.Handle("GET /{$}", http.FileServerFS(static))
	mux.HandleFunc("GET /books", s.handleList)
	mux.HandleFunc("POST /books", s.handleCreate)
	mux.HandleFunc("GET /books/{id}", s.handleGet)
	mux.HandleFunc("DELETE /books/{id}", s.handleDelete)
	return requestID(logging(recoverMW(mux)))
}

GET /books and POST /books are distinct routes to distinct handlers, so the method dispatch you’d have written by hand is gone. A request that matches the path but not the method — a PUT /books — gets an automatic 405 Method Not Allowed from the mux, for free. NewServer returns an http.Handler rather than starting anything, which is what makes the whole thing testable: the tests construct exactly this handler and drive it in memory.

Handlers: JSON in, JSON out, honest status codes

A handler’s job is to translate between HTTP and the store, and to pick the right status code. A tiny helper keeps the JSON-writing consistent:

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}

The order matters and bites people: set headers first, call WriteHeader with the status second, write the body last. Once any bytes are written the status is locked in, so a WriteHeader after the body has no effect. Create decodes the request body, validates it, and reports 201 Created on success or 400 Bad Request on malformed input:

func (s *Store) handleCreate(w http.ResponseWriter, r *http.Request) {
	var b Book
	if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
		return
	}
	if b.Title == "" {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title is required"})
		return
	}
	writeJSON(w, http.StatusCreated, s.Add(b))
}

Fetch parses the {id} wildcard, returns 400 if it isn’t an integer, 404 if there’s no such book, and 200 with the record otherwise:

func (s *Store) handleGet(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.Atoi(r.PathValue("id"))
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id must be an integer"})
		return
	}
	b, ok := s.Get(id)
	if !ok {
		writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such book"})
		return
	}
	writeJSON(w, http.StatusOK, b)
}

Delete returns 204 No Content — success, nothing to say — with an empty body, or 404 if the id was never there. Picking status codes deliberately is what separates an API from a pile of handlers that always answer 200.

Middleware: logging, recovery, and a request-scoped id

Middleware is a function that wraps an http.Handler and returns another http.Handler, so you can stack cross-cutting concerns without touching a single handler. This service stacks three. The outermost stamps every request with an id and puts it in the request’s context:

type ctxKey int

const requestIDKey ctxKey = 0

func requestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := counter.Add(1)
		ctx := context.WithValue(r.Context(), requestIDKey, id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

Two details are load-bearing. The key type ctxKey is unexported, so no other package can collide with our context key even by accident — this is the standard way to use context.WithValue, never a bare string. And r.WithContext(ctx) produces a shallow copy of the request carrying the new context, because a request’s context is request-scoped by design: it lives and dies with that one request, and it’s how a value set at the edge reaches a handler deep inside without a global.

The logging middleware reads that id back out and records the outcome. To log the status it wraps the ResponseWriter in a small recorder, since the plain interface won’t tell you what code a handler wrote:

func logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		sr := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
		next.ServeHTTP(sr, r)
		id, _ := r.Context().Value(requestIDKey).(int64)
		log.Printf("#%d %s %s -> %d (%s)", id, r.Method, r.URL.Path, sr.status, time.Since(start))
	})
}

The innermost wrapper is panic recovery, and it’s the one that earns its keep in production. A panic in a handler goroutine, left alone, kills that goroutine and drops the connection with no response. recover inside a deferred function turns it into a clean 500 instead:

func recoverMW(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if err := recover(); err != nil {
				log.Printf("panic: %v", err)
				writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
			}
		}()
		next.ServeHTTP(w, r)
	})
}

Verified against a handler that panics on purpose: the request comes back 500, the panic is logged, and the server keeps serving. One bad handler no longer takes the process down with it.

go:embed bakes the static page into the binary

The //go:embed directive folds files into the compiled binary at build time, so the single executable you ship carries its own assets — no separate static/ directory to deploy alongside it, nothing to lose in transit. Point it at a file (or a whole tree) and bind the result to a variable:

import "embed"

//go:embed index.html
var static embed.FS

The embed.FS is a read-only filesystem, which http.FileServerFS serves directly — that’s the GET /{$} route above, where {$} matches only the exact root path / and not everything under it. Build the binary, run it anywhere, and the homepage is inside it.

Tests you run, not tests you trust on faith

The reason NewServer returns a handler is this: net/http/httptest can drive that handler entirely in memory, no network, no ports. httptest.NewRequest builds a request, httptest.NewRecorder captures the response, and the table lists a case per route and outcome:

func TestBooksAPI(t *testing.T) {
	store := NewStore()
	store.Add(Book{Title: "Existing", Author: "Someone"}) // id 1
	srv := NewServer(store)

	tests := []struct {
		name       string
		method     string
		path       string
		body       string
		wantStatus int
		wantBody   string
	}{
		{"list ok", "GET", "/books", "", http.StatusOK, `"Existing"`},
		{"get existing", "GET", "/books/1", "", http.StatusOK, `"id":1`},
		{"get missing", "GET", "/books/999", "", http.StatusNotFound, "no such book"},
		{"get bad id", "GET", "/books/abc", "", http.StatusBadRequest, "must be an integer"},
		{"create ok", "POST", "/books", `{"title":"Dune","author":"Herbert"}`, http.StatusCreated, `"id":2`},
		{"create no title", "POST", "/books", `{"author":"Nobody"}`, http.StatusBadRequest, "title is required"},
		{"create bad json", "POST", "/books", `{oops`, http.StatusBadRequest, "invalid JSON"},
		{"delete ok", "DELETE", "/books/1", "", http.StatusNoContent, ""},
		{"delete missing", "DELETE", "/books/1", "", http.StatusNotFound, "no such book"},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
			rec := httptest.NewRecorder()
			srv.ServeHTTP(rec, req)
			if rec.Code != tc.wantStatus {
				t.Errorf("status = %d, want %d", rec.Code, tc.wantStatus)
			}
			if tc.wantBody != "" && !strings.Contains(rec.Body.String(), tc.wantBody) {
				t.Errorf("body = %q, want it to contain %q", rec.Body.String(), tc.wantBody)
			}
		})
	}
}

Run it:

$ go test
ok  	example.com/bookshop	0.293s

All nine cases pass, each as its own named subtest, covering the happy paths and the failure modes side by side. That’s the whole point of the table: one delete succeeds with 204, the next delete of the same id returns 404, and both are asserted in the same run.

Running it for real

Tests exercise the handler in memory; the binary is the real thing. Build it, start it, and talk to it:

$ go build -o books . && ./books
2026/07/31 16:25:36 listening on :8080

A create and the fetch that follows, over real HTTP:

$ curl -i -X POST localhost:8080/books -d '{"title":"Dune","author":"Frank Herbert"}'
HTTP/1.1 201 Created
Content-Type: application/json
Content-Length: 49

{"id":2,"title":"Dune","author":"Frank Herbert"}

$ curl -s localhost:8080/books/2
{"id":2,"title":"Dune","author":"Frank Herbert"}

And on the server side, the logging middleware printing the request id, method, path, status, and duration for each:

#3 POST /books -> 201 (72.083µs)
#4 GET /books/2 -> 200 (11.235µs)
#5 DELETE /books/1 -> 204 (4.911µs)
#6 GET /books/999 -> 404 (56.782µs)

A working JSON API — routed, locked, logged, recovering from panics, serving an embedded page, and tested — in one file and one binary, on nothing but the standard library.

Final thoughts

Look back at what did the work here, because none of it was a dependency. Routing was http.ServeMux, method-aware since 1.22. The store was a map and a sync.RWMutex, the same lock the concurrency series taught. JSON was encoding/json, one decoder in and one encoder out. Middleware was just functions wrapping http.Handler, and the request id rode through on context. The static page was //go:embed, folded into the binary. And the tests were net/http/httptest, driving the real handler in memory. That’s the through-line of this whole series: Go’s standard library is broad and coherent enough to build real things without reaching for a framework first, and knowing it well is most of what “knowing Go” means.

There’s a next layer, though — the one between works on my machine and runs in production — and it’s a series of its own. Go in Production picks up exactly where this binary stops: structured logging with slog in place of log.Printf, pulling configuration out of the code, shutting the server down gracefully so in-flight requests finish instead of dropping, profiling with pprof when something is slow or leaking, building and deploying the binary, and wiring up the observability that tells you what it’s doing once real traffic arrives. The service you built here is the thing that series will harden. You’ve got the language and its library now; that’s the foundation the rest stands on.

Comments