feat(todo-api): add full-stack POC demonstrating micro-lib v0.9.0
Runnable REST API exercising every micro-lib tier in a containerless setup: N-layer architecture, SQLite persistence, header-based auth simulating Firebase output, and bit-mask RBAC enforcement. What's included: - cmd/todo-api: minimal main delegating to application.Run - internal/application: full object graph wiring — launcher, sqlite, httpserver, httpmw stack, routes in BeforeStart - internal/domain: User entity, ResourceTodos constant, PermReadTodo/PermWriteTodo bit positions - internal/repository: TodoRepository, UserRepository, DBPermissionProvider (SQLite via modernc) - internal/service: TodoService, UserService with interface-based dependencies - internal/handler: TodoHandler, UserHandler using httputil adapters and valid for input validation - internal/middleware: Auth (X-User-ID → rbac.Identity) and Require (bit-mask permission gate) - logAdapter: bridges logz.Logger.With return type to httpmw.Logger interface - SQLite schema: users, user_role (bitmask), todos; migrations run in BeforeStart - Routes: POST /users (open), GET+POST /todos (RBAC), GET /users (RBAC) Tested-via: todo-api POC integration Reviewed-against: docs/adr/
This commit is contained in:
76
CHANGELOG.md
Normal file
76
CHANGELOG.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this module will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [0.9.0-poc] - 2026-03-18
|
||||
|
||||
### Added
|
||||
|
||||
This entry describes what the POC demonstrates, not a versioned library API surface.
|
||||
|
||||
**Application structure**
|
||||
|
||||
- N-layer architecture: `cmd/todo-api` → `internal/application` (wiring only) → `internal/handler` → `internal/service` → `internal/repository` → `internal/domain`; each layer depends on its inner neighbours via interfaces, with `application` as the only package that owns the full object graph
|
||||
- `application.Run()` — top-level entry point; wires all dependencies, registers routes inside `lc.BeforeStart`, and calls `lc.Run()` to start the launcher lifecycle
|
||||
- Local `logAdapter` struct in `application` — bridges `logz.Logger` (whose `With` returns `logz.Logger`) to `httpmw.Logger` (whose `With` must return `httpmw.Logger`), demonstrating the pattern for resolving return-type mismatches between micro-lib interfaces without modifying either library
|
||||
|
||||
**Domain layer** (`internal/domain`)
|
||||
|
||||
- `Todo` struct — `ID int64`, `Title string`, `Done bool`, `CreatedAt time.Time`, JSON-tagged
|
||||
- `User` struct — `ID string`, `Name string`, `Email string`, `CreatedAt time.Time`, JSON-tagged
|
||||
- `ResourceTodos = "todos"` constant — the resource key used in the `user_role` table
|
||||
- `PermReadTodo rbac.Permission = 0` and `PermWriteTodo rbac.Permission = 1` — bit positions for the todos resource permission mask
|
||||
|
||||
**Repository layer** (`internal/repository`)
|
||||
|
||||
- `TodoRepository` interface — `FindAll(ctx) ([]domain.Todo, error)`, `Create(ctx, domain.Todo) (domain.Todo, error)`
|
||||
- `UserRepository` interface — `FindAll`, `FindByID`, `Create`, `SetPermissions(ctx, userID, resource string, mask rbac.PermissionMask) error`
|
||||
- `NewTodoRepository(db sqlite.Client) TodoRepository` — SQLite-backed implementation; `FindAll` returns an empty slice (never nil) when no rows exist; `Create` uses `RETURNING` to retrieve the generated ID and timestamp
|
||||
- `NewUserRepository(db sqlite.Client) UserRepository` — SQLite-backed implementation; `FindByID` returns a `xerrors.ErrNotFound`-wrapped error on `sql.ErrNoRows`; `SetPermissions` uses `INSERT ... ON CONFLICT DO UPDATE` for upsert
|
||||
- `DBPermissionProvider` struct implementing `rbac.PermissionProvider` — resolves the integer bitmask from `user_role` for a given `(uid, resource)` pair; returns mask `0` (no permissions) for a missing row rather than an error
|
||||
- `NewPermissionProvider(db sqlite.Client) *DBPermissionProvider`
|
||||
|
||||
**Service layer** (`internal/service`)
|
||||
|
||||
- `CreateTodoRequest` struct — `Title string` with `validate:"required,min=1,max=255"` tag
|
||||
- `TodoService` interface — `FindAll(ctx) ([]domain.Todo, error)`, `Create(ctx, CreateTodoRequest) (domain.Todo, error)`
|
||||
- `NewTodoService(repo repository.TodoRepository) TodoService`
|
||||
- `CreateUserRequest` struct — `Name string`, `Email string`, `CanRead bool`, `CanWrite bool`, with validation tags; `CanRead`/`CanWrite` seed the permission bitmask for `ResourceTodos` at creation time
|
||||
- `UserService` interface — `FindAll(ctx) ([]domain.User, error)`, `Create(ctx, CreateUserRequest) (domain.User, error)`
|
||||
- `NewUserService(repo repository.UserRepository, idGen func() string) UserService` — `idGen` is injected (e.g. `uuid.NewString`) to mint user IDs
|
||||
|
||||
**Handler layer** (`internal/handler`)
|
||||
|
||||
- `TodoHandler` — `NewTodoHandler(svc service.TodoService, v valid.Validator) *TodoHandler`; `FindAll` delegates to `httputil.HandleNoBody[[]domain.Todo]`; `Create` delegates to `httputil.Handle[CreateTodoRequest, domain.Todo]`
|
||||
- `UserHandler` — `NewUserHandler(svc service.UserService, v valid.Validator) *UserHandler`; `FindAll` and `Create` follow the same `httputil` adapter pattern
|
||||
|
||||
**Middleware layer** (`internal/middleware`)
|
||||
|
||||
- `Auth(userRepo repository.UserRepository) func(http.Handler) http.Handler` — reads `X-User-ID` header, looks up the user, and stores an `rbac.Identity` in context via `rbac.SetInContext`; returns 401 JSON on missing header or unknown user ID; the output contract is identical to what `httpauth-firebase.EnrichmentMiddleware` produces, so swapping in real Firebase auth requires no changes to handlers or services
|
||||
- `Require(provider rbac.PermissionProvider, resource string, perms ...rbac.Permission) func(http.Handler) http.Handler` — reads `rbac.Identity` from context, resolves the permission mask, and enforces each required bit; returns 403 JSON on missing identity or insufficient permissions; returns 500 JSON if the provider errors
|
||||
|
||||
**Micro-lib stack composition demonstrated**
|
||||
|
||||
- `launcher` orchestrating `sqlite` and `httpserver` through `OnInit → OnStart → OnStop`
|
||||
- `httpserver` with `RequestID → Recover → RequestLogger` middleware via `httpmw`
|
||||
- `sqlite` (modernc pure-Go, no CGO) as the persistence layer with inline schema migrations run in `lc.BeforeStart`
|
||||
- `rbac` identity and permission types as the shared contract across middleware, repository, and domain
|
||||
- `valid` for request body validation in handlers
|
||||
- `httputil` handler adapters (`Handle`, `HandleNoBody`) for all route handlers
|
||||
- `xerrors` `ErrNotFound` for typed not-found errors in the repository layer
|
||||
- Route registration in `lc.BeforeStart` so migrations complete before any route is reachable and the port is not bound until routes are registered
|
||||
|
||||
**Routes**
|
||||
|
||||
- `POST /users` — open; no auth required (user bootstrapping)
|
||||
- `GET /todos` — requires `Auth` middleware and `PermReadTodo` permission
|
||||
- `POST /todos` — requires `Auth` middleware and `PermWriteTodo` permission
|
||||
- `GET /users` — requires `Auth` middleware and `PermReadTodo` permission on the todos resource
|
||||
|
||||
### Design Notes
|
||||
|
||||
- Header-based auth (`X-User-ID`) deliberately mirrors the `rbac.Identity` output contract of `httpauth-firebase`, proving that the RBAC contract is provider-agnostic; the POC can be upgraded to real Firebase auth by replacing the `Auth` middleware with no changes to any other layer
|
||||
- SQLite with no Docker dependency keeps the POC self-contained: a single `go run ./cmd/todo-api` is sufficient to run the full stack, with the database created as `todos.db` in the working directory on first run
|
||||
- Routes are registered in `lc.BeforeStart` rather than at construction time, making the lifecycle ordering explicit and auditable in a single closure: migrations run after `db.OnInit`, routes register after migrations, and `srv.OnStart` binds the port last
|
||||
Reference in New Issue
Block a user