Files
2026-10-19 16:04:17 +02:00

7.5 KiB

Backend — agent handbook

Go 1.27 API server: stdlib net/http (Go 1.22+ method patterns + r.PathValue), Bun ORM (SQLite file by default, Postgres via DSN), layered so each change touches a predictable set of files. Follow this handbook unless the user says otherwise.

Commands

Task Command
Full gate: fmt-check + vet + tests make check — run before declaring any change done
Dev server make run from backend/ (SQLite: no setup, make migrate-up once)
Tests make test (go test ./...; no database server required)
Optional Postgres up / down make db-up / make db-down (set DATABASE_URL=postgres://…)
Migrations make migrate-up / make migrate-down / make migrate-status
New migration make migrate-new name=create_projects
Binaries make build (writes bin/)

No linters beyond go vet and gofmt are configured — don't add config files for other tools unless asked.

Architecture — dependencies flow one way

cmd/api (wiring only)
   │
   ▼
internal/api ──HTTP──▶ internal/items.Service ──▶ items.Repository (interface)
(transport)              (business rules)              ▲
                                                  BunRepository (SQL)
  • internal/api — transport only: route patterns, parse input, call the service, map errors to status codes. No SQL, no business rules.
  • internal/items/service.go — validation and business rules. Depends on the Repository interface, never on *bun.DB; that is what makes the tests fake-backed.
  • internal/items/repository.go — the only file speaking Bun/SQL.
  • internal/config — the only file reading os.Getenv.
  • cmd/* — wiring and process concerns (signals, exit codes). No logic.

Imports may only point rightward in the diagram. internal/api must never import internal/database, and nothing imports internal/api except cmd.

Adding a resource (the recipe)

items is the reference. For a new resource projects:

  1. internal/projects/model.go — struct with bun:"table:…,alias:…" tags, json tags, Repository interface, ErrNotFound, ValidationError.
  2. internal/projects/repository.go — BunRepository implementing it.
  3. internal/projects/service.go — Service, CreateInput, pointer-field UpdateInput, validation.
  4. make migrate-new name=create_projects, then fill in the generated Go migration in internal/database/migrations/ (model-driven DDL, plus a down migration).
  5. internal/api/projects.go — handler struct + fail error mapping.
  6. internal/api/server.go — register routes on the mux.
  7. Tests: projects/service_test.go (fake repo) + extend api/server_test.go.
  8. make check.

HTTP conventions

  • Routes are method-prefixed ServeMux patterns ("PATCH /api/v1/projects/{id}"); read segments with r.PathValue("id"), parse via the pathID helper. Wrong methods get 405 + Allow — the jsonNotFounds wrapper in server.go keeps that mux behavior, so don't register catch-all routes.
  • Success bodies are the resource JSON directly; lists are bare arrays. Create answers 201 + Location; delete answers 204.
  • Errors are always {"error":{"code","message"}}. Codes live in internal/api/respond.go: invalid_json, validation_failed, not_found, invalid_id, method_not_allowed, internal_error, timeout, unavailable. Map domain errors only in the handler's fail — sentinel ErrNotFound → 404, *ValidationError → 400, everything else → 500 (logged, never leaked to the client).
  • Decode request bodies with decodeJSON (strict: unknown fields and

    1 MiB bodies are 400s). List endpoints take ?limit=&offset= and resource-specific filters; defaults and caps live in the service.

  • Middleware chain (outermost first): logging → recover → cors → timeout → requireAuth (only when OIDC is configured). Add transport-level concerns there, not in handlers.

Auth

OIDC authorization-code + PKCE against the issuer in OIDC_ISSUER (default https://users.k6n.net). Enabled iff OIDC_CLIENT_ID is set; otherwise the server runs standalone (no /api/auth routes, /api/v1 open) and logs that loudly. Pieces:

  • internal/auth — the OIDC client (discovery-driven endpoints, never hardcoded paths) and the cookie layer: session + login-transaction values are <base64url(json)>.<base64url(hmac-sha256)> sealed by Signer. Tokens stay server-side; the browser only ever holds the session cookie.
  • internal/api/auth.go — the four routes: GET /api/auth/session (always 200), GET /api/auth/login?returnTo=, GET /api/auth/callback, POST /api/auth/logout. The callback path /api/auth/callback is fixed; the redirect URI is derived per request from X-Forwarded-Host/Proto (set by the Vite proxy) so localhost, *.preview.k6n.net, and the API origin all work with one client registration.
  • requireAuth guards /api/v1/* with the session cookie (unknown sub-routes answer 401, not 404). It is skipped in standalone mode and in api.New tests that omit Deps.Auth.
  • Login failures redirect to /?auth_error=<code> — the SPA reads that; details go to server logs only. returnTo is sanitized to a relative path (no //, no control chars) to stay an internal redirect.

Migrations

  • One Go file per migration in internal/database/migrations/, named <YYYYMMDDHHMMSS>_<snake_name>.go, registering itself via Migrations.MustRegister(up, down) in init(). make migrate-new name=<snake_name> scaffolds it with a fresh timestamp — never invent timestamps by hand. Apply with go run ./cmd/migrate up (also down, status).
  • Prefer model-driven DDL (db.NewCreateTable().Model((*pkg.Thing)(nil))): bun emits dialect-correct SQL for SQLite and Postgres, so migrations stay portable across the default file database and a real Postgres. Use db.Exec with raw SQL only when a model can't express it.
  • Never edit or reorder an applied migration — add a new one. Every migration gets a down migration.
  • internal/database/database_test.go runs all migrations plus the items repository against in-memory SQLite — extend it when adding resources so dialect drift fails make check, not production.

Databases

  • Default: DATABASE_URL=sqlite:dev.db — a local file (modernc pure-Go driver, no CGO, WAL + busy_timeout applied automatically unless the DSN carries its own _pragma params). Zero setup for iteration.
  • Postgres: make db-up then DATABASE_URL=postgres://fullstack:fullstack@localhost:5432/fullstack?sslmode=disable. The repository layer uses portable queries (? placeholders, no dialect-specific SQL) — if you must, branch on db.Dialect().Name().

Testing

  • Three layers: internal/<pkg>/service_test.go drives business rules through an in-memory fake of the Repository interface; internal/api/server_test.go drives the real mux + middleware with the same fake behind the real service; internal/database/database_test.go runs migrations + the real BunRepository against in-memory SQLite.
  • Fakes are package-local and duplicated on purpose — don't build a shared testutil package until there are enough resources to justify it.
  • Table-driven tests for error cases; one flow test per handler group.

Config

  • Env-only; .env in backend/ is autoloaded by cmd/api. Add a field in internal/config/config.go, a line in .env.example, and a row in the README table.
  • CORS allows the Vite dev server (http://localhost:5173) by default so the frontend can call this API during development.