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:
117
internal/application/launcher.go
Normal file
117
internal/application/launcher.go
Normal 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
11
internal/domain/todo.go
Normal 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
25
internal/domain/user.go
Normal 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
|
||||
)
|
||||
31
internal/handler/todo_handler.go
Normal file
31
internal/handler/todo_handler.go
Normal 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)
|
||||
}
|
||||
31
internal/handler/user_handler.go
Normal file
31
internal/handler/user_handler.go
Normal 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)
|
||||
}
|
||||
37
internal/middleware/auth.go
Normal file
37
internal/middleware/auth.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
42
internal/middleware/rbac.go
Normal file
42
internal/middleware/rbac.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
38
internal/repository/permission_provider.go
Normal file
38
internal/repository/permission_provider.go
Normal 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
|
||||
}
|
||||
61
internal/repository/todo_repository.go
Normal file
61
internal/repository/todo_repository.go
Normal 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
|
||||
}
|
||||
91
internal/repository/user_repository.go
Normal file
91
internal/repository/user_repository.go
Normal 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
|
||||
}
|
||||
36
internal/service/todo_service.go
Normal file
36
internal/service/todo_service.go
Normal 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})
|
||||
}
|
||||
65
internal/service/user_service.go
Normal file
65
internal/service/user_service.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user