155 lines
7.5 KiB
Markdown
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.
|