121 lines
6.4 KiB
Markdown
121 lines
6.4 KiB
Markdown
|
|
# 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.
|