[architecture] finalize architecutre
This commit is contained in:
parent
446850ee12
commit
556480a6d0
10 changed files with 143 additions and 20 deletions
129
ARCHITECTURE.md
Normal file
129
ARCHITECTURE.md
Normal 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
|
||||||
|
|
@ -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__(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue