Initial commit
This commit is contained in:
commit
afed023152
8152 files changed
+2086461
No files matched your search
@@ -0,0 +1,154 @@
|
||||
# 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.
|
||||
Reference in new issue
Block a user