Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et finances personnelles avec import de relevés bancaires. Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS + Vite + Tailwind + ECharts, déploiement Docker Compose. Modules auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework de connecteurs à deux voies (importeurs de fichiers + ingestion JSON) pour brancher de nouvelles sources sans toucher au noyau. Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16 (28 tables, SPA servie par nginx, wizard de premier démarrage). Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les documents de conception et de recherche dans docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
95 lines
2.7 KiB
Python
95 lines
2.7 KiB
Python
from typing import Any
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.encoders import jsonable_encoder
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.responses import JSONResponse
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
|
|
|
class AppError(Exception):
|
|
status_code: int = 400
|
|
code: str = "bad_request"
|
|
|
|
def __init__(self, message: str, *, details: dict[str, Any] | None = None):
|
|
self.message = message
|
|
self.details = details or {}
|
|
super().__init__(message)
|
|
|
|
|
|
class NotFoundError(AppError):
|
|
status_code, code = 404, "not_found"
|
|
|
|
|
|
class ConflictError(AppError):
|
|
status_code, code = 409, "conflict"
|
|
|
|
|
|
class UnauthorizedError(AppError):
|
|
status_code, code = 401, "unauthorized"
|
|
|
|
|
|
class ForbiddenError(AppError):
|
|
status_code, code = 403, "forbidden"
|
|
|
|
|
|
class DomainValidationError(AppError):
|
|
status_code, code = 422, "validation_error"
|
|
|
|
|
|
class PayloadTooLargeError(AppError):
|
|
status_code, code = 413, "payload_too_large"
|
|
|
|
|
|
_HTTP_CODES = {
|
|
400: "bad_request",
|
|
401: "unauthorized",
|
|
403: "forbidden",
|
|
404: "not_found",
|
|
405: "method_not_allowed",
|
|
409: "conflict",
|
|
413: "payload_too_large",
|
|
422: "validation_error",
|
|
500: "internal_error",
|
|
}
|
|
|
|
|
|
def _payload(code: str, message: str, details: Any = None) -> dict[str, Any]:
|
|
return {"error": {"code": code, "message": message, "details": details or {}}}
|
|
|
|
|
|
def register_error_handlers(app: FastAPI) -> None:
|
|
@app.exception_handler(AppError)
|
|
async def app_error_handler(_: Request, exc: AppError) -> JSONResponse:
|
|
return JSONResponse(
|
|
status_code=exc.status_code,
|
|
content=_payload(exc.code, exc.message, exc.details),
|
|
)
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
async def validation_handler(
|
|
_: Request, exc: RequestValidationError
|
|
) -> JSONResponse:
|
|
return JSONResponse(
|
|
status_code=422,
|
|
content=_payload(
|
|
"validation_error",
|
|
"Les données envoyées sont invalides.",
|
|
{"errors": jsonable_encoder(exc.errors())},
|
|
),
|
|
)
|
|
|
|
@app.exception_handler(StarletteHTTPException)
|
|
async def http_exception_handler(
|
|
_: Request, exc: StarletteHTTPException
|
|
) -> JSONResponse:
|
|
# Normalize framework-raised HTTP errors (404 route, 405…) to the
|
|
# single error shape of §8.3.
|
|
code = _HTTP_CODES.get(exc.status_code, "error")
|
|
message = (
|
|
exc.detail if isinstance(exc.detail, str) else "Une erreur est survenue."
|
|
)
|
|
return JSONResponse(
|
|
status_code=exc.status_code, content=_payload(code, message)
|
|
)
|