Building a REST API
This is the payoff phase. Everything so far has been a separate piece on the
workbench — the router from Phase 2, the middleware stack from Phase 3, the
JSON read/write helpers from Phase 4. Now we bolt them together into a real,
working REST API for the articles resource. By the end you'll have full CRUD
(create, read, update, delete) that you can hit with curl.
Here's the thing to hold in your head before any code:
📝 A REST resource is just five plain
http.HandlerFuncs over one collection, mounted on a sub-router. That's the whole mental model. List, get-one, create, update, delete — five functions with the identical signaturefunc(w http.ResponseWriter, r *http.Request). There's no framework "context" object, no special base class, no magic. It's the same conceptual shape you'd draw for Gin or Echo, but here every handler is pure standard library plus chi's router doing the method-and-path matching. If you understand these five, you understand REST in Go.
Let's build it from the inside out: first the place the data lives, then the five handlers, then the routing that wires them up.
The store: where the articles live
Before we can serve articles, we need somewhere to keep them. In a real app this
is a database. To keep this phase about the API and not about SQL, we'll use an
in-memory store: a map from ID to Article, plus a counter for the next ID.
But there's a trap here that catches people, so let's name it loudly.
⚠️ net/http serves every request on its own goroutine. That means two
requests can hit your store at the same time — one creating an article while
another lists them. A plain Go map is not safe for concurrent
read/write; do it unguarded and you'll get a runtime panic ("concurrent map
writes") under load, the kind of bug that never shows up in local testing and
then takes your server down in production. The fix is a sync.RWMutex guarding
every access.
What just happened: Article is the same struct from Phase 4 — plain fields
with JSON tags. store wraps the map together with the mutex that protects it
and the nextID counter. Keeping the mutex next to the data it guards (rather
than as a loose global) is the idiomatic Go move: it's obvious what the lock
protects. newStore hands back a ready-to-use store with an empty map and IDs
starting at 1.
Now the store's methods. The rule of thumb: take a read lock (RLock) when
you're only looking, take a write lock (Lock) when you're changing
anything. Read locks can be held by many goroutines at once; a write lock is
exclusive.
What just happened: five small methods, each one locking before it touches the
map and defer-ing the unlock so it always releases even if something returns
early. list and get use RLock (read-only). create, update, and
delete use Lock (they mutate). Notice the (value, bool) pattern on
get, update, and delete: the bool says "did it exist?" — that's how the
handlers will know whether to return a 404. create builds the Article with
the server-assigned ID, never trusting the client to pick one.
💡 The methods returning
boolinstead of anerroris deliberate: "not found" isn't really an error here, it's a normal outcome the handler maps to a 404. We'll keep error-shaped failures (like a bad ID in the URL) at the handler layer.
The five handlers
Now the heart of it. Each handler is a closure over the store so it can reach the
data, and each one is a plain func(w http.ResponseWriter, r *http.Request). We
reuse the writeJSON helper from Phase 4 verbatim — here it is again so this
phase stands alone:
What just happened: exactly the Phase 4 helper — set the header, write the status, encode the body, in that locked order. We won't re-explain it; the point is that all five handlers respond through it and never repeat that dance.
We'll also need one tiny shared step: pulling the id out of the URL and turning
it into an int. Three of the five handlers do this, so look at it once here and
recognize it when it reappears:
chi
strconv
if err != nil
What just happened: chi.URLParam(r, "id") reads the {id} segment from the
path (chi's one I/O helper). It always hands back a string, so
strconv.Atoi converts it to an int. If the URL had /articles/abc, Atoi
fails and we return 400 — a non-numeric id is the client's mistake, not the
server's.
Now the handlers themselves.
list — GET the whole collection (200)
.HandlerFunc
What just happened: the simplest one. listArticles is a function that
returns a handler (a closure capturing s). The handler asks the store for
every article and writes them as a JSON array with 200 OK. Even when the list
is empty, list() returns a non-nil empty slice, so the client gets [], not
null — a small kindness that keeps clients from special-casing the empty case.
get — GET one by id (200 or 404)
.HandlerFunc
What just happened: parse the id (400 if it's not a number), then ask the
store. The store's bool does the work: if ok is false, the article doesn't
exist and we return 404 and stop. Otherwise we write the single article with
200. This is the canonical get-one shape — two ways to fail, one way to
succeed.
create — POST a new one (201, with decode + manual validation)
.HandlerFunc
}
What just happened: the busiest handler, and it earns it. First, decode the
request body into an input struct (in) — note it has no ID field, because
the client doesn't get to choose the id. A decode failure is malformed JSON, so
400. Next, the part the stdlib won't do for you: validation by hand. We
check in.Title == "" and reject empty titles with a 400. (Add more checks here
as your rules grow — there's no validator unless you bring one.) Finally,
s.create stores it with a fresh server-assigned ID and we reply 201
Created with the full new article, so the client learns the id it was given.
update — PUT to replace one (200 or 404)
.HandlerFunc
}
What just happened: update is "get-one and create had a baby" — it parses the
id and decodes a body, validating both. The store's update returns the same
(value, bool): if the id doesn't exist, 404; otherwise the article is
replaced and we return the updated version with 200. We use PUT here,
meaning "replace the whole article with this." (A partial update — change only
the title, leave the body — would be PATCH, which is fiddlier because you have
to distinguish "field omitted" from "field set to empty"; PUT sidesteps that.)
delete — DELETE one (204 or 404)
.HandlerFunc
What just happened: parse the id, ask the store to delete. If it wasn't there,
404. If it was, we set 204 No Content and write nothing — no
writeJSON, no body at all, because 204 means "success, and there's nothing to
send back." (This is the empty-body rule from Phase 4; deletes are its textbook
use.)
Wiring it up with a sub-router
Five handlers, and now the routing that connects HTTP methods and paths to them.
This is where Phase 2's r.Route shines: we mount the whole resource under one
path prefix and nest the per-id routes inside it.
What just happened: one store, one router, and the resource laid out as a tree.
The outer r.Route("/api/v1/articles", ...) groups everything under that prefix.
Inside it, Get("/") and Post("/") handle the collection (/api/v1/articles
itself) — list and create. The nested r.Route("/{id}", ...) handles a single
item (/api/v1/articles/42), with Get/Put/Delete mapping to get/update/
delete. Read the registration top to bottom and it is the REST table — methods
on the left, handlers on the right, paths from the nesting. middleware.Logger
(from Phase 3) wraps the whole thing so every request gets logged. Each handler
is called with s to produce the actual http.HandlerFunc, threading the shared
store into all five.
💡 The version prefix
/api/v1/is a cheap insurance policy. When you eventually ship a breaking change, you add/api/v2/alongside it and old clients keep working. Costs you nothing today; saves you a migration headache later.
Driving it with curl
Start the server and exercise the whole lifecycle. Here's the tour, request and response side by side:
# Create an article -> 201
# List them all -> 200
# Get one by id -> 200
# Update it -> 200
# Delete it -> 204 (no body). Show the status code to prove it:
# Ask for it again -> 404
What just happened: the full CRUD cycle, every status code from our handlers
showing up exactly where designed. Create gave a 201 and echoed back the id the
server assigned. List returned a JSON array. The DELETE returns no body, so we
used -w '%{http_code}' to print the bare status (204) and confirm it. The
final GET after the delete returns the 404 plain-text message from
http.Error — proof the article is really gone. If you POST a body with no
title, you'll get a 400 ("title is required"); if you GET
/api/v1/articles/abc, you'll get a 400 ("id must be a number").
The store is a stand-in
One last point, and it's the important one for where you're headed.
💡 That in-memory store is a database stand-in. We used a map + mutex so this phase could be about the API shape without dragging in SQL. But look at the five handlers: not one of them knows or cares that the data lives in a map. They call
s.list(),s.get(id),s.create(...),s.update(...),s.delete(id)— five methods. Swap the store's insides for real persistence with GORM and those five methods become database queries, while the handlers, the routing, and the validation barely change. The mutex disappears (the database handles concurrency), but the seams you've drawn here are exactly the seams a real app uses. That's not an accident — it's why we separated the store from the handlers in the first place.
The next phase makes that separation official: how to lay out handlers and
services in real files, how to pass dependencies cleanly with context, and how
to test all of this with httptest so you never have to curl by hand again.
Recap
- A REST resource is five plain
http.HandlerFuncs over one collection, mounted on a sub-router — same shape as Gin/Echo, but pure stdlib + chi routing, no framework context. - The in-memory store is a
map[int]Articleguarded by async.RWMutex. ⚠️net/httpserves requests concurrently, so an unguarded map will panic —RLockto read,Lockto write. - Read the id with
chi.URLParam(r, "id")thenstrconv.Atoi(400 if it's not a number); decode bodies withjson.NewDecoder(r.Body).Decode; reply through the Phase-4writeJSONhelper. - Status codes map cleanly: list/get/update 200, create 201, delete 204 (no body), missing item 404, bad input 400. Validation is by hand — the stdlib won't do it for you.
- The
(value, bool)pattern from the store methods is what drives the 404 decision in the handlers. - The store is a database stand-in — swap in GORM later and the handlers barely change, because the store/handler seam is the real one.
Quick check
[
{
"q": "Why must the in-memory map be guarded by a sync.RWMutex?",
"choices": ["Maps are slow without a lock", "net/http serves each request on its own goroutine, so concurrent map writes would panic", "chi requires a mutex on every handler", "It makes JSON encoding thread-safe"],
"answer": 1,
"explain": "net/http handles requests concurrently on separate goroutines. A plain Go map is not safe for concurrent read/write and will panic, so every access is guarded — RLock to read, Lock to write."
},
{
"q": "A successful DELETE handler should return which status, and with what body?",
"choices": ["200 OK with the deleted article as JSON", "404 Not Found with no body", "204 No Content with no body at all", "201 Created with an empty object"],
"answer": 2,
"explain": "A successful delete returns 204 No Content and writes nothing — 204 means success with no body, so you set the status and stop (no writeJSON)."
},
{
"q": "In createArticle, why decode into a small input struct with only Title and Body instead of straight into Article?",
"choices": ["Article has too many fields to decode", "encoding/json can't decode into a struct with an int field", "The client doesn't get to pick the id — the server assigns it, so ID isn't accepted from the body", "It makes the response faster"],
"answer": 2,
"explain": "The input struct is 'what the client may send.' Leaving ID out means the client can't set it; the server assigns the id in s.create, keeping it authoritative."
}
]
← Phase 4: Requests & Responses with the Standard Library · Guide overview · Phase 6: Structuring & Testing →
Check your understanding
1. Why must the in-memory map be guarded by a sync.RWMutex?
2. A successful DELETE handler should return which status, and with what body?
3. In createArticle, why decode into a small input struct with only Title and Body instead of straight into Article?