Files
todo-api/CLAUDE.md

121 lines
6.4 KiB
Markdown
Raw Permalink Normal View History

# todo-api
POC application demonstrating the full micro-lib stack: N-layer architecture, SQLite persistence, header-based auth, and RBAC permission enforcement.
## Purpose
This is not a library. It is a runnable REST API that exercises every micro-lib tier in a realistic but containerless setup. The goal is to validate that the micro-lib modules compose correctly, that the RBAC contract between `httpmw.Auth`-equivalent middleware and `rbac.PermissionMask` works end-to-end, and that the launcher lifecycle manages database and HTTP server startup cleanly.
## Tier & Dependencies
**Tier 5** (application). Imports:
- `code.nochebuena.dev/go/launcher` — lifecycle orchestration
- `code.nochebuena.dev/go/httpserver` — HTTP server (Tier 4)
- `code.nochebuena.dev/go/httpmw` — request ID, recovery, request logger middleware
- `code.nochebuena.dev/go/logz` — structured logger
- `code.nochebuena.dev/go/sqlite` — SQLite client (Tier 1)
- `code.nochebuena.dev/go/rbac` — identity and permission types
- `code.nochebuena.dev/go/valid` — input validation
- `code.nochebuena.dev/go/httputil` — handler adapters
- `code.nochebuena.dev/go/xerrors` — typed errors (NotFound)
- `github.com/go-chi/chi/v5` — used in application for route grouping
- `github.com/google/uuid` — user ID generation
## Key Design Decisions
- **N-layer architecture** (ADR-001): `cmd → application → handler → service → repository → domain`. Each layer depends only on its inner neighbours via interfaces. `application` is the only package that knows the full object graph.
- **Header-based auth** (ADR-002): `middleware.Auth` reads `X-User-ID`, looks up the user, and puts an `rbac.Identity` in context — identical output contract to `httpauth-firebase`. Swapping in real Firebase auth requires only replacing the middleware.
- **Domain-owned permission bits** (ADR-003): `domain.PermReadTodo = 0` and `domain.PermWriteTodo = 1` define bit positions. `domain.ResourceTodos = "todos"` is the key in `user_role`. All layers import these constants from `domain`.
- **Routes registered in BeforeStart**: Route registration happens in `lc.BeforeStart(...)` so SQLite tables exist before handlers are reachable. This is the correct place in the launcher lifecycle — after `db.OnInit` migrates the schema, before `srv.OnStart` binds the port.
- **logAdapter**: `logz.Logger.With(...)` returns `logz.Logger`; `httpmw.Logger.With(...)` must return `httpmw.Logger`. A local `logAdapter` struct in `application` bridges this mismatch without requiring either library to change.
- **SQLite — no container required**: The database file is `todos.db` in the working directory. Local development and testing require no Docker, no Postgres, no network access.
## Patterns
**Layer interfaces** (defined at consumption site in Go convention):
```
repository.TodoRepository — consumed by service
repository.UserRepository — consumed by service and middleware
rbac.PermissionProvider — consumed by middleware.Require
service.TodoService — consumed by handler
service.UserService — consumed by handler
```
**Full middleware + route wiring in BeforeStart:**
```go
lc.BeforeStart(func() error {
// run migrations
db.GetExecutor(ctx).ExecContext(ctx, migrate)
// open endpoint
srv.Post("/users", userH.Create)
// auth-gated group
srv.Group(func(r chi.Router) {
r.Use(appMW.Auth(userRepo))
r.With(appMW.Require(permProvider, domain.ResourceTodos, domain.PermReadTodo)).
Get("/todos", todoH.FindAll)
r.With(appMW.Require(permProvider, domain.ResourceTodos, domain.PermWriteTodo)).
Post("/todos", todoH.Create)
})
return nil
})
```
**Creating a user with permissions (curl):**
```sh
# Create a user with read+write on todos
curl -X POST http://localhost:3000/users \
-H 'Content-Type: application/json' \
-d '{"name":"Alice","email":"alice@example.com","can_read":true,"can_write":true}'
# Use the returned ID to authenticate
curl http://localhost:3000/todos -H 'X-User-ID: <id>'
```
**Database schema:**
```sql
CREATE TABLE users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE user_role (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
resource TEXT NOT NULL,
permissions INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, resource)
);
CREATE TABLE todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
## What to Avoid
- Do not add business logic to `application`. It is a wiring package only.
- Do not import `application` from any `internal/` sub-package. The dependency arrow points into `application`, never out.
- Do not define permission constants in `repository` or `middleware`. They belong in `domain` (ADR-003).
- Do not use the header auth pattern in production. `X-User-ID` is unauthenticated. Replace with `httpauth-firebase` or equivalent before any real deployment.
- Do not reuse or reassign existing permission bit positions. Append new bits at the next available position.
- Do not run migrations outside of `BeforeStart`. Running them in `OnInit` would execute before other components are ready; running them in `OnStart` races with route availability.
- Do not add telemetry imports to this POC unless demonstrating the telemetry module specifically. The OTel no-op default applies — adding `telemetry.New` is a one-line addition if needed.
## Testing Notes
- There are no tests in this POC currently. The structure is designed for testability: each layer depends only on interfaces, so handlers, services, and repositories can be tested with mock implementations.
- Service-layer tests: implement `repository.TodoRepository` / `repository.UserRepository` as in-memory stubs, construct the service, and call methods directly.
- Handler-layer tests: implement `service.TodoService` as a stub, use `httptest.NewRequest` + `httptest.NewRecorder` with the handler's `ServeHTTP`.
- Middleware tests: use `httptest.NewRequest`, set `X-User-ID`, and verify that `rbac.Identity` is present in context after `Auth` runs.
- Integration tests (optional): start a real SQLite in-memory database (`sqlite.Config{Path: ":memory:"}`), run migrations, and exercise the full stack.