[architecture] finalize architecutre

This commit is contained in:
Piotr Domański 2026-04-07 15:06:56 +02:00
parent 446850ee12
commit 556480a6d0
10 changed files with 143 additions and 20 deletions

129
ARCHITECTURE.md Normal file
View file

@ -0,0 +1,129 @@
# 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

View file

@ -23,7 +23,7 @@ class Context:
) -> None:
self.repo = repo
self.clock = clock
self._user = None
self._user: User | None = None
def set_user(self, user: User) -> None:
self._user = user
@ -37,14 +37,9 @@ class Context:
class ContextDependency:
"""
Context dependecy
Context dependency
"""
def __init__(
self,
) -> None:
pass
def __call__(
self,
session: AsyncSession = Depends(get_db_session),
@ -54,7 +49,7 @@ class ContextDependency:
class AuthContextDependency:
"""
Context dependecy for authorized endpionts
Context dependency for authorized endpoints
"""
async def __call__(

View file

@ -23,7 +23,7 @@ class DatabaseSessionManager:
),
)
self._sessionmaker = async_sessionmaker(
autocommit=False, autoflush=False, future=True, bind=self._engine,
autocommit=False, autoflush=False, bind=self._engine,
expire_on_commit=False,
)

View file

@ -16,7 +16,7 @@ class NotFound(ApiException):
class Unauthorized(ApiException):
HTTP_CODE = 401
MESSAGE = "Unathorized"
MESSAGE = "Unauthorized"
class InvalidValue(ApiException):

View file

@ -23,7 +23,7 @@ class ProductCreateModel(ProductModelBase):
@property
def resolved_calories(self) -> float:
return self.calories or calculate_calories(
return self.calories if self.calories is not None else calculate_calories(
protein=self.protein,
carb=self.carb,
fat=self.fat,

View file

@ -1,11 +1,10 @@
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
class User(BaseModel):
username: str
model_config = ConfigDict(from_attributes=True)
class Config:
from_attributes = True
username: str
class CreateUserPayload(BaseModel):

View file

@ -63,7 +63,7 @@ class RepositoryBase(Generic[T]):
raise Conflict()
return obj
async def delete(self, *expressions: ColumnElement):
async def _delete(self, *expressions: ColumnElement):
stmt = sa_delete(self.model)
if expressions:

View file

@ -62,6 +62,6 @@ async def test_list_returns_empty_when_no_match(test_repo, test_model_factory):
async def test_delete_removes_record(test_repo, test_model):
model = await test_repo.create(test_model)
await test_repo.delete(TestModel.id == model.id)
await test_repo._delete(TestModel.id == model.id)
with pytest.raises(NotFound):
await test_repo._get(TestModel.id == model.id)

View file

@ -1,7 +1,7 @@
from jose import jwt, JOSEError
from pydantic import BaseModel
from datetime import timedelta, datetime
from typing import ClassVar, Literal
from typing import ClassVar
import logging
from fooder.settings import settings
from fooder.exc import Unauthorized
@ -25,7 +25,7 @@ class Token(BaseModel):
jwt_token, cls.secret_key, algorithms=[settings.ALGORITHM]
)
except JOSEError as e:
logging.error(e)
logging.warning(e)
raise Unauthorized()
return cls(**data)

View file

@ -23,8 +23,8 @@ async def update_product(
data: ProductUpdateModel,
ctx: Context = Depends(AuthContextDependency()),
):
obj = await ctx.repo.product.get_by_id(product_id)
async with ctx.repo.transaction():
obj = await ctx.repo.product.get_by_id(product_id)
await ProductController(ctx, obj).update(data)
return obj