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

View file

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

View file

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

View file

@ -23,7 +23,7 @@ class ProductCreateModel(ProductModelBase):
@property @property
def resolved_calories(self) -> float: 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, protein=self.protein,
carb=self.carb, carb=self.carb,
fat=self.fat, fat=self.fat,

View file

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

View file

@ -63,7 +63,7 @@ class RepositoryBase(Generic[T]):
raise Conflict() raise Conflict()
return obj return obj
async def delete(self, *expressions: ColumnElement): async def _delete(self, *expressions: ColumnElement):
stmt = sa_delete(self.model) stmt = sa_delete(self.model)
if expressions: 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): async def test_delete_removes_record(test_repo, test_model):
model = await test_repo.create(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): with pytest.raises(NotFound):
await test_repo._get(TestModel.id == model.id) await test_repo._get(TestModel.id == model.id)

View file

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

View file

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