Day 86 — Capstone build 4 (API / CLI surface)
Day 86 — Capstone build 4 (API / CLI surface)
Stage VIII · ~3h · Capstone day 4/6
Goal: Complete the user-facing surface defined in the design doc—routes or commands, validation, auth (if planned), consistent errors, and help text—mapped cleanly onto application use cases. Go 1.26.
Why this day exists
Domain + DB without a finished surface cannot demo. Today is edge-adapter day: HTTP/CLI only talks to application services. Business rules stay in app/domain (Days 84–85); the surface translates DTOs, status codes, and flags.
Daily goals by option
Option A — Agent API
| Surface | Notes |
|---|---|
GET /healthz /readyz |
Ready when collector loop running / store open |
GET /v1/snapshot |
Latest sample |
GET /v1/history |
Last N samples (cap N) |
| Optional auth | Shared bearer token from config |
| Optional control | POST /v1/collect force sample—validate carefully |
Document that control plane is local/trusted unless auth is on.
Option B — Microservice API
| Surface | Notes |
|---|---|
| CRUD or workflow routes | Match CAPSTONE demo script |
| JSON errors | Consistent envelope |
| Auth | As designed—must protect writes |
| Validation | 400 on bad input |
| Status mapping | 404/409/401/403 |
Prefer Go 1.22+ ServeMux patterns: POST /v1/books, GET /v1/books/{id}.
Option C — CLI surface
| Surface | Notes |
|---|---|
| All planned subcommands | Help for each |
| Global flags | --config, --verbose |
| Exit codes | 0/1/2 discipline (Day 71) |
| Examples | README snippets accurate |
completion optional |
Stretch |
mytool init
mytool apply --dry-run
mytool apply
mytool status
mytool versionTheory 1 — DTO vs domain
Adapters translate at the edge:
type createBookRequest struct {
Title string `json:"title"`
}
func (h Handler) CreateBook(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var req createBookRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, domain.ErrInvalid)
return
}
out, err := h.Create.Exec(r.Context(), app.CreateBookInput{Title: req.Title})
if err != nil {
writeErr(w, err)
return
}
writeJSON(w, http.StatusCreated, bookResponse{ID: out.ID, Title: out.Title})
}Do not leak SQL errors or stack traces to clients.
Theory 2 — Error mapping table
func writeErr(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domain.ErrNotFound):
writeJSON(w, http.StatusNotFound, errBody{"not_found", "not found"})
case errors.Is(err, domain.ErrInvalid):
writeJSON(w, http.StatusBadRequest, errBody{"invalid", "invalid input"})
case errors.Is(err, domain.ErrConflict):
writeJSON(w, http.StatusConflict, errBody{"conflict", "conflict"})
case errors.Is(err, domain.ErrUnauthorized):
writeJSON(w, http.StatusUnauthorized, errBody{"unauthorized", "unauthorized"})
default:
// log err server-side
writeJSON(w, http.StatusInternalServerError, errBody{"internal", "internal error"})
}
}| Domain | HTTP | CLI exit (suggested) |
|---|---|---|
| nil | 2xx | 0 |
| ErrInvalid / usage | 400 | 2 |
| ErrUnauthorized | 401 | 1 |
| ErrNotFound | 404 | 1 |
| ErrConflict | 409 | 1 |
| other | 500 | 1 |
Theory 3 — Middleware chain
Order matters:
request ID → recover → logging → auth → max body → handler
func chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}Recover middleware must log panics and return 500—never expose panic text. Auth bugs should not be “fixed” by recover swallowing them silently without metrics.
Theory 4 — Auth surface honesty
| Design promise | Today’s bar |
|---|---|
| Auth required | Default deny on /v1/* mutating routes |
| Token empty in dev | Document risk; prefer default secure |
| Agent local only | Bind 127.0.0.1 or require bearer |
func requireBearer(token string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if token == "" {
// only if design explicitly allows open lab mode
next.ServeHTTP(w, r)
return
}
h := r.Header.Get("Authorization")
if h != "Bearer "+token {
http.Error(w, `{"error":{"code":"unauthorized"}}`, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}If you cut auth scope, update CAPSTONE.md today—not on demo day.
Theory 5 — CLI surface ergonomics
func exitCode(err error) int {
if err == nil {
return 0
}
if errors.Is(err, ErrUsage) {
return 2
}
return 1
}| Rule | Practice |
|---|---|
| Help | Every subcommand -h works |
| Errors to stderr | stdout clean for piping |
| Dry-run | No side effects when set |
| Timeouts | context.WithTimeout on network |
mytool apply --dry-run 2>/dev/null | jq . # if JSON planWorked example — httptest (B)
func TestCreateBookHTTP(t *testing.T) {
uc := app.CreateBook{Books: memBooks{}, IDs: seqID{}, Clock: fixedClock{}}
h := httpapi.New(uc, "secret-token")
srv := httptest.NewServer(h.Routes())
t.Cleanup(srv.Close)
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v1/books",
strings.NewReader(`{"title":"x"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer secret-token")
res, err := http.DefaultClient.Do(req)
if err != nil || res.StatusCode != http.StatusCreated {
t.Fatalf("%v %v", err, res)
}
}ServeMux route sketch (Go 1.22+)
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", h.Health)
mux.HandleFunc("GET /v1/books/{id}", h.GetBook)
mux.HandleFunc("POST /v1/books", h.CreateBook)Lab 1 — Inventory vs design § surface
- Open
CAPSTONE.mdsurface section.
- List each route/command: done / missing / cut.
- Implement missing pieces in priority of demo path.
- Do not add unplanned resources.
Lab 2 — Error mapping + validation
- Centralize
writeErr/ CLI exit mapping.
- Invalid JSON → 400.
- Empty required fields → 400.
- Not found → 404.
- httptest or CLI test for success + one error.
Lab 3 — Auth or explicit cut
- If design requires auth: protect writes; test 401.
- If cutting: update design + README risk note.
- Never leave half-protected routes (create open, delete locked).
Lab 4 — Smoke script (rough demo)
Write scripts/smoke.sh exercising the full user story:
#!/usr/bin/env bash
set -euo pipefail
base="${BASE_URL:-http://127.0.0.1:8080}"
curl -sf "$base/healthz" >/dev/null
# login/token if needed
# create → get → (optional list)
echo OKCLI:
./bin/mytool version
./bin/mytool apply --dry-run
./bin/mytool apply
./bin/mytool statusDay 88 will polish this into the formal demo—today it must already work roughly.
Lab 5 — CAPSTONE milestone
## Milestone 86 — surface
- [x] Routes/commands match design (or design updated)
- [x] Error mapping consistent
- [x] Auth story honest
- [x] smoke.sh rough path
- Leftovers for 87–88: metrics, docker, polishHour-by-hour suggestion
| Time | Focus |
|---|---|
| 0:00–0:40 | Route/command inventory vs design |
| 0:40–1:40 | Implement missing surface |
| 1:40–2:20 | Error mapping + auth |
| 2:20–2:50 | Smoke script / httptest |
| 2:50–3:00 | Update CAPSTONE if scope cut |
Common gotchas
| Gotcha | Fix |
|---|---|
| Business rules in handlers | Call app services only |
| Inconsistent JSON field names | Document OpenAPI-lite in README |
| Auth half-applied | Default deny on /v1 |
| CLI flags global parse bugs | Per-command FlagSet / Cobra |
| Breaking Day 85 persistence | Smoke after API changes |
| Returning 500 for validation | Map ErrInvalid → 400 |
| Unbounded history query | Cap ?limit= |
Forgetting MaxBytesReader |
Add on JSON POST |
| Help text lies | Run every command -h |
Checkpoint
- Public surface matches design (or doc updated)
- Error mapping consistent
- Auth story honest
- Smoke path works
- httptest or CLI error test present
- Milestone 86 done
go test ./...green on Go 1.26
Commit
git add .
git commit -m "day86: capstone api cli surface"Write three personal gotchas before continuing.
Tomorrow
Day 87 — Capstone build 5: observability and production hooks—metrics, structured logs, shutdown, config polish on top of today’s surface.