Initial commit

This commit is contained in:
mats committed 2026-10-19 16:04:17 +02:00
commit afed023152
8152 files changed
+2086461

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
# Copy to .env (cmd/api autoloads it via godotenv when run from backend/).
# Every value below is the default from internal/config — uncomment to override.
# HTTP listen address.
#API_ADDR=:8080
# Database: a local SQLite file by default (pure-Go driver; WAL +
# busy_timeout are auto-applied unless the DSN has its own _pragma params).
# For Postgres instead: `make db-up`, then use the DSN below.
#DATABASE_URL=sqlite:dev.db
#DATABASE_URL=postgres://fullstack:fullstack@localhost:5432/fullstack?sslmode=disable
# Comma-separated CORS origins allowed to call the API from a browser.
# "*" allows every origin.
#CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# debug | info | warn | error
#LOG_LEVEL=info
# json | text
#LOG_FORMAT=json
# Print every SQL query bun runs (bundebug).
#DB_DEBUG=false
# --- OIDC auth (issuer: https://users.k6n.net) ---
# Auth is ENABLED when OIDC_CLIENT_ID is set: /api/auth/login starts the
# authorization-code + PKCE flow, /api/v1/* then requires the session cookie.
# Leave both empty to run standalone (no /api/auth routes, /api/v1 open).
# The registered redirect URIs for this client cover
# https://*.preview.k6n.net/api/auth/callback and localhost on any port
# (loopback exception), so dev and preview hosts both work.
#OIDC_ISSUER=https://users.k6n.net
#OIDC_CLIENT_ID=fullstack-template
# Secret from the issuer's client registry — NOT safe to commit (keep it in .env).
#OIDC_CLIENT_SECRET=
# Secret used to sign session cookies. Unset = random per boot (all sessions
# are dropped on restart). Set a stable value in production.
#AUTH_SESSION_SECRET=
# Session lifetime as a Go duration.
#AUTH_SESSION_TTL=168h
+6
View File
@@ -0,0 +1,6 @@
bin/
.env
*.out
*.db
*.db-wal
*.db-shm
+154
View File
@@ -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.
+52
View File
@@ -0,0 +1,52 @@
# Backend tasks — run from backend/. `make check` is the gate to run
# before declaring any change done.
.PHONY: help run build test vet fmt fmt-check check tidy \
db-up db-down migrate-up migrate-down migrate-status migrate-new
help: ## List targets
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
run: ## Start the API server (SQLite default — run `make migrate-up` once)
go run ./cmd/api
build: ## Compile the binaries into bin/
go build -o bin/api ./cmd/api
go build -o bin/migrate ./cmd/migrate
test: ## Run all tests
go test ./...
vet: ## Run go vet
go vet ./...
fmt: ## Format all source files
gofmt -w .
fmt-check: ## Fail if any file needs gofmt
@files=$$(gofmt -l .); if [ -n "$$files" ]; then \
echo "gofmt needed on:"; echo "$$files"; exit 1; fi
check: fmt-check vet test ## Full gate: fmt-check + vet + test
tidy: ## Tidy go.mod / go.sum
go mod tidy
db-up: ## Optional: start dev Postgres (docker compose) — set DATABASE_URL to use it
docker compose up -d --wait db
db-down: ## Stop the optional dev Postgres and delete its volume
docker compose down -v
migrate-up: ## Apply pending migrations
go run ./cmd/migrate up
migrate-down: ## Roll back the most recent migration group
go run ./cmd/migrate down
migrate-status: ## List migrations and their state
go run ./cmd/migrate status
migrate-new: ## Create a Go migration scaffold: make migrate-new name=create_projects
@test -n "$(name)" || (echo "usage: make migrate-new name=create_projects"; exit 2)
go run ./cmd/migrate new $(name)
+114
View File
@@ -0,0 +1,114 @@
# fullstack-template backend
Go 1.27 API server template: stdlib `net/http` routing (Go 1.22+ method
patterns with `r.PathValue`), [Bun](https://bun.uptrace.dev) ORM (SQLite
file by default, Postgres via `DATABASE_URL`), and a layered layout
(handler → service → repository behind interfaces) designed to be copied
per resource and iterated on with an LLM.
## Quickstart
No containers, no database server — the default database is a local SQLite
file (`dev.db`):
```sh
cd backend
make migrate-up # apply migrations (creates dev.db)
make run # API on :8080
```
Try it:
```sh
curl -s localhost:8080/api/v1/items
curl -s -X POST localhost:8080/api/v1/items \
-H 'Content-Type: application/json' \
-d '{"title":"First item","description":"Hello template"}'
curl -s localhost:8080/api/v1/items/1
curl -s -X PATCH localhost:8080/api/v1/items/1 \
-H 'Content-Type: application/json' -d '{"status":"archived"}'
curl -s -i -X DELETE localhost:8080/api/v1/items/1
```
## Endpoints
| Method | Path | Notes |
|---|---|---|
| GET | `/healthz` | liveness |
| GET | `/readyz` | checks the database |
| GET | `/api/auth/session` | who is signed in; always 200, `{"authenticated":bool,"user":…\|null}` |
| GET | `/api/auth/login` | starts the OIDC redirect; optional `?returnTo=/path` |
| GET | `/api/auth/callback` | issuer lands here; do not call directly |
| POST | `/api/auth/logout` | clears the session cookie; 204 |
| GET | `/api/v1/items` | `?status=active|archived&limit=&offset=` — requires a session when auth is on |
| POST | `/api/v1/items` | 201 + `Location`, body `{"title","description","status"}` |
| GET | `/api/v1/items/{id}` | |
| PATCH | `/api/v1/items/{id}` | partial update; `null`-absent fields untouched |
| DELETE | `/api/v1/items/{id}` | 204 |
Errors are always `{"error":{"code":"...","message":"..."}}`.
## OIDC authentication
Set `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` (see [.env.example](.env.example))
and the API authenticates against the issuer at `OIDC_ISSUER`
(default `https://users.k6n.net`) using the authorization-code + PKCE flow:
- The browser is sent to the issuer from `GET /api/auth/login`; the callback
path is fixed at `/api/auth/callback`. The redirect URI is derived from the
incoming request (`X-Forwarded-Host`/`X-Forwarded-Proto` when behind the
Vite proxy, `Host`/TLS otherwise), so the same server works on
`localhost:<any port>` (loopback exception), `*.preview.k6n.net`
(registered wildcard), and the API origin itself — as long as those URIs
are registered for the client with the issuer.
- The signed-in identity (sub, name, email, roles) is kept in an
HMAC-SHA256-signed session cookie (`HttpOnly`, `SameSite=Lax`). ID and
access tokens never reach the browser and are not persisted server-side;
- When auth is enabled, everything under `/api/v1/` answers
`401 unauthorized` without a valid session. `/healthz`, `/readyz`, and
`/api/auth/*` stay public.
- Without `OIDC_CLIENT_ID` the server runs standalone: no `/api/auth`
routes and an open `/api/v1` (logged loudly at boot).
`AUTH_SESSION_SECRET` signs the cookies — unset it only in development (a
random one is generated per boot, invalidating all sessions on restart).
`AUTH_SESSION_TTL` (default `168h`) bounds the session.
## Layout
```
cmd/
api/ server entrypoint (config, wiring, graceful shutdown)
migrate/ migration CLI: up | down | status | new
internal/
api/ routing, middleware, JSON conventions, item + auth handlers
auth/ OIDC client (code + PKCE flow) and signed session cookies
config/ env parsing — the only place reading os.Getenv
database/ bun connection (sqlite or postgres) + migrator
migrations/ one Go file per migration: <timestamp>_<name>.go
items/ reference resource: model, Repository interface,
Bun implementation, service (business rules)
compose.yaml optional dev Postgres
Makefile `make check` = fmt-check + vet + test
```
`internal/items` is the pattern to copy when adding a resource — see
AGENTS.md for the step-by-step recipe.
## Configuration
All settings come from the environment and a `.env` file in backend/ is
autoloaded. The database defaults to the SQLite file `dev.db`; for
Postgres run `make db-up` and set
`DATABASE_URL=postgres://fullstack:fullstack@localhost:5432/fullstack?sslmode=disable`.
See [.env.example](.env.example) for the full list.
## Tests
```sh
make test # or: go test ./...
```
Service and handler tests run against in-memory fakes; the database test
runs the real migrations and repository against in-memory SQLite — no
database server needed for any of them.
+141
View File
@@ -0,0 +1,141 @@
// Command api runs the HTTP server. Run it from backend/ so the godotenv
// autoload below finds backend/.env.
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
// Loads a .env file next to the working directory, if present.
_ "github.com/joho/godotenv/autoload"
"git.k6n.net/mats/fullstack-template/internal/api"
"git.k6n.net/mats/fullstack-template/internal/auth"
"git.k6n.net/mats/fullstack-template/internal/config"
"git.k6n.net/mats/fullstack-template/internal/database"
"git.k6n.net/mats/fullstack-template/internal/items"
)
func main() {
if err := run(); err != nil {
slog.Error("server exited with error", "err", err)
os.Exit(1)
}
slog.Info("server stopped")
}
func run() error {
cfg, err := config.Load()
if err != nil {
return err
}
logger := newLogger(cfg)
slog.SetDefault(logger)
db, err := database.Open(cfg)
if err != nil {
return err
}
defer db.Close()
pingCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(pingCtx); err != nil {
return fmt.Errorf("connect to database: %w (check DATABASE_URL; for postgres, is `make db-up` running?)", err)
}
svc := items.NewService(items.NewBunRepository(db))
// OIDC is on when a client id is configured; without one the API runs
// standalone (no /api/auth routes, /api/v1 unprotected) — see README.
authDeps, err := configureAuth(logger, cfg)
if err != nil {
return err
}
handler := api.New(api.Deps{
Logger: logger,
Items: svc,
DB: db,
AllowedOrigins: cfg.AllowedOrigins,
Auth: authDeps,
})
srv := &http.Server{
Addr: cfg.Addr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
errCh := make(chan error, 1)
go func() {
logger.Info("api listening", "addr", cfg.Addr)
errCh <- srv.ListenAndServe()
}()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
select {
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
logger.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("graceful shutdown: %w", err)
}
return nil
}
}
// configureAuth builds the OIDC collaborators, or nil in standalone mode.
func configureAuth(logger *slog.Logger, cfg config.Config) (*api.AuthDeps, error) {
if cfg.OIDCClientID == "" {
logger.Warn("OIDC_CLIENT_ID not set — running standalone: /api/auth is disabled and /api/v1 is unprotected")
return nil, nil
}
if cfg.AuthSessionSecretEphemeral {
logger.Warn("AUTH_SESSION_SECRET not set — generated an ephemeral one; every restart signs everyone out")
}
// Discovery failure at boot is fatal: a server that cannot verify
// identities must not come up half-working.
client, err := auth.New(context.Background(), auth.Config{
Issuer: cfg.OIDCIssuer,
ClientID: cfg.OIDCClientID,
ClientSecret: cfg.OIDCClientSecret,
Scopes: []string{"openid", "profile", "email"},
})
if err != nil {
return nil, fmt.Errorf("oidc discovery against %s: %w", cfg.OIDCIssuer, err)
}
logger.Info("oidc auth enabled", "issuer", cfg.OIDCIssuer, "client_id", cfg.OIDCClientID)
return &api.AuthDeps{
Client: client,
Signer: auth.NewSigner(cfg.AuthSessionSecret),
SessionTTL: cfg.AuthSessionTTL,
}, nil
}
func newLogger(cfg config.Config) *slog.Logger {
opts := &slog.HandlerOptions{Level: cfg.LogLevel}
if cfg.LogFormat == "text" {
return slog.New(slog.NewTextHandler(os.Stdout, opts))
}
return slog.New(slog.NewJSONHandler(os.Stdout, opts))
}
+105
View File
@@ -0,0 +1,105 @@
// Command migrate manages database migrations.
//
// Usage:
//
// go run ./cmd/migrate up apply all pending migrations
// go run ./cmd/migrate down roll back the most recent group
// go run ./cmd/migrate status list migrations and whether they ran
// go run ./cmd/migrate new NAME create a Go migration scaffold named
// <timestamp>_NAME.go in
// internal/database/migrations/
package main
import (
"context"
"fmt"
"os"
_ "github.com/joho/godotenv/autoload"
"git.k6n.net/mats/fullstack-template/internal/config"
"git.k6n.net/mats/fullstack-template/internal/database"
)
func main() {
cmd := "up"
if len(os.Args) > 1 {
cmd = os.Args[1]
}
if cmd != "up" && cmd != "down" && cmd != "status" && cmd != "new" {
fmt.Fprintln(os.Stderr, "usage: migrate [up|down|status|new NAME]")
os.Exit(2)
}
cfg, err := config.Load()
if err != nil {
fatal(err)
}
db, err := database.Open(cfg)
if err != nil {
fatal(err)
}
defer db.Close()
ctx := context.Background()
m := database.NewMigrator(db)
switch cmd {
case "up":
if err := m.Init(ctx); err != nil {
fatal(err)
}
group, err := m.Migrate(ctx)
if err != nil {
fatal(err)
}
if group.IsZero() {
fmt.Println("no pending migrations")
} else {
fmt.Printf("applied %d migration(s) (group %d)\n", len(group.Migrations), group.ID)
}
case "down":
if err := m.Init(ctx); err != nil {
fatal(err)
}
group, err := m.Rollback(ctx)
if err != nil {
fatal(err)
}
if group.IsZero() {
fmt.Println("nothing to roll back")
} else {
fmt.Printf("rolled back %d migration(s) (group %d)\n", len(group.Migrations), group.ID)
}
case "status":
if err := m.Init(ctx); err != nil {
fatal(err)
}
migrations, err := m.MigrationsWithStatus(ctx)
if err != nil {
fatal(err)
}
for _, mig := range migrations {
state := "pending"
if mig.IsApplied() {
state = "applied"
}
fmt.Printf("%-8s %s %s\n", state, mig.Name, mig.Comment)
}
case "new":
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage: migrate new NAME (snake_case)")
os.Exit(2)
}
file, err := m.CreateGoMigration(ctx, os.Args[2])
if err != nil {
fatal(err)
}
fmt.Println("created", file.Path)
}
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, "migrate:", err)
os.Exit(1)
}
+24
View File
@@ -0,0 +1,24 @@
# Dev-only Postgres matching the default DATABASE_URL in internal/config.
name: fullstack-backend
services:
db:
image: postgres:18-alpine
environment:
POSTGRES_USER: fullstack
POSTGRES_PASSWORD: fullstack
POSTGRES_DB: fullstack
ports:
- "5432:5432"
volumes:
# postgres:18+ wants the mount at /var/lib/postgresql (not .../data)
# so in-place major upgrades via pg_upgrade remain possible.
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fullstack -d fullstack"]
interval: 2s
timeout: 2s
retries: 15
volumes:
pgdata:
+43
View File
@@ -0,0 +1,43 @@
module git.k6n.net/mats/fullstack-template
go 1.27.1
require (
github.com/coreos/go-oidc/v3 v3.21.0
github.com/joho/godotenv v1.5.1
github.com/uptrace/bun v1.2.18
github.com/uptrace/bun/dialect/pgdialect v1.2.18
github.com/uptrace/bun/dialect/sqlitedialect v1.2.18
github.com/uptrace/bun/driver/pgdriver v1.2.18
github.com/uptrace/bun/driver/sqliteshim v1.2.18
github.com/uptrace/bun/extra/bundebug v1.2.18
golang.org/x/oauth2 v0.37.0
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/sys v0.41.0 // indirect
mellium.im/sasl v0.3.2 // indirect
modernc.org/libc v1.68.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.46.1 // indirect
)
+109
View File
@@ -0,0 +1,109 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
github.com/uptrace/bun v1.2.18 h1:3HnRcMfS6OBPMG1eSOzlbFJ/X/AyMEJb7rMxE6VQvDU=
github.com/uptrace/bun v1.2.18/go.mod h1:wNltaKJk4JtOt4SG5I5zmA7v0/Mzjh1+/S906Rayd3Y=
github.com/uptrace/bun/dialect/pgdialect v1.2.18 h1:IZ6nM2+OYrL8lkEAy7UkSEZvoa3vluTAUlZfPtlRB2k=
github.com/uptrace/bun/dialect/pgdialect v1.2.18/go.mod h1:Tqdf4QP1okrGYpXfodXvCOK6Ob1OOTwSaoAzCgBB3IU=
github.com/uptrace/bun/dialect/sqlitedialect v1.2.18 h1:Z33SY/U++XK9uGWqS4h8OZVxfCXguIG+sU9cYq2PGFQ=
github.com/uptrace/bun/dialect/sqlitedialect v1.2.18/go.mod h1:1MVOS/Ncy4FZbkJcgUFH6OqYoQinYNjkEwsmNQEXz2A=
github.com/uptrace/bun/driver/pgdriver v1.2.18 h1:Zojuc83ulApocXomBLEcx1DqCZweREafHCjPfyXo88I=
github.com/uptrace/bun/driver/pgdriver v1.2.18/go.mod h1:ZRJcARw93nxbQ5WawTrc5EO+F+GygkcYgDLEnT17CcE=
github.com/uptrace/bun/driver/sqliteshim v1.2.18 h1:fDCXp4L46A23OuUikDbL14SRmm3y+7XO4fkFe1bs2A4=
github.com/uptrace/bun/driver/sqliteshim v1.2.18/go.mod h1:MqvqMCAAKNn6M0HF9YK/Z6xrnCP6sih5OZ37AxdAlHw=
github.com/uptrace/bun/extra/bundebug v1.2.18 h1:5cgkqdvhpSHIEONazSytm4RWYFneNtcznaWLt6r8m4M=
github.com/uptrace/bun/extra/bundebug v1.2.18/go.mod h1:M+U9YJVJcmk0RrszCb2Q1oskJiJ0LuC44FxDhZLP1ws=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mellium.im/sasl v0.3.2 h1:PT6Xp7ccn9XaXAnJ03FcEjmAn7kK1x7aoXV6F+Vmrl0=
mellium.im/sasl v0.3.2/go.mod h1:NKXDi1zkr+BlMHLQjY3ofYuU4KSPFxknb8mfEu6SveY=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+221
View File
@@ -0,0 +1,221 @@
package api
import (
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"git.k6n.net/mats/fullstack-template/internal/auth"
)
// AuthDeps bundles the OIDC collaborators; nil Auth in Deps disables the
// /api/auth routes and the /api/v1 guard (standalone mode).
type AuthDeps struct {
Client *auth.Client
Signer *auth.Signer
SessionTTL time.Duration // zero means the 24h default
}
const defaultSessionTTL = 24 * time.Hour
type authHandler struct {
client *auth.Client
signer *auth.Signer
sessionTTL time.Duration
logger *slog.Logger
}
// sessionUser is the public shape of the signed-in identity.
type sessionUser struct {
Sub string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
Roles []string `json:"roles"`
}
// login starts the browser flow: it parks a login transaction (state,
// nonce, PKCE verifier, return path) in a short-lived cookie and redirects
// to the issuer's authorization endpoint.
func (h *authHandler) login(w http.ResponseWriter, r *http.Request) {
tx, err := auth.NewLoginTx(sanitizeReturnTo(r.URL.Query().Get("returnTo")))
if err != nil {
h.fail(w, err)
return
}
cookie, err := h.signer.NewLoginTxCookie(tx, requestSecure(r))
if err != nil {
h.fail(w, err)
return
}
http.SetCookie(w, cookie)
http.Redirect(w, r, h.client.AuthCodeURL(tx, redirectURI(r)), http.StatusFound)
}
// callback completes the flow: it checks state, exchanges the code with the
// PKCE verifier, verifies the ID token (signature, iss/aud/exp, nonce), and
// installs the session cookie. Any failure redirects back to the app with
// an auth_error code — the SPA surfaces it; details stay in server logs.
func (h *authHandler) callback(w http.ResponseWriter, r *http.Request) {
secure := requestSecure(r)
http.SetCookie(w, auth.Expired(auth.CookieLoginTx, secure))
fail := func(code string) {
http.Redirect(w, r, "/?auth_error="+url.QueryEscape(code), http.StatusFound)
}
// An issuer-reported error (user denied, interaction required, …) is
// more useful than our own missing-transaction complaint.
if e := r.URL.Query().Get("error"); e != "" {
fail(e)
return
}
tx, ok := h.readLoginTx(r)
if !ok {
fail("expired_login")
return
}
q := r.URL.Query()
if q.Get("state") != tx.State {
fail("state_mismatch")
return
}
user, err := h.client.CompleteCode(r.Context(), q.Get("code"), redirectURI(r), tx)
if err != nil {
h.logger.Error("oidc code exchange failed", "err", err)
fail("code_exchange")
return
}
cookie, err := h.signer.NewSessionCookie(
auth.Session{Sub: user.Sub, Name: user.Name, Email: user.Email, Roles: user.Roles},
h.sessionTTL, secure)
if err != nil {
h.fail(w, err)
return
}
http.SetCookie(w, cookie)
http.Redirect(w, r, tx.ReturnTo, http.StatusFound)
}
// session reports who is signed in; it answers 200 either way so the SPA
// can poll it without treating "anonymous" as an error.
func (h *authHandler) session(w http.ResponseWriter, r *http.Request) {
type sessionResponse struct {
Authenticated bool `json:"authenticated"`
User *sessionUser `json:"user"`
}
res := sessionResponse{}
if sess, ok := CurrentSession(h.signer, r); ok {
res.Authenticated = true
res.User = &sessionUser{Sub: sess.Sub, Name: sess.Name, Email: sess.Email, Roles: sess.Roles}
}
writeJSON(w, http.StatusOK, res)
}
// logout clears the session cookie.
func (h *authHandler) logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, auth.Expired(auth.CookieSession, requestSecure(r)))
w.WriteHeader(http.StatusNoContent)
}
// readLoginTx loads and validates the login-transaction cookie.
func (h *authHandler) readLoginTx(r *http.Request) (auth.LoginTx, bool) {
cookie, err := r.Cookie(auth.CookieLoginTx)
if err != nil {
return auth.LoginTx{}, false
}
var tx auth.LoginTx
if h.signer.Open(cookie.Value, &tx) != nil || !tx.Valid() {
return auth.LoginTx{}, false
}
return tx, true
}
// CurrentSession returns the valid session behind the request, if any. It
// is the single read path shared by the session endpoint and requireAuth.
func CurrentSession(signer *auth.Signer, r *http.Request) (auth.Session, bool) {
cookie, err := r.Cookie(auth.CookieSession)
if err != nil {
return auth.Session{}, false
}
var sess auth.Session
if signer.Open(cookie.Value, &sess) != nil || !sess.Valid() {
return auth.Session{}, false
}
return sess, true
}
// requireAuth guards the business API behind a valid session cookie. It is
// installed inside the chain, so unknown /api/v1/* routes answer 401, not
// 404 — route existence is not public information.
func requireAuth(signer *auth.Signer) middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/v1/") {
if _, ok := CurrentSession(signer, r); !ok {
writeError(w, http.StatusUnauthorized, "unauthorized",
"sign in via /api/auth/login to call the API")
return
}
}
next.ServeHTTP(w, r)
})
}
}
// redirectURI rebuilds the public origin the browser used, so the issuer
// sends the user back through the same host that started the flow — the
// vite proxy (localhost or *.preview.k6n.net) or the API itself. The proxy
// sets X-Forwarded-Host/Proto; direct requests fall back to r.Host/TLS.
func redirectURI(r *http.Request) string {
return requestScheme(r) + "://" + requestHost(r) + "/api/auth/callback"
}
// requestSecure reports whether the browser reached us over https.
func requestSecure(r *http.Request) bool { return requestScheme(r) == "https" }
// requestScheme resolves the scheme from X-Forwarded-Proto (first hop) or
// the connection itself.
func requestScheme(r *http.Request) string {
if proto := forwardedValue(r.Header.Get("X-Forwarded-Proto")); proto != "" {
return proto
}
if r.TLS != nil {
return "https"
}
return "http"
}
// requestHost resolves the host from X-Forwarded-Host (first hop) or Host.
func requestHost(r *http.Request) string {
if host := forwardedValue(r.Header.Get("X-Forwarded-Host")); host != "" {
return host
}
return r.Host
}
// forwardedValue returns the first value of a forwarded header, which is
// what our own proxy appended.
func forwardedValue(v string) string {
first, _, _ := strings.Cut(v, ",")
return strings.TrimSpace(first)
}
// sanitizeReturnTo keeps post-login redirects inside the app: relative
// paths only, no protocol-relative //host or scheme tricks.
func sanitizeReturnTo(v string) string {
if v == "" || !strings.HasPrefix(v, "/") || strings.HasPrefix(v, "//") ||
strings.ContainsAny(v, "\r\n\\") {
return "/"
}
return v
}
// fail maps unexpected internal errors to the standard envelope.
func (h *authHandler) fail(w http.ResponseWriter, err error) {
h.logger.Error("auth handler failed", "err", err)
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
}
+400
View File
@@ -0,0 +1,400 @@
package api_test
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"git.k6n.net/mats/fullstack-template/internal/api"
"git.k6n.net/mats/fullstack-template/internal/auth"
"git.k6n.net/mats/fullstack-template/internal/items"
)
// fakeIDP is a minimal OpenID Connect issuer: discovery, a token endpoint
// that answers any authorization code with a freshly signed RS256 ID token,
// and the matching JWKS. Enough to drive the real client end to end.
type fakeIDP struct {
server *httptest.Server
key *rsa.PrivateKey
clientID string
// nonceForCode records the nonce embedded in the token issued per code.
nonceForCode map[string]string
}
func newFakeIDP(t *testing.T) *fakeIDP {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate rsa key: %v", err)
}
idp := &fakeIDP{key: key, clientID: "test-client", nonceForCode: map[string]string{}}
mux := http.NewServeMux()
mux.HandleFunc("GET /.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
writeJSONString(w, map[string]any{
"issuer": idp.server.URL,
"authorization_endpoint": idp.server.URL + "/authorize",
"token_endpoint": idp.server.URL + "/token",
"jwks_uri": idp.server.URL + "/keys",
"id_token_signing_alg_values_supported": []string{"RS256"},
})
})
mux.HandleFunc("GET /keys", func(w http.ResponseWriter, _ *http.Request) {
pub := idp.key.PublicKey
writeJSONString(w, map[string]any{
"keys": []map[string]any{{
"kty": "RSA", "kid": "test-key", "alg": "RS256",
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}),
}},
})
})
mux.HandleFunc("POST /token", func(w http.ResponseWriter, r *http.Request) {
code := r.FormValue("code")
claims := map[string]any{
"iss": idp.server.URL,
"sub": "user-1",
"aud": idp.clientID,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
"name": "Test User",
"email": "test@example.com",
"roles": []string{"admin"},
}
// The authorize step would have bound the nonce to this code; the
// test injects it via nonceForCode before calling the callback.
if nonce := idp.nonceForCode[code]; nonce != "" {
claims["nonce"] = nonce
}
writeJSONString(w, map[string]any{
"access_token": "at-" + code,
"token_type": "Bearer",
"id_token": idp.sign(t, claims),
})
})
idp.server = httptest.NewServer(mux)
t.Cleanup(idp.server.Close)
return idp
}
// sign produces a compact RS256 JWS for the claims.
func (f *fakeIDP) sign(t *testing.T, claims map[string]any) string {
t.Helper()
header, err := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT", "kid": "test-key"})
if err != nil {
t.Fatalf("marshal header: %v", err)
}
payload, err := json.Marshal(claims)
if err != nil {
t.Fatalf("marshal claims: %v", err)
}
signingInput := base64.RawURLEncoding.EncodeToString(header) + "." +
base64.RawURLEncoding.EncodeToString(payload)
digest := sha256.Sum256([]byte(signingInput))
sig, err := rsa.SignPKCS1v15(rand.Reader, f.key, crypto.SHA256, digest[:])
if err != nil {
t.Fatalf("sign: %v", err)
}
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
}
func writeJSONString(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
// newAuthTestServer builds the full api stack with OIDC pointed at idp.
func newAuthTestServer(t *testing.T, idp *fakeIDP) http.Handler {
t.Helper()
client, err := auth.New(context.Background(), auth.Config{
Issuer: idp.server.URL,
ClientID: idp.clientID,
ClientSecret: "test-secret",
})
if err != nil {
t.Fatalf("auth.New: %v", err)
}
return api.New(api.Deps{
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
Items: items.NewService(newFakeRepo()),
DB: stubPinger{},
AllowedOrigins: []string{"http://localhost:5173"},
Auth: &api.AuthDeps{
Client: client,
Signer: auth.NewSigner("test-signing-secret"),
SessionTTL: time.Hour,
},
})
}
// startLogin drives GET /api/auth/login and returns (tx cookie, parsed
// authorize URL).
func startLogin(t *testing.T, h http.Handler, target string) (*http.Cookie, *url.URL) {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, target, nil)
req.Host = "localhost:5173"
h.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("login = %d: %s", rec.Code, rec.Body.String())
}
var txCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == auth.CookieLoginTx {
txCookie = c
}
}
if txCookie == nil {
t.Fatalf("login set no %s cookie", auth.CookieLoginTx)
}
authURL, err := url.Parse(rec.Header().Get("Location"))
if err != nil {
t.Fatalf("parse authorize url: %v", err)
}
return txCookie, authURL
}
func TestOIDCLoginFlow(t *testing.T) {
idp := newFakeIDP(t)
h := newAuthTestServer(t, idp)
// Anonymous: session reports unauthenticated, /api/v1 is closed.
rec := do(t, h, http.MethodGet, "/api/auth/session", "")
var sess struct {
Authenticated bool `json:"authenticated"`
User any `json:"user"`
}
decodeBody(t, rec, &sess)
if sess.Authenticated || sess.User != nil {
t.Errorf("anonymous session = %+v", sess)
}
if rec = do(t, h, http.MethodGet, "/api/v1/items", ""); rec.Code != http.StatusUnauthorized {
t.Fatalf("items without session = %d, want 401", rec.Code)
}
// Login redirect: PKCE + state + nonce present, redirect URI rebuilt
// from the request host.
txCookie, authURL := startLogin(t, h, "/api/auth/login?returnTo=/overview")
q := authURL.Query()
if q.Get("response_type") != "code" || q.Get("client_id") != idp.clientID {
t.Errorf("authorize query = %v", q)
}
if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
t.Errorf("PKCE missing: %v", q)
}
if q.Get("state") == "" || q.Get("nonce") == "" {
t.Errorf("state/nonce missing: %v", q)
}
if got := q.Get("redirect_uri"); got != "http://localhost:5173/api/auth/callback" {
t.Errorf("redirect_uri = %q", got)
}
// The IdP remembers the nonce for the code it would mint here.
idp.nonceForCode["stub-code"] = q.Get("nonce")
// Callback: state + tx cookie exchange for a session cookie.
req := httptest.NewRequest(http.MethodGet,
"/api/auth/callback?code=stub-code&state="+url.QueryEscape(q.Get("state")), nil)
req.Host = "localhost:5173"
req.AddCookie(txCookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("callback = %d: %s", rec.Code, rec.Body.String())
}
if loc := rec.Header().Get("Location"); loc != "/overview" {
t.Errorf("callback Location = %q, want /overview", loc)
}
var sessionCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == auth.CookieSession {
sessionCookie = c
}
}
if sessionCookie == nil {
t.Fatal("callback set no session cookie")
}
// Signed in: session knows the user, /api/v1 opens up.
req = httptest.NewRequest(http.MethodGet, "/api/auth/session", nil)
req.AddCookie(sessionCookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("session = %d", rec.Code)
}
var after struct {
Authenticated bool `json:"authenticated"`
User struct {
Sub string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
Roles []string `json:"roles"`
} `json:"user"`
}
decodeBody(t, rec, &after)
if !after.Authenticated || after.User.Sub != "user-1" ||
after.User.Name != "Test User" || after.User.Email != "test@example.com" {
t.Errorf("session after login = %+v", after)
}
if len(after.User.Roles) != 1 || after.User.Roles[0] != "admin" {
t.Errorf("roles = %v", after.User.Roles)
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/items", nil)
req.AddCookie(sessionCookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("items with session = %d: %s", rec.Code, rec.Body.String())
}
// Logout clears the session; /api/v1 closes again.
req = httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
req.AddCookie(sessionCookie)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("logout = %d", rec.Code)
}
var cleared *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == auth.CookieSession && c.MaxAge < 0 {
cleared = c
}
}
if cleared == nil {
t.Fatal("logout did not expire the session cookie")
}
}
func TestOIDCCallbackRejectsBadState(t *testing.T) {
idp := newFakeIDP(t)
h := newAuthTestServer(t, idp)
txCookie, _ := startLogin(t, h, "/api/auth/login")
req := httptest.NewRequest(http.MethodGet, "/api/auth/callback?code=c&state=wrong", nil)
req.AddCookie(txCookie)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/?auth_error=state_mismatch" {
t.Fatalf("callback with wrong state = %d %q", rec.Code, rec.Header().Get("Location"))
}
// And without any tx cookie at all.
rec = do(t, h, http.MethodGet, "/api/auth/callback?code=c&state=x", "")
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/?auth_error=expired_login" {
t.Fatalf("callback without tx cookie = %d %q", rec.Code, rec.Header().Get("Location"))
}
}
func TestOIDCCallbackPassesIssuerErrorThrough(t *testing.T) {
idp := newFakeIDP(t)
h := newAuthTestServer(t, idp)
rec := do(t, h, http.MethodGet, "/api/auth/callback?error=access_denied", "")
if rec.Code != http.StatusFound ||
rec.Header().Get("Location") != "/?auth_error=access_denied" {
t.Fatalf("callback issuer error = %d %q", rec.Code, rec.Header().Get("Location"))
}
}
func TestAuthRoutesAbsentInStandaloneMode(t *testing.T) {
h := api.New(api.Deps{
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
Items: items.NewService(newFakeRepo()),
DB: stubPinger{},
AllowedOrigins: []string{"http://localhost:5173"},
})
if rec := do(t, h, http.MethodGet, "/api/auth/session", ""); rec.Code != http.StatusNotFound {
t.Errorf("session without OIDC = %d, want 404", rec.Code)
}
if rec := do(t, h, http.MethodGet, "/api/v1/items", ""); rec.Code != http.StatusOK {
t.Errorf("items without OIDC = %d, want 200 (standalone stays open)", rec.Code)
}
}
func TestRequireAuthCoversUnknownV1Routes(t *testing.T) {
idp := newFakeIDP(t)
h := newAuthTestServer(t, idp)
if rec := do(t, h, http.MethodGet, "/api/v1/nope", ""); rec.Code != http.StatusUnauthorized {
t.Errorf("unknown /api/v1 route = %d, want 401 (not 404)", rec.Code)
}
if !strings.Contains(recBodyFor(t, h, "/api/v1/nope"), "unauthorized") {
t.Error("401 body should carry the unauthorized code")
}
}
func TestForwardedHeadersBuildPreviewRedirectURI(t *testing.T) {
idp := newFakeIDP(t)
h := newAuthTestServer(t, idp)
req := httptest.NewRequest(http.MethodGet, "/api/auth/login", nil)
req.Host = "backend.internal:8080"
req.Header.Set("X-Forwarded-Host", "worker-7.preview.k6n.net")
req.Header.Set("X-Forwarded-Proto", "https")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
authURL, err := url.Parse(rec.Header().Get("Location"))
if err != nil {
t.Fatalf("parse authorize url: %v", err)
}
if got := authURL.Query().Get("redirect_uri"); got != "https://worker-7.preview.k6n.net/api/auth/callback" {
t.Errorf("redirect_uri = %q", got)
}
// Session cookies set behind https must carry Secure.
var txCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == auth.CookieLoginTx {
txCookie = c
}
}
if txCookie == nil || !txCookie.Secure {
t.Fatal("tx cookie behind https must be Secure")
}
}
func TestReturnToSanitization(t *testing.T) {
idp := newFakeIDP(t)
h := newAuthTestServer(t, idp)
// Open-redirect attempts fall back to "/" inside the tx cookie.
for _, raw := range []string{"https://evil.example", "//evil.example", "\r\n/x", "\\evil"} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/auth/login?returnTo="+url.QueryEscape(raw), nil)
h.ServeHTTP(rec, req)
var txCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == auth.CookieLoginTx {
txCookie = c
}
}
if txCookie == nil {
t.Fatalf("returnTo=%q: no tx cookie", raw)
}
var tx auth.LoginTx
if err := auth.NewSigner("test-signing-secret").Open(txCookie.Value, &tx); err != nil {
t.Fatalf("open tx cookie: %v", err)
}
if tx.ReturnTo != "/" {
t.Errorf("returnTo=%q sanitized to %q, want /", raw, tx.ReturnTo)
}
}
}
func recBodyFor(t *testing.T, h http.Handler, target string) string {
t.Helper()
return do(t, h, http.MethodGet, target, "").Body.String()
}
+126
View File
@@ -0,0 +1,126 @@
package api
import (
"errors"
"log/slog"
"net/http"
"strconv"
"git.k6n.net/mats/fullstack-template/internal/items"
)
// itemHandler adapts HTTP requests to the items service: parse input, call
// the service, map errors to status codes. No business logic lives here.
type itemHandler struct {
svc *items.Service
logger *slog.Logger
}
func (h *itemHandler) list(w http.ResponseWriter, r *http.Request) {
f := items.Filter{
Status: items.Status(r.URL.Query().Get("status")),
Limit: queryInt(r, "limit", items.DefaultLimit),
Offset: queryInt(r, "offset", 0),
}
result, err := h.svc.List(r.Context(), f)
if err != nil {
h.fail(w, err)
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *itemHandler) create(w http.ResponseWriter, r *http.Request) {
var in items.CreateInput
if !decodeJSON(w, r, &in) {
return
}
item, err := h.svc.Create(r.Context(), in)
if err != nil {
h.fail(w, err)
return
}
w.Header().Set("Location", "/api/v1/items/"+strconv.FormatInt(item.ID, 10))
writeJSON(w, http.StatusCreated, item)
}
func (h *itemHandler) get(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
item, err := h.svc.Get(r.Context(), id)
if err != nil {
h.fail(w, err)
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *itemHandler) update(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
var in items.UpdateInput
if !decodeJSON(w, r, &in) {
return
}
item, err := h.svc.Update(r.Context(), id, in)
if err != nil {
h.fail(w, err)
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *itemHandler) delete(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
if err := h.svc.Delete(r.Context(), id); err != nil {
h.fail(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// pathID parses the {id} path segment; writes a 400 and returns false on
// input that can't be an id.
func pathID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "invalid_id", "id must be a positive integer")
return 0, false
}
return id, true
}
// queryInt reads an int query parameter, falling back when absent or junk.
func queryInt(r *http.Request, key string, fallback int) int {
v := r.URL.Query().Get(key)
if v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
return fallback
}
return n
}
// fail is the single translation point from items errors to the wire:
// ErrNotFound → 404, *ValidationError → 400, anything else → 500 (logged).
func (h *itemHandler) fail(w http.ResponseWriter, err error) {
var vErr *items.ValidationError
switch {
case errors.Is(err, items.ErrNotFound):
writeError(w, http.StatusNotFound, "not_found", "item not found")
case errors.As(err, &vErr):
writeError(w, http.StatusBadRequest, "validation_failed", vErr.Error())
default:
h.logger.Error("request failed", "err", err)
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
}
}
+107
View File
@@ -0,0 +1,107 @@
package api
import (
"log/slog"
"net/http"
"runtime/debug"
"time"
)
// middleware wraps an http.Handler. Keep middleware transport-level;
// business concerns belong in services.
type middleware func(http.Handler) http.Handler
// chain composes middlewares so chain(h, a, b) == a(b(h)): the first
// argument runs outermost.
func chain(h http.Handler, mws ...middleware) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
// logging records one line per request after it completes.
func logging(logger *slog.Logger) middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration_ms", time.Since(start).Milliseconds(),
)
})
}
}
// recoverPanics turns panics into 500s instead of dropped connections.
func recoverPanics(logger *slog.Logger) middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
logger.Error("panic recovered", "panic", v, "stack", string(debug.Stack()))
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
}
}()
next.ServeHTTP(w, r)
})
}
}
// cors lets the configured origins call the API from a browser (e.g. the
// Vite dev server on :5173). A "*" entry allows every origin.
func cors(allowed []string) middleware {
set := make(map[string]struct{}, len(allowed))
allowAll := false
for _, o := range allowed {
if o == "*" {
allowAll = true
}
set[o] = struct{}{}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if _, ok := set[origin]; origin != "" && (allowAll || ok) {
h := w.Header()
h.Set("Access-Control-Allow-Origin", origin)
h.Add("Vary", "Origin")
h.Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
h.Set("Access-Control-Max-Age", "600")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
}
next.ServeHTTP(w, r)
})
}
}
// timeout bounds each request's handler time. The body is the raw timeout
// response — http.TimeoutHandler does not set a content type for it.
func timeout(d time.Duration) middleware {
return func(next http.Handler) http.Handler {
return http.TimeoutHandler(next, d,
`{"error":{"code":"timeout","message":"request timed out"}}`)
}
}
// statusRecorder captures the response status for logging.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
// Unwrap lets http.ResponseController reach the underlying writer.
func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
+53
View File
@@ -0,0 +1,53 @@
package api
import (
"encoding/json"
"net/http"
)
// Error responses always use this envelope:
//
// {"error": {"code": "...", "message": "..."}}
//
// Known codes: invalid_json, validation_failed, not_found, invalid_id,
// method_not_allowed, internal_error, timeout, unavailable. Add new codes
// here when needed.
type errorResponse struct {
Error errorDetail `json:"error"`
}
type errorDetail struct {
Code string `json:"code"`
Message string `json:"message"`
}
// maxBodySize caps JSON request bodies at 1 MiB.
const maxBodySize = 1 << 20
// writeJSON sets the content type and encodes v.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
// The status line is already sent, so an encode failure can only be
// logged — and only by wrapping the ResponseWriter. Not worth it here.
_ = json.NewEncoder(w).Encode(v)
}
// writeError emits the error envelope with the given status and code.
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, errorResponse{Error: errorDetail{Code: code, Message: message}})
}
// decodeJSON strictly decodes the request body into dst: unknown fields and
// bodies over maxBodySize are rejected. It writes the 400 response itself
// and returns false, telling the handler to stop.
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", "request body is not valid JSON: "+err.Error())
return false
}
return true
}
+151
View File
@@ -0,0 +1,151 @@
// Package api wires HTTP routing, middleware, and JSON conventions on top
// of the stdlib ServeMux (Go 1.22+ method patterns and r.PathValue).
package api
import (
"context"
"log/slog"
"net/http"
"strings"
"time"
"git.k6n.net/mats/fullstack-template/internal/items"
)
// Pinger abstracts the database health check so tests can stub it.
type Pinger interface {
PingContext(ctx context.Context) error
}
// Deps are the server's runtime collaborators, wired in cmd/api.
type Deps struct {
Logger *slog.Logger
Items *items.Service
DB Pinger
AllowedOrigins []string
RequestTimeout time.Duration // zero means the 30s default
Auth *AuthDeps // nil disables OIDC: /api/auth answers 404, /api/v1 stays open
}
const defaultRequestTimeout = 30 * time.Second
// New builds the full handler: middleware chain around the route table.
// Middleware order (outermost first): logging → recover → cors → timeout
// → (requireAuth, only when OIDC is configured).
func New(deps Deps) http.Handler {
if deps.RequestTimeout <= 0 {
deps.RequestTimeout = defaultRequestTimeout
}
var authH *authHandler
if deps.Auth != nil {
if deps.Auth.SessionTTL <= 0 {
deps.Auth.SessionTTL = defaultSessionTTL
}
authH = &authHandler{
client: deps.Auth.Client,
signer: deps.Auth.Signer,
sessionTTL: deps.Auth.SessionTTL,
logger: deps.Logger,
}
}
s := &server{
logger: deps.Logger,
db: deps.DB,
items: &itemHandler{svc: deps.Items, logger: deps.Logger},
auth: authH,
}
mux := http.NewServeMux()
// Liveness is "the process is up"; readiness is "it can serve real
// traffic", here meaning the database answers.
mux.HandleFunc("GET /healthz", s.health)
mux.HandleFunc("GET /readyz", s.readiness)
if s.auth != nil {
mux.HandleFunc("GET /api/auth/session", s.auth.session)
mux.HandleFunc("GET /api/auth/login", s.auth.login)
mux.HandleFunc("GET /api/auth/callback", s.auth.callback)
mux.HandleFunc("POST /api/auth/logout", s.auth.logout)
}
// Reference CRUD resource. Method-specific patterns make the mux
// answer 405 with an Allow header for free; read segments with
// r.PathValue("id").
mux.HandleFunc("GET /api/v1/items", s.items.list)
mux.HandleFunc("POST /api/v1/items", s.items.create)
mux.HandleFunc("GET /api/v1/items/{id}", s.items.get)
mux.HandleFunc("PATCH /api/v1/items/{id}", s.items.update)
mux.HandleFunc("DELETE /api/v1/items/{id}", s.items.delete)
// The wrapper keeps the mux's automatic 405 + Allow responses for
// wrong-method requests and renders all unmatched routes as JSON 404s
// (the mux default is plain text).
mws := []middleware{
logging(deps.Logger),
recoverPanics(deps.Logger),
cors(deps.AllowedOrigins),
timeout(deps.RequestTimeout),
}
if s.auth != nil {
mws = append(mws, requireAuth(deps.Auth.Signer))
}
return chain(jsonNotFounds(mux), mws...)
}
type server struct {
logger *slog.Logger
db Pinger
items *itemHandler
auth *authHandler
}
// jsonNotFounds delegates to mux and turns its default plain-text responses
// for unmatched requests into the JSON error envelope. A registered "/" or
// catch-all route would swallow the mux's automatic 405s, so this checks
// mux.Handler instead: an empty pattern means nothing matched, and probing
// the registered methods distinguishes "wrong method" (405 + Allow) from
// "unknown route" (404). The probe runs only on the miss path.
func jsonNotFounds(mux *http.ServeMux) http.Handler {
methods := []string{
http.MethodGet, http.MethodPost, http.MethodPut,
http.MethodPatch, http.MethodDelete,
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, pattern := mux.Handler(r); pattern == "" {
var allowed []string
for _, m := range methods {
probe := r.Clone(r.Context())
probe.Method = m
if _, pattern := mux.Handler(probe); pattern != "" {
allowed = append(allowed, m)
}
}
if len(allowed) > 0 {
w.Header().Set("Allow", strings.Join(allowed, ", "))
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed",
r.Method+" is not allowed for this route")
return
}
writeError(w, http.StatusNotFound, "not_found", "route not found")
return
}
mux.ServeHTTP(w, r)
})
}
func (s *server) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *server) readiness(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := s.db.PingContext(ctx); err != nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "database is not reachable")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}
+295
View File
@@ -0,0 +1,295 @@
package api_test
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"sort"
"strings"
"sync"
"testing"
"time"
"git.k6n.net/mats/fullstack-template/internal/api"
"git.k6n.net/mats/fullstack-template/internal/items"
)
// These tests run the full HTTP stack (mux patterns, middleware, JSON
// conventions) against the real items service over an in-memory repository.
// fakeRepo is the same in-memory fake as internal/items/service_test.go,
// duplicated on purpose: test fakes stay package-local.
type fakeRepo struct {
mu sync.Mutex
nextID int64
byID map[int64]items.Item
}
func newFakeRepo() *fakeRepo {
return &fakeRepo{nextID: 1, byID: map[int64]items.Item{}}
}
func (f *fakeRepo) List(_ context.Context, fl items.Filter) ([]items.Item, error) {
f.mu.Lock()
defer f.mu.Unlock()
ids := make([]int64, 0, len(f.byID))
for id := range f.byID {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
out := []items.Item{}
for _, id := range ids {
item := f.byID[id]
if fl.Status != "" && item.Status != fl.Status {
continue
}
out = append(out, item)
}
if fl.Offset < len(out) {
out = out[fl.Offset:]
} else {
out = []items.Item{}
}
if fl.Limit < len(out) {
out = out[:fl.Limit]
}
return out, nil
}
func (f *fakeRepo) Get(_ context.Context, id int64) (items.Item, error) {
f.mu.Lock()
defer f.mu.Unlock()
item, ok := f.byID[id]
if !ok {
return items.Item{}, items.ErrNotFound
}
return item, nil
}
func (f *fakeRepo) Create(_ context.Context, item *items.Item) error {
f.mu.Lock()
defer f.mu.Unlock()
item.ID = f.nextID
f.nextID++
now := time.Now()
item.CreatedAt, item.UpdatedAt = now, now
f.byID[item.ID] = *item
return nil
}
func (f *fakeRepo) Update(_ context.Context, item *items.Item) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.byID[item.ID]; !ok {
return items.ErrNotFound
}
item.UpdatedAt = time.Now()
f.byID[item.ID] = *item
return nil
}
func (f *fakeRepo) Delete(_ context.Context, id int64) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.byID[id]; !ok {
return items.ErrNotFound
}
delete(f.byID, id)
return nil
}
// stubPinger fakes the database for /readyz.
type stubPinger struct{ err error }
func (p stubPinger) PingContext(context.Context) error { return p.err }
func newTestServer(t *testing.T) http.Handler {
t.Helper()
return api.New(api.Deps{
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
Items: items.NewService(newFakeRepo()),
DB: stubPinger{},
AllowedOrigins: []string{"http://localhost:5173"},
})
}
func do(t *testing.T, h http.Handler, method, target, body string) *httptest.ResponseRecorder {
t.Helper()
var rdr io.Reader
if body != "" {
rdr = strings.NewReader(body)
}
req := httptest.NewRequest(method, target, rdr)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func decodeBody(t *testing.T, rec *httptest.ResponseRecorder, dst any) {
t.Helper()
if err := json.NewDecoder(rec.Body).Decode(dst); err != nil {
t.Fatalf("decode response body %q: %v", rec.Body.String(), err)
}
}
func TestHealthAndReadiness(t *testing.T) {
h := newTestServer(t)
if rec := do(t, h, http.MethodGet, "/healthz", ""); rec.Code != http.StatusOK {
t.Errorf("healthz = %d, want 200", rec.Code)
}
if rec := do(t, h, http.MethodGet, "/readyz", ""); rec.Code != http.StatusOK {
t.Errorf("readyz = %d, want 200", rec.Code)
}
sick := api.New(api.Deps{
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
Items: items.NewService(newFakeRepo()),
DB: stubPinger{err: errors.New("down")},
})
if rec := do(t, sick, http.MethodGet, "/readyz", ""); rec.Code != http.StatusServiceUnavailable {
t.Errorf("readyz with dead db = %d, want 503", rec.Code)
}
}
func TestItemCRUDFlow(t *testing.T) {
h := newTestServer(t)
// Create.
rec := do(t, h, http.MethodPost, "/api/v1/items", `{"title":"First","description":"hello"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("create = %d: %s", rec.Code, rec.Body.String())
}
var created items.Item
decodeBody(t, rec, &created)
if created.ID != 1 || created.Title != "First" || created.Status != items.StatusActive {
t.Errorf("created = %+v", created)
}
if loc := rec.Header().Get("Location"); loc != "/api/v1/items/1" {
t.Errorf("Location = %q, want /api/v1/items/1", loc)
}
// Get by id (PathValue routing).
rec = do(t, h, http.MethodGet, "/api/v1/items/1", "")
if rec.Code != http.StatusOK {
t.Fatalf("get = %d: %s", rec.Code, rec.Body.String())
}
var got items.Item
decodeBody(t, rec, &got)
if got.Title != "First" {
t.Errorf("get title = %q", got.Title)
}
// Patch one field.
rec = do(t, h, http.MethodPatch, "/api/v1/items/1", `{"status":"archived"}`)
if rec.Code != http.StatusOK {
t.Fatalf("patch = %d: %s", rec.Code, rec.Body.String())
}
var patched items.Item
decodeBody(t, rec, &patched)
if patched.Status != items.StatusArchived || patched.Title != "First" {
t.Errorf("patched = %+v", patched)
}
// List.
rec = do(t, h, http.MethodGet, "/api/v1/items", "")
if rec.Code != http.StatusOK {
t.Fatalf("list = %d: %s", rec.Code, rec.Body.String())
}
var listed []items.Item
decodeBody(t, rec, &listed)
if len(listed) != 1 {
t.Fatalf("list len = %d, want 1", len(listed))
}
// Delete, then confirm it's gone.
if rec := do(t, h, http.MethodDelete, "/api/v1/items/1", ""); rec.Code != http.StatusNoContent {
t.Fatalf("delete = %d, want 204", rec.Code)
}
if rec := do(t, h, http.MethodGet, "/api/v1/items/1", ""); rec.Code != http.StatusNotFound {
t.Errorf("get after delete = %d, want 404", rec.Code)
}
}
func TestErrorResponses(t *testing.T) {
h := newTestServer(t)
do(t, h, http.MethodPost, "/api/v1/items", `{"title":"seed"}`)
tests := []struct {
name string
method string
target string
body string
wantStatus int
wantCode string
}{
{"invalid path id", "GET", "/api/v1/items/abc", "", 400, "invalid_id"},
{"unknown item", "GET", "/api/v1/items/999", "", 404, "not_found"},
{"broken json", "POST", "/api/v1/items", `{"title":`, 400, "invalid_json"},
{"unknown field", "POST", "/api/v1/items", `{"title":"x","wat":1}`, 400, "invalid_json"},
{"validation", "POST", "/api/v1/items", `{"title":""}`, 400, "validation_failed"},
{"unknown route", "GET", "/api/v1/nope", "", 404, "not_found"},
{"empty patch", "PATCH", "/api/v1/items/1", `{}`, 400, "validation_failed"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := do(t, h, tt.method, tt.target, tt.body)
if rec.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d (body %s)", rec.Code, tt.wantStatus, rec.Body.String())
}
var res struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
decodeBody(t, rec, &res)
if res.Error.Code != tt.wantCode {
t.Errorf("code = %q, want %q", res.Error.Code, tt.wantCode)
}
})
}
}
func TestMethodNotAllowed(t *testing.T) {
h := newTestServer(t)
rec := do(t, h, http.MethodPut, "/api/v1/items/1", `{}`)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("put = %d, want 405 (ServeMux method patterns)", rec.Code)
}
if allow := rec.Header().Get("Allow"); !strings.Contains(allow, "PATCH") {
t.Errorf("Allow = %q, want it to mention PATCH", allow)
}
}
func TestCORSPreflight(t *testing.T) {
h := newTestServer(t)
req := httptest.NewRequest(http.MethodOptions, "/api/v1/items", nil)
req.Header.Set("Origin", "http://localhost:5173")
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("preflight = %d, want 204", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:5173" {
t.Errorf("Allow-Origin = %q", got)
}
// Unknown origins get no CORS headers and fall through to the mux.
req = httptest.NewRequest(http.MethodOptions, "/api/v1/items", nil)
req.Header.Set("Origin", "http://evil.example")
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Allow-Origin for unknown origin = %q, want none", got)
}
}
+137
View File
@@ -0,0 +1,137 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
)
// Cookie names. The login-tx cookie is scoped to /api/auth so it is only
// sent to the endpoints that need it.
const (
CookieSession = "fs_session"
CookieLoginTx = "fs_auth_tx"
)
// Session is the signed-in identity kept in the session cookie. It is a
// self-contained, HMAC-protected claim set — not a bearer token: it carries
// no issuer credentials and only opens this API.
type Session struct {
Sub string
Name string
Email string
Roles []string
ExpiresAt time.Time
}
// Valid reports whether the session is still within its TTL.
func (s Session) Valid() bool { return time.Now().Before(s.ExpiresAt) }
// ErrTampered is returned when a cookie value fails its HMAC check or is
// malformed — treat it exactly like a missing cookie.
var ErrTampered = errors.New("auth cookie failed integrity check")
// Signer seals values into "<base64url(json)>.<base64url(hmac-sha256)>"
// cookie payloads and opens them back. Construction is cheap; keep one
// per server and share it.
type Signer struct{ key []byte }
// NewSigner returns a Signer keyed by secret.
func NewSigner(secret string) *Signer { return &Signer{key: []byte(secret)} }
// Seal encodes v into a signed cookie value.
func (s *Signer) Seal(v any) (string, error) {
payload, err := json.Marshal(v)
if err != nil {
return "", fmt.Errorf("marshal cookie payload: %w", err)
}
return base64.RawURLEncoding.EncodeToString(payload) + "." + s.mac(payload), nil
}
// Open verifies a sealed value and decodes it into v.
func (s *Signer) Open(sealed string, v any) error {
value, mac, ok := strings.Cut(sealed, ".")
if !ok {
return ErrTampered
}
payload, err := base64.RawURLEncoding.DecodeString(value)
if err != nil {
return ErrTampered
}
sig, err := base64.RawURLEncoding.DecodeString(mac)
if err != nil {
return ErrTampered
}
if !hmac.Equal(sig, s.macBytes(payload)) {
return ErrTampered
}
if err := json.Unmarshal(payload, v); err != nil {
return ErrTampered
}
return nil
}
func (s *Signer) mac(payload []byte) string {
return base64.RawURLEncoding.EncodeToString(s.macBytes(payload))
}
func (s *Signer) macBytes(payload []byte) []byte {
h := hmac.New(sha256.New, s.key)
h.Write(payload)
return h.Sum(nil)
}
// NewSessionCookie returns the Set-Cookie for a session valid for ttl.
func (s *Signer) NewSessionCookie(sess Session, ttl time.Duration, secure bool) (*http.Cookie, error) {
sess.ExpiresAt = time.Now().Add(ttl)
value, err := s.Seal(sess)
if err != nil {
return nil, err
}
return &http.Cookie{
Name: CookieSession,
Value: value,
Path: "/",
MaxAge: int(ttl.Seconds()),
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
}, nil
}
// NewLoginTxCookie returns the short-lived Set-Cookie carrying tx between
// the login redirect and the callback.
func (s *Signer) NewLoginTxCookie(tx LoginTx, secure bool) (*http.Cookie, error) {
value, err := s.Seal(tx)
if err != nil {
return nil, err
}
return &http.Cookie{
Name: CookieLoginTx,
Value: value,
Path: "/api/auth",
MaxAge: int(txTTL.Seconds()),
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
}, nil
}
// Expired returns a Set-Cookie that deletes the named cookie.
func Expired(name string, secure bool) *http.Cookie {
return &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
}
}
+140
View File
@@ -0,0 +1,140 @@
package auth
import (
"net/http"
"strings"
"testing"
"time"
)
func TestSignerSealOpen(t *testing.T) {
s := NewSigner("test-secret")
tx, err := NewLoginTx("/some/path")
if err != nil {
t.Fatalf("NewLoginTx: %v", err)
}
sealed, err := s.Seal(tx)
if err != nil {
t.Fatalf("Seal: %v", err)
}
var opened LoginTx
if err := s.Open(sealed, &opened); err != nil {
t.Fatalf("Open: %v", err)
}
// Compare fields: time.Time equality also compares the monotonic clock,
// which the JSON roundtrip drops.
if opened.State != tx.State || opened.Nonce != tx.Nonce ||
opened.Verifier != tx.Verifier || opened.ReturnTo != tx.ReturnTo ||
!opened.ExpiresAt.Equal(tx.ExpiresAt) {
t.Errorf("opened = %+v, want %+v", opened, tx)
}
}
func TestSignerRejectsTampering(t *testing.T) {
s := NewSigner("test-secret")
sess := Session{Sub: "user-1", ExpiresAt: time.Now().Add(time.Hour)}
sealed, err := s.Seal(sess)
if err != nil {
t.Fatalf("Seal: %v", err)
}
tests := []struct {
name string
value func(string) string
reason string
}{
{"flipped payload", func(v string) string {
return flipLastChar(strings.Split(v, ".")[0]) + "." + strings.Split(v, ".")[1]
}, "forged payload"},
{"flipped mac", func(v string) string {
return strings.Split(v, ".")[0] + "." + flipLastChar(strings.Split(v, ".")[1])
}, "forged mac"},
{"wrong key", func(v string) string {
other, _ := NewSigner("other-secret").Seal(sess)
return other
}, "signed with a different key"},
{"garbage", func(string) string { return "not-a-sealed-value" }, "garbage"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var out Session
if err := s.Open(tt.value(sealed), &out); err == nil {
t.Errorf("Open accepted %s", tt.reason)
}
})
}
}
func TestSessionAndTxCookies(t *testing.T) {
s := NewSigner("test-secret")
sessCookie, err := s.NewSessionCookie(Session{Sub: "u1"}, 2*time.Hour, true)
if err != nil {
t.Fatalf("NewSessionCookie: %v", err)
}
if sessCookie.Name != CookieSession || sessCookie.Path != "/" {
t.Errorf("session cookie = %+v", sessCookie)
}
if !sessCookie.HttpOnly || !sessCookie.Secure || sessCookie.SameSite != http.SameSiteLaxMode {
t.Errorf("session cookie attrs = %+v", sessCookie)
}
var sess Session
if err := s.Open(sessCookie.Value, &sess); err != nil {
t.Fatalf("Open session: %v", err)
}
if !sess.Valid() {
t.Error("session with future ExpiresAt should be valid")
}
sess.ExpiresAt = time.Now().Add(-time.Minute)
if sess.Valid() {
t.Error("expired session should be invalid")
}
tx, err := NewLoginTx("/")
if err != nil {
t.Fatalf("NewLoginTx: %v", err)
}
if !tx.Valid() {
t.Error("fresh login tx should be valid")
}
tx.ExpiresAt = time.Now().Add(-time.Second)
if tx.Valid() {
t.Error("expired login tx should be invalid")
}
tx, _ = NewLoginTx("/")
txCookie, err := s.NewLoginTxCookie(tx, false)
if err != nil {
t.Fatalf("NewLoginTxCookie: %v", err)
}
if txCookie.Name != CookieLoginTx || txCookie.Path != "/api/auth" {
t.Errorf("tx cookie = %+v", txCookie)
}
if !txCookie.HttpOnly || txCookie.Secure {
t.Errorf("tx cookie attrs = %+v", txCookie)
}
expired := Expired(CookieSession, true)
if expired.Name != CookieSession || expired.MaxAge != -1 {
t.Errorf("Expired cookie = %+v", expired)
}
}
func TestNewLoginTxUsesDistinctSecrets(t *testing.T) {
a, _ := NewLoginTx("/")
b, _ := NewLoginTx("/")
if a.State == b.State || a.Nonce == b.Nonce || a.Verifier == b.Verifier {
t.Error("state/nonce/verifier must be fresh per login")
}
}
// flipLastChar swaps the last character of a base64url value so any
// signature or payload check must fail.
func flipLastChar(v string) string {
last := "A"
if strings.HasSuffix(v, last) {
last = "B"
}
return v[:len(v)-1] + last
}
+157
View File
@@ -0,0 +1,157 @@
// Package auth implements the browser login flow against an OpenID Connect
// issuer: authorization code + PKCE (S256), discovery-driven endpoints, and
// RS256 ID token verification via JWKS. The signed-in identity is kept in an
// HMAC-protected session cookie (cookie.go) — tokens never reach the browser.
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
// txTTL bounds the login transaction: the gap between redirecting to the
// issuer and the callback that completes it.
const txTTL = 10 * time.Minute
// Config identifies this app to the issuer. Scopes defaults to
// ["openid","profile","email"] when empty.
type Config struct {
Issuer string
ClientID string
ClientSecret string
Scopes []string
}
// Client is the configured OIDC relying party. Discovery happens once in
// New; the client is immutable and safe for concurrent use.
type Client struct {
oauth oauth2.Config
verifier *oidc.IDTokenVerifier
}
// New discovers the issuer's endpoints and returns a ready client.
func New(ctx context.Context, cfg Config) (*Client, error) {
provider, err := oidc.NewProvider(ctx, cfg.Issuer)
if err != nil {
return nil, fmt.Errorf("discover issuer %s: %w", cfg.Issuer, err)
}
if len(cfg.Scopes) == 0 {
cfg.Scopes = []string{"openid", "profile", "email"}
}
return &Client{
oauth: oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Endpoint: provider.Endpoint(),
Scopes: cfg.Scopes,
},
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
}, nil
}
// User is the verified identity established by a completed login.
type User struct {
Sub string
Name string
Email string
Roles []string
}
// LoginTx is the per-login state kept in a short-lived cookie between the
// redirect to the issuer and the callback: state and nonce guard against
// CSRF and replay, the verifier carries PKCE, ReturnTo is where the browser
// lands afterwards.
type LoginTx struct {
State string
Nonce string
Verifier string
ReturnTo string
ExpiresAt time.Time
}
// NewLoginTx creates a login transaction with fresh state, nonce, and PKCE
// verifier for the given post-login path.
func NewLoginTx(returnTo string) (LoginTx, error) {
state, err := randomToken(16)
if err != nil {
return LoginTx{}, err
}
nonce, err := randomToken(16)
if err != nil {
return LoginTx{}, err
}
verifier, err := randomToken(48)
if err != nil {
return LoginTx{}, err
}
return LoginTx{
State: state,
Nonce: nonce,
Verifier: verifier,
ReturnTo: returnTo,
ExpiresAt: time.Now().Add(txTTL),
}, nil
}
// Valid reports whether the transaction is still within its TTL.
func (tx LoginTx) Valid() bool { return time.Now().Before(tx.ExpiresAt) }
// AuthCodeURL builds the issuer's authorization URL for tx — PKCE challenge
// and nonce included. redirectURI is the callback URL on the origin the
// browser came from (it must be registered with the issuer).
func (c *Client) AuthCodeURL(tx LoginTx, redirectURI string) string {
return c.oauth.AuthCodeURL(tx.State,
oauth2.SetAuthURLParam("redirect_uri", redirectURI),
oauth2.SetAuthURLParam("nonce", tx.Nonce),
oauth2.S256ChallengeOption(tx.Verifier),
)
}
// CompleteCode exchanges the authorization code (PKCE verifier from tx) and
// verifies the returned ID token — signature via JWKS, plus iss/aud/exp and
// the nonce — returning the signed-in user.
func (c *Client) CompleteCode(ctx context.Context, code, redirectURI string, tx LoginTx) (User, error) {
tok, err := c.oauth.Exchange(ctx, code,
oauth2.SetAuthURLParam("redirect_uri", redirectURI),
oauth2.VerifierOption(tx.Verifier),
)
if err != nil {
return User{}, fmt.Errorf("exchange authorization code: %w", err)
}
rawIDToken, ok := tok.Extra("id_token").(string)
if !ok || rawIDToken == "" {
return User{}, errors.New("token response carries no id_token")
}
idToken, err := c.verifier.Verify(ctx, rawIDToken)
if err != nil {
return User{}, fmt.Errorf("verify id_token: %w", err)
}
if idToken.Nonce != tx.Nonce {
return User{}, errors.New("id_token nonce does not match the login transaction")
}
var claims struct {
Name string `json:"name"`
Email string `json:"email"`
Roles []string `json:"roles"`
}
if err := idToken.Claims(&claims); err != nil {
return User{}, fmt.Errorf("decode id_token claims: %w", err)
}
return User{Sub: idToken.Subject, Name: claims.Name, Email: claims.Email, Roles: claims.Roles}, nil
}
// randomToken returns a URL-safe random string with n bytes of entropy.
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("read randomness: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
+155
View File
@@ -0,0 +1,155 @@
// Package config loads runtime configuration from the environment.
//
// Every setting has a safe local default — the database is a local SQLite
// file, so the server runs with `make migrate-up && make run` and no .env
// file, no containers. Point DATABASE_URL at Postgres for the real thing.
// This is the only package that reads os.Getenv — add new settings here
// and to .env.example, never at the point of use.
package config
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"net/url"
"os"
"strings"
"time"
)
// Config is the validated runtime configuration.
type Config struct {
// Addr is the HTTP listen address.
Addr string
// DatabaseURL selects the database: "sqlite:FILE" for a local file
// (dev default, pure-Go driver) or a "postgres://…" DSN.
DatabaseURL string
// AllowedOrigins lists CORS origins permitted to call the API from a
// browser (the Vite dev server on :5173 by default). "*" allows all.
AllowedOrigins []string
// LogLevel and LogFormat configure the slog handler.
LogLevel slog.Level
LogFormat string // "json" or "text"
// DebugQueries enables Bun query logging (bundebug).
DebugQueries bool
// OIDCIssuer is the OpenID Connect issuer; endpoints are discovered,
// never hardcoded. Auth is enabled when OIDCClientID is non-empty.
OIDCIssuer string
// OIDCClientID and OIDCClientSecret identify this app as a confidential
// client of the issuer. An empty OIDCClientID disables auth entirely:
// /api/auth/* answers 404 and /api/v1 stays open (standalone mode).
OIDCClientID string
OIDCClientSecret string
// AuthSessionSecret signs session cookies. When unset, a random
// ephemeral secret is generated (AuthSessionSecretEphemeral = true) —
// every restart then invalidates all sessions.
AuthSessionSecret string
AuthSessionSecretEphemeral bool
// AuthSessionTTL is how long a session cookie stays valid.
AuthSessionTTL time.Duration
}
// Load reads the environment and returns a validated Config.
func Load() (Config, error) {
cfg := Config{
Addr: env("API_ADDR", ":8080"),
DatabaseURL: env("DATABASE_URL", "sqlite:dev.db"),
AllowedOrigins: splitCSV(env("CORS_ALLOWED_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173")),
LogFormat: strings.ToLower(env("LOG_FORMAT", "json")),
DebugQueries: env("DB_DEBUG", "false") == "true",
OIDCIssuer: strings.TrimRight(env("OIDC_ISSUER", "https://users.k6n.net"), "/"),
OIDCClientID: env("OIDC_CLIENT_ID", ""),
OIDCClientSecret: env("OIDC_CLIENT_SECRET", ""),
AuthSessionSecret: env("AUTH_SESSION_SECRET", ""),
}
sessionTTL, ttlErr := time.ParseDuration(env("AUTH_SESSION_TTL", "168h"))
if ttlErr == nil {
cfg.AuthSessionTTL = sessionTTL
}
var errs []error
level := strings.ToLower(env("LOG_LEVEL", "info"))
switch level {
case "debug":
cfg.LogLevel = slog.LevelDebug
case "info":
cfg.LogLevel = slog.LevelInfo
case "warn":
cfg.LogLevel = slog.LevelWarn
case "error":
cfg.LogLevel = slog.LevelError
default:
errs = append(errs, fmt.Errorf("invalid LOG_LEVEL %q (want debug, info, warn, or error)", level))
}
if cfg.Addr == "" {
errs = append(errs, errors.New("API_ADDR must not be empty"))
}
if cfg.DatabaseURL == "" {
errs = append(errs, errors.New("DATABASE_URL must not be empty"))
}
if cfg.LogFormat != "json" && cfg.LogFormat != "text" {
errs = append(errs, fmt.Errorf("invalid LOG_FORMAT %q (want json or text)", cfg.LogFormat))
}
if ttlErr != nil {
errs = append(errs, fmt.Errorf("invalid AUTH_SESSION_TTL: %v", ttlErr))
} else if cfg.AuthSessionTTL <= 0 {
errs = append(errs, errors.New("AUTH_SESSION_TTL must be positive"))
}
if _, err := url.Parse(cfg.OIDCIssuer); err != nil || !strings.HasPrefix(cfg.OIDCIssuer, "https://") && !strings.HasPrefix(cfg.OIDCIssuer, "http://") {
errs = append(errs, fmt.Errorf("invalid OIDC_ISSUER %q (want an http(s) URL)", cfg.OIDCIssuer))
}
if cfg.OIDCClientID != "" && cfg.OIDCClientSecret == "" {
errs = append(errs, errors.New("OIDC_CLIENT_SECRET is required when OIDC_CLIENT_ID is set"))
}
if cfg.AuthSessionSecret == "" {
secret, err := randomSecret()
if err != nil {
return Config{}, fmt.Errorf("generate session secret: %w", err)
}
cfg.AuthSessionSecret = secret
cfg.AuthSessionSecretEphemeral = true
}
if len(errs) > 0 {
return Config{}, fmt.Errorf("invalid config: %w", errors.Join(errs...))
}
return cfg, nil
}
// randomSecret returns a URL-safe random string for signing session
// cookies when AUTH_SESSION_SECRET is not provided.
func randomSecret() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// env returns the environment variable or fallback when unset/blank.
func env(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
return fallback
}
// splitCSV splits a comma-separated env value, dropping blank entries.
func splitCSV(v string) []string {
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+68
View File
@@ -0,0 +1,68 @@
// Package database owns the Bun connection and the migration registry.
package database
import (
"database/sql"
"fmt"
"strings"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/pgdialect"
"github.com/uptrace/bun/dialect/sqlitedialect"
"github.com/uptrace/bun/driver/pgdriver"
"github.com/uptrace/bun/driver/sqliteshim"
"github.com/uptrace/bun/extra/bundebug"
"github.com/uptrace/bun/migrate"
"github.com/uptrace/bun/schema"
"git.k6n.net/mats/fullstack-template/internal/config"
migrations "git.k6n.net/mats/fullstack-template/internal/database/migrations"
)
// Open connects to the database named by cfg.DatabaseURL:
//
// sqlite:FILE?params Bun + modernc sqlite (pure Go, no CGO) — dev default
// postgres://… Bun + pgdriver against Postgres
//
// It does not ping — call PingContext during startup to fail fast.
func Open(cfg config.Config) (*bun.DB, error) {
var sqldb *sql.DB
var dialect schema.Dialect
switch {
case strings.HasPrefix(cfg.DatabaseURL, "sqlite:"):
dsn := strings.TrimPrefix(cfg.DatabaseURL, "sqlite:")
// Sensible dev defaults when the DSN brings no pragmas of its own:
// WAL keeps readers unblocked during writes, busy_timeout waits out
// the remaining write locks instead of erroring, foreign_keys is
// what everyone expects to be on.
if !strings.Contains(dsn, "_pragma") {
sep := "?"
if strings.Contains(dsn, "?") {
sep = "&"
}
dsn += sep + "_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"
}
var err error
sqldb, err = sql.Open(sqliteshim.ShimName, dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite %q: %w", dsn, err)
}
dialect = sqlitedialect.New()
default:
sqldb = sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(cfg.DatabaseURL)))
dialect = pgdialect.New()
}
db := bun.NewDB(sqldb, dialect)
if cfg.DebugQueries {
db.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true)))
}
return db, nil
}
// NewMigrator returns a bun migrator over the migrations registered in
// internal/database/migrations. See cmd/migrate for the CLI around it.
func NewMigrator(db *bun.DB) *migrate.Migrator {
return migrate.NewMigrator(db, migrations.Migrations)
}
@@ -0,0 +1,84 @@
package database_test
import (
"context"
"errors"
"testing"
"git.k6n.net/mats/fullstack-template/internal/config"
"git.k6n.net/mats/fullstack-template/internal/database"
"git.k6n.net/mats/fullstack-template/internal/items"
)
// The whole persistence stack against an in-memory SQLite database:
// migrations produce a working schema and the real BunRepository
// round-trips items. This is also the cheapest way to catch dialect drift
// between the model and the migrations — no Postgres container required.
func TestSQLiteSchemaRoundTrip(t *testing.T) {
ctx := context.Background()
db, err := database.Open(config.Config{DatabaseURL: "sqlite:file:mem?mode=memory&cache=shared"})
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
// One connection keeps the shared in-memory database stable across
// the pool (and mirrors how single-writer sqlite behaves anyway).
db.SetMaxOpenConns(1)
migrator := database.NewMigrator(db)
if err := migrator.Init(ctx); err != nil {
t.Fatalf("migrator init: %v", err)
}
if _, err := migrator.Migrate(ctx); err != nil {
t.Fatalf("migrate up: %v", err)
}
svc := items.NewService(items.NewBunRepository(db))
created, err := svc.Create(ctx, items.CreateInput{Title: "first", Description: "from sqlite"})
if err != nil {
t.Fatalf("create: %v", err)
}
if created.ID == 0 || created.Status != items.StatusActive {
t.Fatalf("create returned %+v", created)
}
got, err := svc.Get(ctx, created.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Title != "first" || got.CreatedAt.IsZero() {
t.Fatalf("get returned %+v", got)
}
archived, err := svc.Update(ctx, created.ID, items.UpdateInput{Status: ptr(items.StatusArchived)})
if err != nil {
t.Fatalf("update: %v", err)
}
if archived.Status != items.StatusArchived {
t.Fatalf("update returned %+v", archived)
}
listed, err := svc.List(ctx, items.Filter{Status: items.StatusArchived})
if err != nil {
t.Fatalf("list: %v", err)
}
if len(listed) != 1 {
t.Fatalf("list archived len = %d, want 1", len(listed))
}
if err := svc.Delete(ctx, created.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := svc.Get(ctx, created.ID); !errors.Is(err, items.ErrNotFound) {
t.Fatalf("get after delete: got %v, want ErrNotFound", err)
}
// The down migration must undo the up migration cleanly.
if _, err := migrator.Rollback(ctx); err != nil {
t.Fatalf("rollback: %v", err)
}
}
func ptr[T any](v T) *T { return &v }
@@ -0,0 +1,31 @@
package migrations
import (
"context"
"github.com/uptrace/bun"
"git.k6n.net/mats/fullstack-template/internal/items"
)
func init() {
Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error {
_, err := db.NewCreateTable().
Model((*items.Item)(nil)).
IfNotExists().
Exec(ctx)
if err != nil {
return err
}
_, err = db.NewCreateIndex().
Model((*items.Item)(nil)).
IfNotExists().
Index("items_status_idx").
Column("status").
Exec(ctx)
return err
}, func(ctx context.Context, db *bun.DB) error {
_, err := db.NewDropTable().Model((*items.Item)(nil)).IfExists().Exec(ctx)
return err
})
}
@@ -0,0 +1,18 @@
// Package migrations holds the schema migrations: one Go file per
// migration, named <YYYYMMDDHHMMSS>_<snake_name>.go, each registering
// itself into Migrations via init(). `make migrate-new` scaffolds the next
// one with a fresh timestamp.
//
// Prefer model-driven DDL (db.NewCreateTable().Model((*pkg.Thing)(nil))) —
// bun emits dialect-correct SQL for both sqlite and postgres, so migrations
// stay portable. Reach for db.Exec with dialect-specific SQL only when a
// model can't express it.
package migrations
import "github.com/uptrace/bun/migrate"
// Migrations is the registry consumed by internal/database.NewMigrator.
// The directory option tells `migrate new` where to write (from backend/).
var Migrations = migrate.NewMigrations(
migrate.WithMigrationsDirectory("internal/database/migrations"),
)
+89
View File
@@ -0,0 +1,89 @@
// Package items is the reference vertical slice: model, repository
// interface, Bun implementation, and service. To add a new resource, copy
// this package's shape and register its routes in internal/api.
package items
import (
"context"
"errors"
"time"
"github.com/uptrace/bun"
)
// Status is the lifecycle state of an item, stored as text.
type Status string
const (
StatusActive Status = "active"
StatusArchived Status = "archived"
)
// Valid reports whether s is a known status value.
func (s Status) Valid() bool {
return s == StatusActive || s == StatusArchived
}
// Item is the persisted model. Struct tags are Bun's mapping; json tags
// define the wire format (camelCase, matching the frontend).
type Item struct {
bun.BaseModel `bun:"table:items,alias:i"`
ID int64 `bun:"id,pk,autoincrement" json:"id"`
Title string `bun:"title,notnull" json:"title"`
Description string `bun:"description,nullzero" json:"description"`
Status Status `bun:"status,notnull,default:'active'" json:"status"`
CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp" json:"createdAt"`
UpdatedAt time.Time `bun:"updated_at,notnull,default:current_timestamp" json:"updatedAt"`
}
// BeforeAppendModel stamps timestamps on INSERT and UPDATE queries.
// Postgres stores microsecond UTC; truncate + UTC so values set in Go
// (PATCH responses) match what round-trips through the database.
func (i *Item) BeforeAppendModel(_ context.Context, query bun.Query) error {
now := time.Now().UTC().Truncate(time.Microsecond)
switch query.(type) {
case *bun.InsertQuery:
i.CreatedAt, i.UpdatedAt = now, now
case *bun.UpdateQuery:
i.UpdatedAt = now
}
return nil
}
// Filter narrows and pages List queries. Apply normalizeFilter (via the
// service) before handing one to a repository.
type Filter struct {
Status Status
Limit int
Offset int
}
// List result paging defaults, applied by normalizeFilter.
const (
DefaultLimit = 50
MaxLimit = 100
)
// Repository is the persistence contract for items. The service depends on
// this interface — not on Bun — so tests use in-memory fakes and other
// backends remain possible.
type Repository interface {
List(ctx context.Context, f Filter) ([]Item, error)
Get(ctx context.Context, id int64) (Item, error)
Create(ctx context.Context, item *Item) error
Update(ctx context.Context, item *Item) error
Delete(ctx context.Context, id int64) error
}
// ErrNotFound is returned by repositories and services when an item does
// not exist. Handlers map it to 404.
var ErrNotFound = errors.New("item not found")
// ValidationError reports an invalid field value. Handlers map it to 400.
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string { return e.Field + ": " + e.Reason }
+81
View File
@@ -0,0 +1,81 @@
package items
import (
"context"
"database/sql"
"errors"
"github.com/uptrace/bun"
)
// BunRepository is the Postgres implementation of Repository. It is the
// only file in the package that speaks Bun/SQL.
type BunRepository struct {
db *bun.DB
}
// NewBunRepository wraps a *bun.DB opened by internal/database.
func NewBunRepository(db *bun.DB) *BunRepository {
return &BunRepository{db: db}
}
// List returns items newest-first, honoring the (already normalized) filter.
// An empty result set encodes as [] on the wire, not null.
func (r *BunRepository) List(ctx context.Context, f Filter) ([]Item, error) {
var out []Item
q := r.db.NewSelect().Model(&out)
if f.Status != "" {
q = q.Where("i.status = ?", f.Status)
}
if err := q.
Order("i.created_at DESC", "i.id DESC").
Limit(f.Limit).
Offset(f.Offset).
Scan(ctx); err != nil {
return nil, err
}
if out == nil {
out = []Item{}
}
return out, nil
}
// Get fetches a single item by id, mapping missing rows to ErrNotFound.
func (r *BunRepository) Get(ctx context.Context, id int64) (Item, error) {
var item Item
err := r.db.NewSelect().Model(&item).Where("i.id = ?", id).Scan(ctx)
if errors.Is(err, sql.ErrNoRows) {
return Item{}, ErrNotFound
}
return item, err
}
// Create inserts item and reads back the generated columns.
func (r *BunRepository) Create(ctx context.Context, item *Item) error {
return r.db.NewInsert().Model(item).Returning("*").Scan(ctx, item)
}
// Update persists item (matched by primary key); ErrNotFound when the row
// is gone.
func (r *BunRepository) Update(ctx context.Context, item *Item) error {
res, err := r.db.NewUpdate().Model(item).WherePK().Exec(ctx)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// Delete removes an item by id; ErrNotFound when the row is gone.
func (r *BunRepository) Delete(ctx context.Context, id int64) error {
res, err := r.db.NewDelete().Model((*Item)(nil)).Where("i.id = ?", id).Exec(ctx)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
+147
View File
@@ -0,0 +1,147 @@
package items
import (
"context"
"strings"
)
// Field length limits enforced on input.
const (
maxTitleLen = 200
maxDescriptionLen = 2000
)
// Service holds the business rules for items. It depends only on the
// Repository interface; main wires the Bun implementation in.
type Service struct {
repo Repository
}
// NewService returns a Service over the given repository.
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
// CreateInput is the request body for creating an item.
type CreateInput struct {
Title string `json:"title"`
Description string `json:"description"`
Status Status `json:"status"`
}
// UpdateInput patches only provided fields (nil = leave untouched), which
// is why every field is a pointer.
type UpdateInput struct {
Title *string `json:"title"`
Description *string `json:"description"`
Status *Status `json:"status"`
}
// List validates the filter and delegates to the repository.
func (s *Service) List(ctx context.Context, f Filter) ([]Item, error) {
f, err := normalizeFilter(f)
if err != nil {
return nil, err
}
return s.repo.List(ctx, f)
}
// Get returns one item.
func (s *Service) Get(ctx context.Context, id int64) (Item, error) {
return s.repo.Get(ctx, id)
}
// Create validates input and stores a new item.
func (s *Service) Create(ctx context.Context, in CreateInput) (Item, error) {
item := Item{
Title: strings.TrimSpace(in.Title),
Description: strings.TrimSpace(in.Description),
}
if item.Title == "" {
return Item{}, &ValidationError{Field: "title", Reason: "required"}
}
if len(item.Title) > maxTitleLen {
return Item{}, &ValidationError{Field: "title", Reason: "must be at most 200 characters"}
}
if len(item.Description) > maxDescriptionLen {
return Item{}, &ValidationError{Field: "description", Reason: "must be at most 2000 characters"}
}
switch in.Status {
case "":
item.Status = StatusActive
case StatusActive, StatusArchived:
item.Status = in.Status
default:
return Item{}, &ValidationError{Field: "status", Reason: "must be active or archived"}
}
if err := s.repo.Create(ctx, &item); err != nil {
return Item{}, err
}
return item, nil
}
// Update applies a partial patch to an existing item.
func (s *Service) Update(ctx context.Context, id int64, in UpdateInput) (Item, error) {
if in.Title == nil && in.Description == nil && in.Status == nil {
return Item{}, &ValidationError{Field: "body", Reason: "at least one field must be set"}
}
item, err := s.repo.Get(ctx, id)
if err != nil {
return Item{}, err
}
if in.Title != nil {
item.Title = strings.TrimSpace(*in.Title)
if item.Title == "" {
return Item{}, &ValidationError{Field: "title", Reason: "required"}
}
if len(item.Title) > maxTitleLen {
return Item{}, &ValidationError{Field: "title", Reason: "must be at most 200 characters"}
}
}
if in.Description != nil {
item.Description = strings.TrimSpace(*in.Description)
if len(item.Description) > maxDescriptionLen {
return Item{}, &ValidationError{Field: "description", Reason: "must be at most 2000 characters"}
}
}
if in.Status != nil {
if !in.Status.Valid() {
return Item{}, &ValidationError{Field: "status", Reason: "must be active or archived"}
}
item.Status = *in.Status
}
if err := s.repo.Update(ctx, &item); err != nil {
return Item{}, err
}
return item, nil
}
// Delete removes an item.
func (s *Service) Delete(ctx context.Context, id int64) error {
return s.repo.Delete(ctx, id)
}
// normalizeFilter applies paging defaults and validates the status filter.
func normalizeFilter(f Filter) (Filter, error) {
switch f.Status {
case "":
case StatusActive, StatusArchived:
default:
return Filter{}, &ValidationError{Field: "status", Reason: "must be active or archived"}
}
if f.Limit <= 0 {
f.Limit = DefaultLimit
}
if f.Limit > MaxLimit {
f.Limit = MaxLimit
}
if f.Offset < 0 {
f.Offset = 0
}
return f, nil
}
+231
View File
@@ -0,0 +1,231 @@
package items_test
import (
"context"
"errors"
"sort"
"strings"
"sync"
"testing"
"time"
"git.k6n.net/mats/fullstack-template/internal/items"
)
// fakeRepo is an in-memory items.Repository — the point of the Repository
// interface is that these tests never touch a database.
type fakeRepo struct {
mu sync.Mutex
nextID int64
byID map[int64]items.Item
}
func newFakeRepo() *fakeRepo {
return &fakeRepo{nextID: 1, byID: map[int64]items.Item{}}
}
func (f *fakeRepo) List(_ context.Context, fl items.Filter) ([]items.Item, error) {
f.mu.Lock()
defer f.mu.Unlock()
ids := make([]int64, 0, len(f.byID))
for id := range f.byID {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
out := []items.Item{}
for _, id := range ids {
item := f.byID[id]
if fl.Status != "" && item.Status != fl.Status {
continue
}
out = append(out, item)
}
if fl.Offset < len(out) {
out = out[fl.Offset:]
} else {
out = []items.Item{}
}
if fl.Limit < len(out) {
out = out[:fl.Limit]
}
return out, nil
}
func (f *fakeRepo) Get(_ context.Context, id int64) (items.Item, error) {
f.mu.Lock()
defer f.mu.Unlock()
item, ok := f.byID[id]
if !ok {
return items.Item{}, items.ErrNotFound
}
return item, nil
}
func (f *fakeRepo) Create(_ context.Context, item *items.Item) error {
f.mu.Lock()
defer f.mu.Unlock()
item.ID = f.nextID
f.nextID++
now := time.Now()
item.CreatedAt, item.UpdatedAt = now, now
f.byID[item.ID] = *item
return nil
}
func (f *fakeRepo) Update(_ context.Context, item *items.Item) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.byID[item.ID]; !ok {
return items.ErrNotFound
}
item.UpdatedAt = time.Now()
f.byID[item.ID] = *item
return nil
}
func (f *fakeRepo) Delete(_ context.Context, id int64) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.byID[id]; !ok {
return items.ErrNotFound
}
delete(f.byID, id)
return nil
}
func newService() (*items.Service, *fakeRepo) {
repo := newFakeRepo()
return items.NewService(repo), repo
}
func TestCreateValidation(t *testing.T) {
svc, _ := newService()
ctx := context.Background()
tests := []struct {
name string
in items.CreateInput
want string // expected ValidationError.Field
}{
{"empty title", items.CreateInput{Title: " "}, "title"},
{"long title", items.CreateInput{Title: strings.Repeat("x", 201)}, "title"},
{"bad status", items.CreateInput{Title: "ok", Status: "nope"}, "status"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := svc.Create(ctx, tt.in)
var vErr *items.ValidationError
if !errors.As(err, &vErr) {
t.Fatalf("got error %v, want *items.ValidationError", err)
}
if vErr.Field != tt.want {
t.Errorf("field = %q, want %q", vErr.Field, tt.want)
}
})
}
}
func TestCreateDefaults(t *testing.T) {
svc, _ := newService()
item, err := svc.Create(context.Background(), items.CreateInput{Title: " hello "})
if err != nil {
t.Fatalf("Create: %v", err)
}
if item.Title != "hello" {
t.Errorf("title = %q, want trimmed %q", item.Title, "hello")
}
if item.Status != items.StatusActive {
t.Errorf("status = %q, want default %q", item.Status, items.StatusActive)
}
}
func TestUpdatePatchesOnlyProvidedFields(t *testing.T) {
svc, _ := newService()
ctx := context.Background()
created, err := svc.Create(ctx, items.CreateInput{Title: "original", Description: "keep me"})
if err != nil {
t.Fatalf("Create: %v", err)
}
archived := items.StatusArchived
updated, err := svc.Update(ctx, created.ID, items.UpdateInput{Status: &archived})
if err != nil {
t.Fatalf("Update: %v", err)
}
if updated.Status != items.StatusArchived {
t.Errorf("status = %q, want %q", updated.Status, items.StatusArchived)
}
if updated.Title != "original" || updated.Description != "keep me" {
t.Errorf("untouched fields changed: %+v", updated)
}
}
func TestUpdateRequiresField(t *testing.T) {
svc, _ := newService()
ctx := context.Background()
created, _ := svc.Create(ctx, items.CreateInput{Title: "x"})
_, err := svc.Update(ctx, created.ID, items.UpdateInput{})
var vErr *items.ValidationError
if !errors.As(err, &vErr) || vErr.Field != "body" {
t.Fatalf("got %v, want validation error on body", err)
}
}
func TestUpdateNotFound(t *testing.T) {
svc, _ := newService()
title := "x"
_, err := svc.Update(context.Background(), 999, items.UpdateInput{Title: &title})
if !errors.Is(err, items.ErrNotFound) {
t.Fatalf("got %v, want ErrNotFound", err)
}
}
func TestListFilterAndPaging(t *testing.T) {
svc, _ := newService()
ctx := context.Background()
svc.Create(ctx, items.CreateInput{Title: "one"})
svc.Create(ctx, items.CreateInput{Title: "two", Status: items.StatusArchived})
svc.Create(ctx, items.CreateInput{Title: "three", Status: items.StatusArchived})
got, err := svc.List(ctx, items.Filter{Status: items.StatusArchived})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if _, err := svc.List(ctx, items.Filter{Status: "bogus"}); err == nil {
t.Error("List with bogus status: want validation error")
}
got, err = svc.List(ctx, items.Filter{Limit: 1})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != items.DefaultLimit && len(got) != 1 {
t.Fatalf("len = %d, want 1", len(got))
}
}
func TestDeleteFlow(t *testing.T) {
svc, _ := newService()
ctx := context.Background()
item, _ := svc.Create(ctx, items.CreateInput{Title: "gone"})
if err := svc.Delete(ctx, item.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := svc.Get(ctx, item.ID); !errors.Is(err, items.ErrNotFound) {
t.Fatalf("Get after delete: got %v, want ErrNotFound", err)
}
if err := svc.Delete(ctx, item.ID); !errors.Is(err, items.ErrNotFound) {
t.Fatalf("second delete: got %v, want ErrNotFound", err)
}
}