Project started 🍾

This commit is contained in:
2026-06-01 18:47:59 +03:00
commit 4bca90a50e
39 changed files with 2340 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Driving adapter — FastAPI routers, schemas, dependency wiring."""
+30
View File
@@ -0,0 +1,30 @@
"""Shared FastAPI dependencies — the composition root for request-scoped wiring.
Concrete adapters are bound to ports here so routers and services stay
decoupled from infrastructure. Repository/service providers are added in
later steps as the domain grows.
"""
from collections.abc import AsyncIterator
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.infrastructure.db import get_sessionmaker
async def get_session() -> AsyncIterator[AsyncSession]:
"""Request-scoped DB session. Commits on success, rolls back on exception."""
session = get_sessionmaker()()
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
SessionDep = Annotated[AsyncSession, Depends(get_session)]
+50
View File
@@ -0,0 +1,50 @@
"""Maps domain exceptions to HTTP responses. The only place that knows both."""
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse
from app.core.logging import get_logger
from app.domain.errors import (
AlreadyExistsError,
AuthenticationError,
ConflictError,
DependencyUnavailableError,
DomainError,
NotFoundError,
PermissionDeniedError,
ValidationError,
)
log = get_logger(__name__)
_STATUS_BY_ERROR: dict[type[DomainError], int] = {
NotFoundError: status.HTTP_404_NOT_FOUND,
AlreadyExistsError: status.HTTP_409_CONFLICT,
ConflictError: status.HTTP_409_CONFLICT,
ValidationError: status.HTTP_422_UNPROCESSABLE_CONTENT,
AuthenticationError: status.HTTP_401_UNAUTHORIZED,
PermissionDeniedError: status.HTTP_403_FORBIDDEN,
DependencyUnavailableError: status.HTTP_503_SERVICE_UNAVAILABLE,
}
def _error_body(code: str, message: str) -> dict[str, dict[str, str]]:
return {"error": {"code": code, "message": message}}
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(DomainError)
async def _handle_domain_error(_request: Request, exc: DomainError) -> JSONResponse:
http_status = _STATUS_BY_ERROR.get(type(exc), status.HTTP_400_BAD_REQUEST)
return JSONResponse(
status_code=http_status,
content=_error_body(exc.code, exc.message),
)
@app.exception_handler(Exception)
async def _handle_unexpected(_request: Request, exc: Exception) -> JSONResponse:
log.error("unhandled_exception", exc_info=exc)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content=_error_body("internal_error", "An unexpected error occurred."),
)
+83
View File
@@ -0,0 +1,83 @@
"""Health & readiness endpoints — used by compose healthchecks and the admin UI.
* ``/health`` — liveness: the process is up. Always 200 if serving.
* ``/health/ready`` — readiness: checks DB, Redis, and (optionally) ML.
Returns 503 if a *required* dependency is down. ML is optional — its absence
degrades, never fails, readiness (graceful degradation, see plan §6.5).
"""
import asyncio
from typing import Literal
from fastapi import APIRouter, Response, status
from pydantic import BaseModel
from sqlalchemy import text
from app.core.config import get_settings
from app.core.logging import get_logger
from app.infrastructure.cache import get_redis
from app.infrastructure.db import get_sessionmaker
log = get_logger(__name__)
router = APIRouter(tags=["health"])
CheckStatus = Literal["ok", "down", "skipped"]
# A readiness probe must answer fast and never hang — bound every dependency
# check. A check that exceeds this is reported "down".
CHECK_TIMEOUT_SECONDS = 2.0
class HealthResponse(BaseModel):
status: Literal["ok"] = "ok"
class ReadinessResponse(BaseModel):
status: Literal["ready", "degraded"]
checks: dict[str, CheckStatus]
@router.get("/health", response_model=HealthResponse)
async def health() -> HealthResponse:
return HealthResponse()
async def _check_db() -> CheckStatus:
try:
async with asyncio.timeout(CHECK_TIMEOUT_SECONDS):
async with get_sessionmaker()() as session:
await session.execute(text("SELECT 1"))
return "ok"
except Exception as exc:
log.warning("healthcheck_db_down", error=str(exc))
return "down"
async def _check_redis() -> CheckStatus:
try:
async with asyncio.timeout(CHECK_TIMEOUT_SECONDS):
await get_redis().ping()
return "ok"
except Exception as exc:
log.warning("healthcheck_redis_down", error=str(exc))
return "down"
async def _check_ml() -> CheckStatus:
# Optional dependency. A real client lands in step 12; absence is fine.
return "skipped" if get_settings().ml_service_url is None else "ok"
@router.get("/health/ready", response_model=ReadinessResponse)
async def readiness(response: Response) -> ReadinessResponse:
db, redis, ml = await asyncio.gather(_check_db(), _check_redis(), _check_ml())
checks: dict[str, CheckStatus] = {"database": db, "redis": redis, "ml": ml}
required_down = db == "down" or redis == "down"
if required_down:
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return ReadinessResponse(
status="degraded" if required_down else "ready",
checks=checks,
)
+52
View File
@@ -0,0 +1,52 @@
"""HTTP middleware: bind a correlation id and log each request."""
import time
import uuid
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from app.core.logging import correlation_id, get_logger
log = get_logger("http")
_HEADER = "x-correlation-id"
class CorrelationIdMiddleware:
"""Pure-ASGI middleware: reuse inbound ``X-Correlation-Id`` or mint one,
bind it for downstream logs, echo it back, and log request completion."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
headers = dict(scope["headers"])
inbound = headers.get(_HEADER.encode())
cid = inbound.decode() if inbound else uuid.uuid4().hex
token = correlation_id.set(cid)
started = time.perf_counter()
status_code = 0
async def send_wrapper(message: Message) -> None:
nonlocal status_code
if message["type"] == "http.response.start":
status_code = message["status"]
message.setdefault("headers", [])
message["headers"].append((_HEADER.encode(), cid.encode()))
await send(message)
try:
await self.app(scope, receive, send_wrapper)
finally:
log.info(
"request",
method=scope["method"],
path=scope["path"],
status=status_code,
duration_ms=round((time.perf_counter() - started) * 1000, 1),
)
correlation_id.reset(token)