From 556480a6d04692d659eb6dbb4e9ae55850511d21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Doma=C5=84ski?= Date: Tue, 7 Apr 2026 15:06:56 +0200 Subject: [PATCH] [architecture] finalize architecutre --- ARCHITECTURE.md | 129 ++++++++++++++++++++++++++++ fooder/context.py | 11 +-- fooder/db.py | 2 +- fooder/exc.py | 2 +- fooder/model/product.py | 2 +- fooder/model/user.py | 7 +- fooder/repository/base.py | 2 +- fooder/test/repository/test_base.py | 2 +- fooder/utils/jwt.py | 4 +- fooder/view/product.py | 2 +- 10 files changed, 143 insertions(+), 20 deletions(-) create mode 100644 ARCHITECTURE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..4359469 --- /dev/null +++ b/ARCHITECTURE.md @@ -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 diff --git a/fooder/context.py b/fooder/context.py index 779bbf1..124aa6d 100644 --- a/fooder/context.py +++ b/fooder/context.py @@ -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__( diff --git a/fooder/db.py b/fooder/db.py index f3c823c..c41accc 100644 --- a/fooder/db.py +++ b/fooder/db.py @@ -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, ) diff --git a/fooder/exc.py b/fooder/exc.py index 9752a9e..17f0fdb 100644 --- a/fooder/exc.py +++ b/fooder/exc.py @@ -16,7 +16,7 @@ class NotFound(ApiException): class Unauthorized(ApiException): HTTP_CODE = 401 - MESSAGE = "Unathorized" + MESSAGE = "Unauthorized" class InvalidValue(ApiException): diff --git a/fooder/model/product.py b/fooder/model/product.py index 754baf8..bdef2ef 100644 --- a/fooder/model/product.py +++ b/fooder/model/product.py @@ -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, diff --git a/fooder/model/user.py b/fooder/model/user.py index 04aebb2..3203518 100644 --- a/fooder/model/user.py +++ b/fooder/model/user.py @@ -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): diff --git a/fooder/repository/base.py b/fooder/repository/base.py index 11d4733..f7c15ba 100644 --- a/fooder/repository/base.py +++ b/fooder/repository/base.py @@ -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: diff --git a/fooder/test/repository/test_base.py b/fooder/test/repository/test_base.py index e47c243..210f600 100644 --- a/fooder/test/repository/test_base.py +++ b/fooder/test/repository/test_base.py @@ -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) diff --git a/fooder/utils/jwt.py b/fooder/utils/jwt.py index 119ef86..c9349ac 100644 --- a/fooder/utils/jwt.py +++ b/fooder/utils/jwt.py @@ -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) diff --git a/fooder/view/product.py b/fooder/view/product.py index e02d60e..7bbff7b 100644 --- a/fooder/view/product.py +++ b/fooder/view/product.py @@ -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