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:
2026-03-19 13:55:08 +00:00
commit 3fcba82448
23 changed files with 1143 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
package application
import (
"context"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"code.nochebuena.dev/go/httpserver"
"code.nochebuena.dev/go/httpmw"
"code.nochebuena.dev/go/launcher"
"code.nochebuena.dev/go/logz"
"code.nochebuena.dev/go/sqlite"
"code.nochebuena.dev/go/valid"
"code.nochebuena.dev/go/todo-api/internal/domain"
"code.nochebuena.dev/go/todo-api/internal/handler"
appMW "code.nochebuena.dev/go/todo-api/internal/middleware"
"code.nochebuena.dev/go/todo-api/internal/repository"
"code.nochebuena.dev/go/todo-api/internal/service"
)
const migrate = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);`
// logAdapter bridges logz.Logger (With returns logz.Logger) to httpmw.Logger
// (With must return httpmw.Logger). Defined locally — no extra package needed.
type logAdapter struct{ l logz.Logger }
func (a *logAdapter) Info(msg string, args ...any) { a.l.Info(msg, args...) }
func (a *logAdapter) Error(msg string, err error, args ...any) { a.l.Error(msg, err, args...) }
func (a *logAdapter) With(args ...any) httpmw.Logger { return &logAdapter{a.l.With(args...)} }
// Run wires all dependencies, starts the launcher, and blocks until shutdown.
func Run() error {
logger := logz.New(logz.Options{JSON: false})
// --- Infrastructure ---
db := sqlite.New(logger, sqlite.Config{Path: "todos.db"})
// --- Repositories ---
todoRepo := repository.NewTodoRepository(db)
userRepo := repository.NewUserRepository(db)
permProvider := repository.NewPermissionProvider(db)
// --- Services ---
v := valid.New()
todoSvc := service.NewTodoService(todoRepo)
userSvc := service.NewUserService(userRepo, uuid.NewString)
// --- Handlers ---
todoH := handler.NewTodoHandler(todoSvc, v)
userH := handler.NewUserHandler(userSvc, v)
// --- HTTP server ---
srv := httpserver.New(logger, httpserver.Config{Port: 3000},
httpserver.WithMiddleware(
httpmw.RequestID(uuid.NewString),
httpmw.Recover(),
httpmw.RequestLogger(&logAdapter{logger}),
),
)
// --- Launcher ---
lc := launcher.New(logger)
lc.Append(db, srv)
lc.BeforeStart(func() error {
// Migrations run after db.OnInit, before srv.OnStart.
ctx := context.Background()
if _, err := db.GetExecutor(ctx).ExecContext(ctx, migrate); err != nil {
return err
}
// POST /users — open; no auth required (bootstrapping)
srv.Post("/users", userH.Create)
// All other routes require a valid X-User-ID (Auth middleware populates rbac.Identity).
srv.Group(func(r chi.Router) {
r.Use(appMW.Auth(userRepo))
// Todos — permission-gated
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)
// Users — read-only list, also requires read permission on todos
// (keeps the surface small; demonstrates RBAC on a second endpoint)
r.With(appMW.Require(permProvider, domain.ResourceTodos, domain.PermReadTodo)).
Get("/users", userH.FindAll)
})
return nil
})
return lc.Run()
}

11
internal/domain/todo.go Normal file
View File

@@ -0,0 +1,11 @@
package domain
import "time"
// Todo is the core domain entity.
type Todo struct {
ID int64 `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
CreatedAt time.Time `json:"created_at"`
}

25
internal/domain/user.go Normal file
View File

@@ -0,0 +1,25 @@
package domain
import (
"time"
"code.nochebuena.dev/go/rbac"
)
// User is the core user entity.
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}
// ResourceTodos is the resource key stored in the user_role table for todo permissions.
const ResourceTodos = "todos"
// Permission bits for the todos resource.
// Each constant is a bit position (0-based); the stored value is 1 << bit.
const (
PermReadTodo rbac.Permission = 0 // bit 0 → mask value 1
PermWriteTodo rbac.Permission = 1 // bit 1 → mask value 2
)

View File

@@ -0,0 +1,31 @@
package handler
import (
"net/http"
"code.nochebuena.dev/go/httputil"
"code.nochebuena.dev/go/valid"
"code.nochebuena.dev/go/todo-api/internal/domain"
"code.nochebuena.dev/go/todo-api/internal/service"
)
// TodoHandler wires HTTP requests to the TodoService.
type TodoHandler struct {
svc service.TodoService
v valid.Validator
}
// NewTodoHandler returns a TodoHandler.
func NewTodoHandler(svc service.TodoService, v valid.Validator) *TodoHandler {
return &TodoHandler{svc: svc, v: v}
}
// FindAll handles GET /todos — returns all todos as JSON.
func (h *TodoHandler) FindAll(w http.ResponseWriter, r *http.Request) {
httputil.HandleNoBody[[]domain.Todo](h.svc.FindAll).ServeHTTP(w, r)
}
// Create handles POST /todos — creates a new todo from the JSON body.
func (h *TodoHandler) Create(w http.ResponseWriter, r *http.Request) {
httputil.Handle[service.CreateTodoRequest, domain.Todo](h.v, h.svc.Create).ServeHTTP(w, r)
}

View File

@@ -0,0 +1,31 @@
package handler
import (
"net/http"
"code.nochebuena.dev/go/httputil"
"code.nochebuena.dev/go/valid"
"code.nochebuena.dev/go/todo-api/internal/domain"
"code.nochebuena.dev/go/todo-api/internal/service"
)
// UserHandler wires HTTP requests to UserService.
type UserHandler struct {
svc service.UserService
v valid.Validator
}
// NewUserHandler returns a UserHandler.
func NewUserHandler(svc service.UserService, v valid.Validator) *UserHandler {
return &UserHandler{svc: svc, v: v}
}
// FindAll handles GET /users — returns all users as JSON.
func (h *UserHandler) FindAll(w http.ResponseWriter, r *http.Request) {
httputil.HandleNoBody[[]domain.User](h.svc.FindAll).ServeHTTP(w, r)
}
// Create handles POST /users — creates a new user with optional permission bits.
func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
httputil.Handle[service.CreateUserRequest, domain.User](h.v, h.svc.Create).ServeHTTP(w, r)
}

View File

@@ -0,0 +1,37 @@
package middleware
import (
"net/http"
"code.nochebuena.dev/go/rbac"
"code.nochebuena.dev/go/todo-api/internal/repository"
)
// Auth reads the X-User-ID request header, looks up the user in the database,
// and stores an rbac.Identity in the context.
//
// Returns 401 if the header is absent or the user ID is not found — the two
// cases are intentionally indistinguishable to callers.
func Auth(userRepo repository.UserRepository) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid := r.Header.Get("X-User-ID")
if uid == "" {
http.Error(w, `{"code":"UNAUTHENTICATED","message":"missing X-User-ID header"}`,
http.StatusUnauthorized)
return
}
user, err := userRepo.FindByID(r.Context(), uid)
if err != nil {
http.Error(w, `{"code":"UNAUTHENTICATED","message":"user not found"}`,
http.StatusUnauthorized)
return
}
identity := rbac.NewIdentity(user.ID, user.Name, user.Email)
ctx := rbac.SetInContext(r.Context(), identity)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

View File

@@ -0,0 +1,42 @@
package middleware
import (
"net/http"
"code.nochebuena.dev/go/rbac"
)
// Require guards a handler: it reads the rbac.Identity from context (set by Auth),
// resolves the permission mask from the provider, and rejects the request with 403
// if any of the required permission bits are not set.
//
// Must be chained after Auth so that an identity is guaranteed in context.
func Require(provider rbac.PermissionProvider, resource string, perms ...rbac.Permission) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
identity, ok := rbac.FromContext(r.Context())
if !ok {
http.Error(w, `{"code":"PERMISSION_DENIED","message":"no identity in context"}`,
http.StatusForbidden)
return
}
mask, err := provider.ResolveMask(r.Context(), identity.UID, resource)
if err != nil {
http.Error(w, `{"code":"INTERNAL","message":"could not resolve permissions"}`,
http.StatusInternalServerError)
return
}
for _, p := range perms {
if !mask.Has(p) {
http.Error(w, `{"code":"PERMISSION_DENIED","message":"insufficient permissions"}`,
http.StatusForbidden)
return
}
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,38 @@
package repository
import (
"context"
"database/sql"
"errors"
"code.nochebuena.dev/go/rbac"
"code.nochebuena.dev/go/sqlite"
)
// DBPermissionProvider implements rbac.PermissionProvider by reading the
// user_role table. A missing row is treated as "no permissions" (mask = 0).
type DBPermissionProvider struct {
db sqlite.Client
}
// NewPermissionProvider returns a DBPermissionProvider backed by the given client.
func NewPermissionProvider(db sqlite.Client) *DBPermissionProvider {
return &DBPermissionProvider{db: db}
}
// ResolveMask returns the permission bit-mask for uid on resource.
// Returns 0 (no permissions) if no row exists for the user/resource pair.
func (p *DBPermissionProvider) ResolveMask(ctx context.Context, uid, resource string) (rbac.PermissionMask, error) {
row := p.db.GetExecutor(ctx).QueryRowContext(ctx,
`SELECT permissions FROM user_role WHERE user_id = ? AND resource = ?`,
uid, resource,
)
var bits int64
if err := row.Scan(&bits); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
return 0, err
}
return rbac.PermissionMask(bits), nil
}

View File

@@ -0,0 +1,61 @@
package repository
import (
"context"
"code.nochebuena.dev/go/sqlite"
"code.nochebuena.dev/go/todo-api/internal/domain"
)
// TodoRepository defines persistence operations for todos.
type TodoRepository interface {
FindAll(ctx context.Context) ([]domain.Todo, error)
Create(ctx context.Context, todo domain.Todo) (domain.Todo, error)
}
type todoRepository struct {
db sqlite.Client
}
// NewTodoRepository returns a SQLite-backed TodoRepository.
func NewTodoRepository(db sqlite.Client) TodoRepository {
return &todoRepository{db: db}
}
func (r *todoRepository) FindAll(ctx context.Context) ([]domain.Todo, error) {
rows, err := r.db.GetExecutor(ctx).QueryContext(ctx,
`SELECT id, title, done, created_at FROM todos ORDER BY created_at DESC`,
)
if err != nil {
return nil, r.db.HandleError(err)
}
defer rows.Close()
var todos []domain.Todo
for rows.Next() {
var t domain.Todo
if err := rows.Scan(&t.ID, &t.Title, &t.Done, &t.CreatedAt); err != nil {
return nil, err
}
todos = append(todos, t)
}
if err := rows.Err(); err != nil {
return nil, err
}
if todos == nil {
todos = []domain.Todo{} // always return a slice, never nil
}
return todos, nil
}
func (r *todoRepository) Create(ctx context.Context, todo domain.Todo) (domain.Todo, error) {
row := r.db.GetExecutor(ctx).QueryRowContext(ctx,
`INSERT INTO todos (title, done) VALUES (?, ?) RETURNING id, title, done, created_at`,
todo.Title, todo.Done,
)
var t domain.Todo
if err := row.Scan(&t.ID, &t.Title, &t.Done, &t.CreatedAt); err != nil {
return domain.Todo{}, r.db.HandleError(err)
}
return t, nil
}

View File

@@ -0,0 +1,91 @@
package repository
import (
"context"
"database/sql"
"errors"
"code.nochebuena.dev/go/rbac"
"code.nochebuena.dev/go/sqlite"
"code.nochebuena.dev/go/xerrors"
"code.nochebuena.dev/go/todo-api/internal/domain"
)
// UserRepository defines persistence operations for users and their role bits.
type UserRepository interface {
FindAll(ctx context.Context) ([]domain.User, error)
FindByID(ctx context.Context, id string) (domain.User, error)
Create(ctx context.Context, user domain.User) (domain.User, error)
SetPermissions(ctx context.Context, userID, resource string, mask rbac.PermissionMask) error
}
type userRepository struct {
db sqlite.Client
}
// NewUserRepository returns a SQLite-backed UserRepository.
func NewUserRepository(db sqlite.Client) UserRepository {
return &userRepository{db: db}
}
func (r *userRepository) FindAll(ctx context.Context) ([]domain.User, error) {
rows, err := r.db.GetExecutor(ctx).QueryContext(ctx,
`SELECT id, name, email, created_at FROM users ORDER BY created_at DESC`,
)
if err != nil {
return nil, r.db.HandleError(err)
}
defer rows.Close()
var users []domain.User
for rows.Next() {
var u domain.User
if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt); err != nil {
return nil, err
}
users = append(users, u)
}
if err := rows.Err(); err != nil {
return nil, err
}
if users == nil {
users = []domain.User{}
}
return users, nil
}
func (r *userRepository) FindByID(ctx context.Context, id string) (domain.User, error) {
row := r.db.GetExecutor(ctx).QueryRowContext(ctx,
`SELECT id, name, email, created_at FROM users WHERE id = ?`, id,
)
var u domain.User
if err := row.Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.User{}, xerrors.New(xerrors.ErrNotFound, "user not found")
}
return domain.User{}, r.db.HandleError(err)
}
return u, nil
}
func (r *userRepository) Create(ctx context.Context, user domain.User) (domain.User, error) {
row := r.db.GetExecutor(ctx).QueryRowContext(ctx,
`INSERT INTO users (id, name, email) VALUES (?, ?, ?)
RETURNING id, name, email, created_at`,
user.ID, user.Name, user.Email,
)
var u domain.User
if err := row.Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt); err != nil {
return domain.User{}, r.db.HandleError(err)
}
return u, nil
}
func (r *userRepository) SetPermissions(ctx context.Context, userID, resource string, mask rbac.PermissionMask) error {
_, err := r.db.GetExecutor(ctx).ExecContext(ctx,
`INSERT INTO user_role (user_id, resource, permissions) VALUES (?, ?, ?)
ON CONFLICT (user_id, resource) DO UPDATE SET permissions = excluded.permissions`,
userID, resource, int64(mask),
)
return err
}

View File

@@ -0,0 +1,36 @@
package service
import (
"context"
"code.nochebuena.dev/go/todo-api/internal/domain"
"code.nochebuena.dev/go/todo-api/internal/repository"
)
// CreateTodoRequest is the input for creating a new todo.
type CreateTodoRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
}
// TodoService handles todo business logic.
type TodoService interface {
FindAll(ctx context.Context) ([]domain.Todo, error)
Create(ctx context.Context, req CreateTodoRequest) (domain.Todo, error)
}
type todoService struct {
repo repository.TodoRepository
}
// NewTodoService returns a TodoService backed by the given repository.
func NewTodoService(repo repository.TodoRepository) TodoService {
return &todoService{repo: repo}
}
func (s *todoService) FindAll(ctx context.Context) ([]domain.Todo, error) {
return s.repo.FindAll(ctx)
}
func (s *todoService) Create(ctx context.Context, req CreateTodoRequest) (domain.Todo, error) {
return s.repo.Create(ctx, domain.Todo{Title: req.Title})
}

View File

@@ -0,0 +1,65 @@
package service
import (
"context"
"code.nochebuena.dev/go/rbac"
"code.nochebuena.dev/go/todo-api/internal/domain"
"code.nochebuena.dev/go/todo-api/internal/repository"
)
// CreateUserRequest is the input for creating a new user.
// CanRead / CanWrite seed the permission bits for the todos resource immediately.
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=1,max=100"`
Email string `json:"email" validate:"required,email"`
CanRead bool `json:"can_read"`
CanWrite bool `json:"can_write"`
}
// UserService handles user business logic.
type UserService interface {
FindAll(ctx context.Context) ([]domain.User, error)
Create(ctx context.Context, req CreateUserRequest) (domain.User, error)
}
type userService struct {
repo repository.UserRepository
idGen func() string
}
// NewUserService returns a UserService. idGen is called to mint new user IDs (e.g. uuid.NewString).
func NewUserService(repo repository.UserRepository, idGen func() string) UserService {
return &userService{repo: repo, idGen: idGen}
}
func (s *userService) FindAll(ctx context.Context) ([]domain.User, error) {
return s.repo.FindAll(ctx)
}
func (s *userService) Create(ctx context.Context, req CreateUserRequest) (domain.User, error) {
user, err := s.repo.Create(ctx, domain.User{
ID: s.idGen(),
Name: req.Name,
Email: req.Email,
})
if err != nil {
return domain.User{}, err
}
// Build and persist permission mask from the request flags.
var mask rbac.PermissionMask
if req.CanRead {
mask = mask.Grant(domain.PermReadTodo)
}
if req.CanWrite {
mask = mask.Grant(domain.PermWriteTodo)
}
if mask != 0 {
if err := s.repo.SetPermissions(ctx, user.ID, domain.ResourceTodos, mask); err != nil {
return domain.User{}, err
}
}
return user, nil
}