fooder-api/ARCHITECTURE.md

129 lines
4.7 KiB
Markdown

# Architecture
## Layers
```
view → controller → repository → domain
↑ ↑
model domain
(Pydantic) (SQLAlchemy)
```
### Domain (`fooder/domain/`)
Pure SQLAlchemy ORM models. No business logic, no session awareness, no CRUD methods. Just column definitions and computed properties derived from loaded relationships.
### Repository (`fooder/repository/`)
All database access lives here. `RepositoryBase` provides protected `_get` / `_list` / `_delete` / `create` / `update` primitives. Concrete repositories (`ProductRepository`, `UserRepository`) expose intention-revealing named methods:
```python
await ctx.repo.product.get_by_id(product_id)
await ctx.repo.user.get_by_username(username)
await ctx.repo.product.list(q=q, limit=10)
```
No SQLAlchemy expressions (`ColumnElement`) ever leave the repository layer. Callers never know about the ORM.
`Repository` aggregates all sub-repositories and owns transaction management via an async context manager:
```python
async with ctx.repo.transaction():
await SomeController.create(ctx, data)
await OtherController(ctx, obj).update(other_data)
# commits on exit, rolls back on any exception
```
### Controller (`fooder/controller/`)
Business logic for write operations only. Controllers:
- Hold a domain object (`ModelController[T].obj`) obtained before entering a transaction
- Mutate that object and call `repo.update()`
- Never commit — the view owns the transaction boundary
Classmethods (`create`) construct and persist a new domain object. Instance methods (`update`) mutate an existing one.
### View (`fooder/view/`)
Thin FastAPI route handlers. Responsible for:
- Accepting and validating HTTP input (via Pydantic models)
- Returning HTTP responses
- Owning the transaction boundary for write operations
Follows **CQS (Command/Query Separation)**:
| Operation | Path |
|-----------|------|
| Read (GET/list) | view → repository directly |
| Write (POST/PATCH/DELETE) | view → controller, wrapped in `transaction()` |
Reads have no side effects and need no transaction or controller indirection.
### Model (`fooder/model/`)
Pydantic schemas. Three roles:
- `*Model` — response serialization (`from_attributes=True` for ORM→Pydantic)
- `*CreateModel` — input validation for POST endpoints
- `*UpdateModel` — input validation for PATCH endpoints (all fields optional)
Models contain input-level logic (field constraints via `Field(ge=0, le=100)`, computed properties like `resolved_calories`) but no database awareness.
### Utils (`fooder/utils/`)
Stateless utilities with no layer dependencies:
- `jwt.py``Token`, `AccessToken`, `RefreshToken` encode/decode; `generate_token_pair()`
- `calories.py``calculate_calories()`
- `datetime.py``utc_now()`
- `password_helper.py` — bcrypt wrapper
---
## Context
`Context` is a per-request container injected into every view via FastAPI `Depends`:
```python
class Context:
repo: Repository # all database access
clock: Callable # injectable clock (testable)
user: User # authenticated user (raises Unauthorized if not set)
```
Two dependency variants:
- `ContextDependency` — unauthenticated endpoints (token create/refresh)
- `AuthContextDependency` — decodes Bearer token, loads user, raises `Unauthorized` on failure
---
## Exception Handling
All domain exceptions inherit `ApiException` and declare their HTTP code:
| Exception | HTTP |
|-----------|------|
| `NotFound` | 404 |
| `Unauthorized` | 401 |
| `InvalidValue` | 400 |
| `Conflict` | 409 |
FastAPI's exception handler in `app.py` catches all `ApiException` instances and returns the appropriate JSON response. No exception handling in views or controllers.
---
## Auth Flow
**Login:** `POST /api/token`
1. `UserController.session_start` verifies username + password → raises `Unauthorized` on failure
2. `generate_token_pair(user.id, now)` returns a stateless JWT access + refresh token pair
**Authenticated request:** Any endpoint using `AuthContextDependency`
1. Bearer token decoded via `AccessToken.decode()`
2. User loaded from DB by `user_id` from token payload
3. `Context.user` set — raises `Unauthorized` if missing
**Token refresh:** `POST /api/token/refresh`
1. `RefreshToken.decode(token)` validates signature and expiry
2. New token pair issued for `token.sub` — stateless rotation, no DB storage
---
## Testing
- All tests use a real SQLite in-memory database (no mocks)
- `db_session` fixture wraps each test in a transaction that is rolled back after the test — no cleanup needed
- `auth_client` fixture provides an `AsyncClient` with a valid Bearer token pre-set
- `product` fixture creates a product via `ProductController` within a transaction