Day 53 — REST design
Day 53 — REST design
Stage VI · ~3h
Goal: Design a versioned JSON REST API on stdlib ServeMux, backed by your repository—clear resources, status codes, pagination basics, and consistent response envelopes.
Why this day exists
CRUD handlers without REST thinking become ad-hoc RPC-over-HTTP:
- Verbs in paths (
/getItem)
- Always HTTP 200 with error in body
- No resource identifiers
- Breaking changes without versioning
Today you align HTTP semantics with domain operations so Day 54 auth and Day 55 errors attach cleanly.
Theory 1 — Resources, not actions
| Prefer | Avoid |
|---|---|
GET /v1/items |
GET /v1/getItems |
GET /v1/items/{id} |
GET /v1/item?id= only |
POST /v1/items |
POST /v1/createItem |
DELETE /v1/items/{id} |
POST /v1/items/delete |
Collections vs items:
- Collection:
/v1/items
- Member:
/v1/items/{id}
- Subresource:
/v1/items/{id}/tags
Nouns scale; verb paths invent a private RPC dialect per team.
Theory 2 — Status code cheat sheet
| Code | When |
|---|---|
| 200 | OK with body |
| 201 | Created (often + Location header) |
| 204 | OK no body (delete/update) |
| 400 | Malformed / validation failed |
| 401 | Unauthenticated |
| 403 | Authenticated but not allowed |
| 404 | Missing resource |
| 409 | Conflict (duplicate id) |
| 415 | Unsupported Content-Type |
| 422 | Semantic validation (optional; some teams use 400) |
| 429 | Rate limit |
| 500 | Unexpected server bug |
| 503 | Dependency down |
Do not invent “200 OK + {"success":false}” as the primary pattern. Clients, load balancers, and caches all understand status codes better than inventing a second protocol inside JSON.
Theory 3 — Versioning strategies
| Strategy | Example | Notes |
|---|---|---|
| URL prefix | /v1/items |
Simple, explicit—use this today |
| Header | Accept: application/vnd.myapp.v1+json |
Flexible, harder to explore |
| No version | /items |
OK until first break—still learn v1 habit |
Breaking change → /v2. Additive fields are usually non-breaking if clients ignore unknown JSON keys. Removing or renaming fields is breaking.
Theory 4 — Response shapes
Success
{
"id": "01H...",
"name": "widget",
"created_at": "2026-07-27T12:00:00Z"
}List + pagination (basic)
{
"items": [ ... ],
"next_cursor": "abc",
"limit": 20
}Cursor pagination beats large OFFSET for big tables—mention even if you implement only limit today.
limit := 20
if v := r.URL.Query().Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid limit")
return
}
if n > 100 {
n = 100
}
limit = n
}Errors (preview Day 55)
{
"error": {
"code": "not_found",
"message": "item not found"
}
}Stay consistent across handlers—copy one helper, not six shapes.
Theory 5 — Idempotency & methods
| Method | Idempotent? | Safe? |
|---|---|---|
| GET | yes | yes |
| PUT | yes | no |
| DELETE | yes | no |
| POST | no (usually) | no |
| PATCH | often conditional | no |
PUT can mean “replace resource at id” (client-supplied id). POST “create under collection” (server id). Pick and document.
Idempotency keys for POST (stretch): client sends Idempotency-Key; server stores request hash → response for replay. Awareness is enough today.
Theory 6 — DTO vs domain vs DB
HTTP JSON (DTO) ↔ domain/service ↔ SQL row
Do not leak DB column names or internal flags to clients without intent. Map explicitly:
type ItemDTO struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
}
func toDTO(it store.Item) ItemDTO {
return ItemDTO{
ID: it.ID, Name: it.Name,
CreatedAt: it.CreatedAt.UTC().Format(time.RFC3339),
}
}Worked example — wiring repo to HTTP
func (s *Server) routes() {
s.mux.HandleFunc("GET /v1/items", s.listItems)
s.mux.HandleFunc("POST /v1/items", s.createItem)
s.mux.HandleFunc("GET /v1/items/{id}", s.getItem)
s.mux.HandleFunc("DELETE /v1/items/{id}", s.deleteItem)
s.mux.HandleFunc("GET /healthz", s.health)
}
func (s *Server) getItem(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
it, err := s.repo.Get(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeErr(w, http.StatusNotFound, "not_found", "item not found")
return
}
s.log.Error("get item", "err", err, "id", id)
writeErr(w, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, http.StatusOK, toDTO(it))
}
func (s *Server) createItem(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := readJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
if strings.TrimSpace(req.Name) == "" {
writeErr(w, http.StatusBadRequest, "validation", "name is required")
return
}
id := newID()
if err := s.repo.Create(r.Context(), id, req.Name); err != nil {
s.log.Error("create item", "err", err)
writeErr(w, http.StatusInternalServerError, "internal", "internal error")
return
}
w.Header().Set("Location", "/v1/items/"+id)
writeJSON(w, http.StatusCreated, ItemDTO{ID: id, Name: req.Name})
}Helpers (readJSON, writeJSON, writeErr) should live once in the api package.
Lab 1 — Compose service
mkdir -p ~/lab/90daysofx/01-go/day53
cd ~/lab/90daysofx/01-go/day53
go mod init example.com/day53Integrate: migrations (Day 52) + repo (Day 51) + HTTP (Day 45 style) + slog. Prefer real SQLite file or :memory: with migrations applied at startup for local demo.
Lab 2 — REST compliance checklist
For each endpoint, document in README:
- Method + path
- Request body schema
- Success status + body
- Error statuses
Implement list with ?limit= (default 20, max 100).
Lab 3 — Tests as contract
httptest tables:
- Create → 201 + Location
- Get → 200
- Get missing → 404 + error code
- Invalid JSON → 400
- List limit enforcement (101 → clamped or 400—document which)
go test ./api/ -race -count=1Lab 4 — Conflict path
Unique name constraint (optional unique index migration). Second create → 409 with code=conflict. Map store.ErrConflict carefully; never return raw SQL UNIQUE text to clients.
Lab 5 — Curl script
scripts/smoke.sh exercising happy path. Runnable after go run:
#!/usr/bin/env bash
set -euo pipefail
base=${1:-http://127.0.0.1:8080}
curl -sf "$base/healthz" >/dev/null
id=$(curl -sf -X POST "$base/v1/items" -H 'Content-Type: application/json' \
-d '{"name":"smoke"}' | jq -r .id)
curl -sf "$base/v1/items/$id" | jq .
echo OKCommon gotchas
| Gotcha | Fix |
|---|---|
| RPC path names | Resource nouns |
| 200 for errors | Proper status codes |
| Leaking SQL errors to clients | Map to stable codes |
| Unbounded list | limit/cursor |
| Mixing v0 unversioned breakages | /v1 prefix now |
| Wrong method semantics | Follow table |
Forgetting Location on 201 |
Set header |
| Scanning into DTO from SQL directly | Map at boundary |
Checkpoint
- Versioned routes under
/v1
- Status codes match cheat sheet
- Repo errors mapped to HTTP
- Pagination limit enforced
- Contract tests green
- README endpoint table
Commit
git add .
git commit -m "day53: versioned REST JSON API over repository"Write three personal gotchas before continuing.
Tomorrow
Day 54 — auth basics: protect routes with sessions or JWT—authentication vs authorization, middleware, and safe defaults.