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

155 lines
7.5 KiB
Markdown

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