diff --git a/copier.yaml b/copier.yaml index e4972b6..8671d02 100644 --- a/copier.yaml +++ b/copier.yaml @@ -148,6 +148,7 @@ _exclude: - "{{ 'app/utils/conversion.py' if project_type != 'api-monolith' else '' }}" - "{{ 'app/utils/healthcheck.py' if project_type != 'api-monolith' else '' }}" - "{{ 'app/utils/exceptions.py' if project_type not in ['api-monolith', 'api-microservice'] else '' }}" + - "{{ 'app/utils/exception_handlers.py' if project_type not in ['api-monolith', 'api-microservice'] else '' }}" - "{{ 'app/middlewares.py' if project_type == 'mcp-server' else '' }}" - "{{ 'scripts/healthchecks' if project_type not in ['api-monolith', 'api-microservice'] else '' }}" - "{{ 'scripts/start' if project_type == 'mcp-server' else '' }}" diff --git a/python-ai-kit/app/api.py b/python-ai-kit/app/api.py index f065a13..40fafd8 100644 --- a/python-ai-kit/app/api.py +++ b/python-ai-kit/app/api.py @@ -1,9 +1,10 @@ from fastapi import APIRouter from app.user.routes import user_crud_router_v1 +from app.utils.exceptions import VALIDATION_ERROR_RESPONSE from app.utils.healthcheck import healthcheck_router -head_router = APIRouter() +head_router = APIRouter(responses=VALIDATION_ERROR_RESPONSE) head_router.include_router(healthcheck_router, prefix="/health", tags=["health"]) head_router.include_router(user_crud_router_v1, prefix="/users", tags=["users"]) diff --git a/python-ai-kit/app/api/__init__.py.jinja b/python-ai-kit/app/api/__init__.py.jinja index a2ac547..d5f280a 100644 --- a/python-ai-kit/app/api/__init__.py.jinja +++ b/python-ai-kit/app/api/__init__.py.jinja @@ -5,9 +5,14 @@ from app.api.routes.v1.chat import router as chat_router_v1 {% endif %} {% if project_type == "api-microservice" %} from app.api.routes.v1.user import router as user_router_v1 +from app.utils.exceptions import VALIDATION_ERROR_RESPONSE {% endif %} +{% if project_type == "api-microservice" %} +head_router = APIRouter(responses=VALIDATION_ERROR_RESPONSE) +{% else %} head_router = APIRouter() +{% endif %} {% if project_type == "agent" %} head_router.include_router(chat_router_v1, prefix="/chat", tags=["chat"]) diff --git a/python-ai-kit/app/main.py.jinja b/python-ai-kit/app/main.py.jinja index bf5e360..d8abda4 100644 --- a/python-ai-kit/app/main.py.jinja +++ b/python-ai-kit/app/main.py.jinja @@ -1,9 +1,6 @@ from logging import INFO, basicConfig -{% if project_type in ["api-monolith", "api-microservice"] %} -from fastapi import FastAPI, Request -from fastapi.exceptions import RequestValidationError -{% elif project_type == "agent" %} +{% if project_type in ["api-monolith", "api-microservice", "agent"] %} from fastapi import FastAPI {% elif project_type == "mcp-server" %} from fastmcp import FastMCP @@ -38,7 +35,7 @@ from app.integrations.sqladmin import admin_authentication_backend from app.middlewares import add_cors_middleware {% endif %} {% if project_type in ["api-monolith", "api-microservice"] %} -from app.utils.exceptions import handle_exception +from app.utils.exception_handlers import add_exception_handlers {% endif %} basicConfig(level=INFO, format="[%(asctime)s - %(name)s] (%(levelname)s) %(message)s") @@ -60,6 +57,9 @@ init_tracing() {% endif %} add_cors_middleware(api) +{% if project_type in ["api-monolith", "api-microservice"] %} +add_exception_handlers(api) +{% endif %} @api.get("/") @@ -67,13 +67,6 @@ async def root() -> dict[str, str]: return {"message": "Server is running!"} {% endif %} -{% if project_type in ["api-monolith", "api-microservice"] %} - -@api.exception_handler(RequestValidationError) -async def request_validation_exception_handler(_: Request, exc: RequestValidationError) -> None: - raise handle_exception(exc, err_msg=exc.args[0][0]["msg"]) -{% endif %} - {% if project_type == "mcp-server" %} mcp = FastMCP(name=settings.mcp_server_name) diff --git a/python-ai-kit/app/repositories.py b/python-ai-kit/app/repositories.py index cd90351..cdd9317 100644 --- a/python-ai-kit/app/repositories.py +++ b/python-ai-kit/app/repositories.py @@ -112,9 +112,7 @@ async def get_filtered_scalar( if results: if len(results) == 1: return results[0] - raise MultipleResultsFoundError( - f"Found {len(results)} instances of {self.model} when only one was expected", - ) + raise MultipleResultsFoundError(self.model, len(results)) return None async def update( diff --git a/python-ai-kit/app/repositories/repositories.py b/python-ai-kit/app/repositories/repositories.py index cd90351..cdd9317 100644 --- a/python-ai-kit/app/repositories/repositories.py +++ b/python-ai-kit/app/repositories/repositories.py @@ -112,9 +112,7 @@ async def get_filtered_scalar( if results: if len(results) == 1: return results[0] - raise MultipleResultsFoundError( - f"Found {len(results)} instances of {self.model} when only one was expected", - ) + raise MultipleResultsFoundError(self.model, len(results)) return None async def update( diff --git a/python-ai-kit/app/user/exceptions.py b/python-ai-kit/app/user/exceptions.py new file mode 100644 index 0000000..91969a4 --- /dev/null +++ b/python-ai-kit/app/user/exceptions.py @@ -0,0 +1,12 @@ +from uuid import UUID + +from app.utils.exceptions import AppError + + +class UserInactiveError(AppError): + """User has had no activity in the last 30 days.""" + + status_code = 403 + + def __init__(self, user_id: UUID) -> None: + super().__init__(message=f"User {user_id} is inactive") diff --git a/python-ai-kit/app/user/routes/v1/user_crud.py b/python-ai-kit/app/user/routes/v1/user_crud.py index a564d4e..8cceb4f 100644 --- a/python-ai-kit/app/user/routes/v1/user_crud.py +++ b/python-ai-kit/app/user/routes/v1/user_crud.py @@ -4,9 +4,11 @@ from app.config import settings from app.database import AsyncDbSession from app.schemas import FilterParams -from app.user.schemas import UserCreate, UserRead, UserUpdate +from app.user.exceptions import UserInactiveError +from app.user.schemas import UserActivity, UserCreate, UserRead, UserUpdate from app.user.services import user_service from app.utils.api_utils import format_response +from app.utils.exceptions import AppErrorResponse from fastapi import APIRouter, Query, Request, status router = APIRouter() @@ -27,6 +29,19 @@ async def get_user(request: Request, user_id: UUID, session: AsyncDbSession): return await user_service.get(session, user_id) +@router.get( + "/{user_id}/activity", + response_model=UserActivity, + responses={ + UserInactiveError.status_code: {"model": AppErrorResponse, "description": UserInactiveError.__doc__} + }, +) +async def get_user_activity(request: Request, user_id: UUID, session: AsyncDbSession): + if not await user_service.is_user_active(session, user_id): + raise UserInactiveError(user_id) + return UserActivity(active=True) + + @router.get("/", response_model=list[UserRead]) @format_response(extra_rels=user_rels) async def get_users( diff --git a/python-ai-kit/app/user/schemas.py b/python-ai-kit/app/user/schemas.py index 4e9f6a1..4ac37aa 100644 --- a/python-ai-kit/app/user/schemas.py +++ b/python-ai-kit/app/user/schemas.py @@ -24,3 +24,7 @@ class UserUpdate(BaseModel): username: str | None = None email: EmailStr | None = None updated_at: datetime | None = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class UserActivity(BaseModel): + active: bool diff --git a/python-ai-kit/app/user/services/activity_mixin.py b/python-ai-kit/app/user/services/activity_mixin.py index 3ad220d..79f788a 100644 --- a/python-ai-kit/app/user/services/activity_mixin.py +++ b/python-ai-kit/app/user/services/activity_mixin.py @@ -4,14 +4,13 @@ from app.database import AsyncDbSession from app.user.repositories import ActivityRepository from app.utils.exceptions import handle_exceptions -from fastapi import Depends if TYPE_CHECKING: from app.user.services import UserService class ActivityMixin: - def __init__(self, activity_repository: ActivityRepository = Depends(), **kwargs): + def __init__(self, activity_repository: ActivityRepository, **kwargs): self.activity_repository = activity_repository super().__init__(**kwargs) diff --git a/python-ai-kit/app/user/services/services.py b/python-ai-kit/app/user/services/services.py index 5b84242..badc486 100644 --- a/python-ai-kit/app/user/services/services.py +++ b/python-ai-kit/app/user/services/services.py @@ -3,6 +3,7 @@ from app.repositories import CrudRepository from app.services import AppService from app.user.models import User +from app.user.repositories import ActivityRepository from app.user.schemas import UserCreate, UserUpdate from .activity_mixin import ActivityMixin @@ -25,7 +26,7 @@ def __init__( log: Logger, **kwargs, ) -> None: - super().__init__(crud_model, model, log, **kwargs) + super().__init__(crud_model, model, log, activity_repository=ActivityRepository(), **kwargs) user_service = UserService(UserRepository, User, logger) diff --git a/python-ai-kit/app/utils/exception_handlers.py b/python-ai-kit/app/utils/exception_handlers.py new file mode 100644 index 0000000..d91fb19 --- /dev/null +++ b/python-ai-kit/app/utils/exception_handlers.py @@ -0,0 +1,42 @@ +from logging import getLogger + +from fastapi import FastAPI, Request +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse + +from app.utils.exceptions import AppError, AppErrorResponse, UnprocessableEntityError + +logger = getLogger(__name__) + +SERVER_ERROR_MESSAGE = "Internal server error" + + +def to_error_response(exc: AppError) -> JSONResponse: + """Serialize an AppError to the one error body shape used across the API.""" + # 5xx detail may name tables or models, so it is logged (and picked up by Sentry) not returned. + if exc.status_code >= 500: + logger.error("Server error: %s", exc.message, exc_info=exc) + body = AppErrorResponse(message=SERVER_ERROR_MESSAGE, status_code=exc.status_code) + else: + body = AppErrorResponse(message=exc.message, status_code=exc.status_code, errors=exc.errors) + return JSONResponse( + status_code=exc.status_code, + content=jsonable_encoder(body, exclude_none=True), + headers=exc.headers, + ) + + +def add_exception_handlers(api: FastAPI) -> None: + """Register the handlers that give every API error the same JSON body.""" + + @api.exception_handler(AppError) + async def app_error_handler(_: Request, exc: AppError) -> JSONResponse: + return to_error_response(exc) + + @api.exception_handler(RequestValidationError) + async def request_validation_handler(_: Request, exc: RequestValidationError) -> JSONResponse: + # jsonable_encoder is required here: pydantic puts raw exception objects in `ctx`. + return to_error_response( + UnprocessableEntityError(message="Request validation failed", errors={"fields": exc.errors()}), + ) diff --git a/python-ai-kit/app/utils/exceptions.py b/python-ai-kit/app/utils/exceptions.py index 28344ca..d96f89d 100644 --- a/python-ai-kit/app/utils/exceptions.py +++ b/python-ai-kit/app/utils/exceptions.py @@ -1,16 +1,106 @@ import asyncio from collections.abc import Callable from functools import singledispatch, wraps -from typing import ParamSpec, TypeVar +from typing import Any, ParamSpec, TypeVar from uuid import UUID -from fastapi.exceptions import HTTPException, RequestValidationError from psycopg.errors import IntegrityError as PsycopgIntegrityError +from pydantic import BaseModel +from pydantic_core import ErrorDetails from sqlalchemy.exc import IntegrityError as SQLAIntegrityError -class MultipleResultsFoundError(Exception): - pass +class AppError(Exception): + """Base application error — presentation-ready: it carries its own HTTP status, optional + field-level `errors`, and response `headers`. + + Every error this API returns is an AppError: domain modules subclass it, and + `handle_exception` below converts infrastructure exceptions into it. A single handler + (see `app/utils/exception_handlers.py`) serializes all of them to `AppErrorResponse`, so + clients only ever parse one error shape. + + Subclasses with a fixed status only need to override the `status_code` class attribute. + """ + + status_code: int = 400 + + def __init__( + self, + message: str, + status_code: int | None = None, + errors: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> None: + super().__init__(message) + self.message = message + if status_code is not None: + self.status_code = status_code + self.errors = errors + self.headers = headers + + def __str__(self) -> str: + return self.message + + +class AppErrorResponse(BaseModel): + """JSON body for an `AppError` (also documents the error shape in OpenAPI). + + Headers live on the HTTP response, not in the body, so they are intentionally omitted here. + """ + + message: str + status_code: int + errors: dict[str, Any] | None = None + + +class ValidationErrors(BaseModel): + """`errors` payload of a 422 — pydantic's own report, passed through untouched.""" + + fields: list[ErrorDetails] + + +class ValidationErrorResponse(AppErrorResponse): + """422 body: the same envelope as any other error, with `errors` typed for OpenAPI.""" + + errors: ValidationErrors + + +class BadRequestError(AppError): + """Request cannot be fulfilled as sent.""" + + status_code = 400 + + +class NotFoundError(AppError): + """Requested resource does not exist.""" + + status_code = 404 + + +class UnprocessableEntityError(AppError): + """Request body or parameters failed validation.""" + + status_code = 422 + + +class MultipleResultsFoundError(AppError): + """More rows matched than the single result expected — a data integrity problem, not a + client mistake, hence 5xx. Its message names the model and row count, so it is logged + rather than returned; see `to_error_response`. + """ + + status_code = 500 + + def __init__(self, model: type, count: int) -> None: + super().__init__(message=f"Found {count} instances of {model.__name__} when only one was expected") + + +VALIDATION_ERROR_RESPONSE: dict[int | str, dict[str, Any]] = { + UnprocessableEntityError.status_code: { + "model": ValidationErrorResponse, + "description": UnprocessableEntityError.__doc__, + }, +} class ResourceNotFoundError(Exception): @@ -23,37 +113,28 @@ def __init__(self, entity_name: str, entity_id: int | UUID | None = None): @singledispatch -def handle_exception(exc: Exception, _: str) -> HTTPException: +def handle_exception(exc: Exception, _: str) -> AppError: raise exc @handle_exception.register -def _(exc: SQLAIntegrityError | PsycopgIntegrityError, entity: str) -> HTTPException: - return HTTPException( - status_code=400, - detail=f"{entity.capitalize()} entity already exists. Details: {exc.args[0]}", +def _(exc: SQLAIntegrityError | PsycopgIntegrityError, entity: str) -> AppError: + return BadRequestError( + message=f"{entity.capitalize()} entity already exists.", + errors={"detail": str(exc.args[0])}, ) @handle_exception.register -def _(exc: ResourceNotFoundError, _: str) -> HTTPException: - return HTTPException(status_code=404, detail=exc.detail) - - -@handle_exception.register -def _(exc: AttributeError, entity: str) -> HTTPException: - return HTTPException( - status_code=400, - detail=f"{entity.capitalize()} doesn't support attribute or method. Details: {exc.args[0]} ", - ) +def _(exc: ResourceNotFoundError, _: str) -> AppError: + return NotFoundError(message=exc.detail) @handle_exception.register -def _(exc: RequestValidationError, _: str) -> HTTPException: - err_args = exc.args[0][0] - return HTTPException( - status_code=400, - detail=f"{err_args['msg']} - {err_args['ctx']['error']}", +def _(exc: AttributeError, entity: str) -> AppError: + return BadRequestError( + message=f"{entity.capitalize()} doesn't support attribute or method.", + errors={"detail": str(exc.args[0])}, )