Day 53 — REST design

Updated

July 30, 2026

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/day53

Integrate: 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=1

Lab 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 OK

Common 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.