Initial commit

This commit is contained in:
2026-01-25 16:11:44 +01:00
commit 762ed35341
29 changed files with 5072 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
from .exceptions import (
ApiException,
ConflictError,
ForbiddenError,
NotFoundError,
UnauthorizedError,
generate_error_responses,
)
from .handler import init_exceptions_handlers
__all__ = [
"init_exceptions_handlers",
"generate_error_responses",
"ApiException",
"ConflictError",
"ForbiddenError",
"NotFoundError",
"UnauthorizedError",
]

View File

@@ -0,0 +1,166 @@
"""Custom exceptions with standardized API error responses."""
from typing import Any, ClassVar
from ..schemas import ApiError, ErrorResponse, ResponseStatus
class ApiException(Exception):
"""Base exception for API errors with structured response.
Subclass this to create custom API exceptions with consistent error format.
The exception handler will use api_error to generate the response.
Example:
class CustomError(ApiException):
api_error = ApiError(
code=400,
msg="Bad Request",
desc="The request was invalid.",
err_code="CUSTOM-400",
)
"""
api_error: ClassVar[ApiError]
def __init__(self, detail: str | None = None):
"""Initialize the exception.
Args:
detail: Optional override for the error message
"""
super().__init__(detail or self.api_error.msg)
class UnauthorizedError(ApiException):
"""HTTP 401 - User is not authenticated."""
api_error = ApiError(
code=401,
msg="Unauthorized",
desc="Authentication credentials were missing or invalid.",
err_code="AUTH-401",
)
class ForbiddenError(ApiException):
"""HTTP 403 - User lacks required permissions."""
api_error = ApiError(
code=403,
msg="Forbidden",
desc="You do not have permission to access this resource.",
err_code="AUTH-403",
)
class NotFoundError(ApiException):
"""HTTP 404 - Resource not found."""
api_error = ApiError(
code=404,
msg="Not Found",
desc="The requested resource was not found.",
err_code="RES-404",
)
class ConflictError(ApiException):
"""HTTP 409 - Resource conflict."""
api_error = ApiError(
code=409,
msg="Conflict",
desc="The request conflicts with the current state of the resource.",
err_code="RES-409",
)
class InsufficientRolesError(ForbiddenError):
"""User does not have the required roles."""
api_error = ApiError(
code=403,
msg="Insufficient Roles",
desc="You do not have the required roles to access this resource.",
err_code="RBAC-403",
)
def __init__(self, required_roles: list[str], user_roles: set[str] | None = None):
self.required_roles = required_roles
self.user_roles = user_roles
desc = f"Required roles: {', '.join(required_roles)}"
if user_roles is not None:
desc += f". User has: {', '.join(user_roles) if user_roles else 'no roles'}"
super().__init__(desc)
class UserNotFoundError(NotFoundError):
"""User was not found."""
api_error = ApiError(
code=404,
msg="User Not Found",
desc="The requested user was not found.",
err_code="USER-404",
)
class RoleNotFoundError(NotFoundError):
"""Role was not found."""
api_error = ApiError(
code=404,
msg="Role Not Found",
desc="The requested role was not found.",
err_code="ROLE-404",
)
def generate_error_responses(
*errors: type[ApiException],
) -> dict[int | str, dict[str, Any]]:
"""Generate OpenAPI response documentation for exceptions.
Use this to document possible error responses for an endpoint.
Args:
*errors: Exception classes that inherit from ApiException
Returns:
Dict suitable for FastAPI's responses parameter
Example:
from fastapi_toolsets.exceptions import generate_error_responses, UnauthorizedError, ForbiddenError
@app.get(
"/admin",
responses=generate_error_responses(UnauthorizedError, ForbiddenError)
)
async def admin_endpoint():
...
"""
responses: dict[int | str, dict[str, Any]] = {}
for error in errors:
api_error = error.api_error
responses[api_error.code] = {
"model": ErrorResponse,
"description": api_error.msg,
"content": {
"application/json": {
"example": {
"data": None,
"status": ResponseStatus.FAIL.value,
"message": api_error.msg,
"description": api_error.desc,
"error_code": api_error.err_code,
}
}
},
}
return responses

View File

@@ -0,0 +1,169 @@
"""Exception handlers for FastAPI applications."""
from typing import Any
from fastapi import FastAPI, Request, Response, status
from fastapi.exceptions import RequestValidationError, ResponseValidationError
from fastapi.openapi.utils import get_openapi
from fastapi.responses import JSONResponse
from ..schemas import ResponseStatus
from .exceptions import ApiException
def init_exceptions_handlers(app: FastAPI) -> FastAPI:
_register_exception_handlers(app)
app.openapi = lambda: _custom_openapi(app) # type: ignore[method-assign]
return app
def _register_exception_handlers(app: FastAPI) -> None:
"""Register all exception handlers on a FastAPI application.
Args:
app: FastAPI application instance
Example:
from fastapi import FastAPI
from fastapi_toolsets.exceptions import init_exceptions_handlers
app = FastAPI()
init_exceptions_handlers(app)
"""
@app.exception_handler(ApiException)
async def api_exception_handler(request: Request, exc: ApiException) -> Response:
"""Handle custom API exceptions with structured response."""
api_error = exc.api_error
return JSONResponse(
status_code=api_error.code,
content={
"data": None,
"status": ResponseStatus.FAIL.value,
"message": api_error.msg,
"description": api_error.desc,
"error_code": api_error.err_code,
},
)
@app.exception_handler(RequestValidationError)
async def request_validation_handler(
request: Request, exc: RequestValidationError
) -> Response:
"""Handle Pydantic request validation errors (422)."""
return _format_validation_error(exc)
@app.exception_handler(ResponseValidationError)
async def response_validation_handler(
request: Request, exc: ResponseValidationError
) -> Response:
"""Handle Pydantic response validation errors (422)."""
return _format_validation_error(exc)
@app.exception_handler(Exception)
async def generic_exception_handler(request: Request, exc: Exception) -> Response:
"""Handle all unhandled exceptions with a generic 500 response."""
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"data": None,
"status": ResponseStatus.FAIL.value,
"message": "Internal Server Error",
"description": "An unexpected error occurred. Please try again later.",
"error_code": "SERVER-500",
},
)
def _format_validation_error(
exc: RequestValidationError | ResponseValidationError,
) -> JSONResponse:
"""Format validation errors into a structured response."""
errors = exc.errors()
formatted_errors = []
for error in errors:
field_path = ".".join(
str(loc)
for loc in error["loc"]
if loc not in ("body", "query", "path", "header", "cookie")
)
formatted_errors.append(
{
"field": field_path or "root",
"message": error.get("msg", ""),
"type": error.get("type", ""),
}
)
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"data": {"errors": formatted_errors},
"status": ResponseStatus.FAIL.value,
"message": "Validation Error",
"description": f"{len(formatted_errors)} validation error(s) detected",
"error_code": "VAL-422",
},
)
def _custom_openapi(app: FastAPI) -> dict[str, Any]:
"""Generate custom OpenAPI schema with standardized error format.
Replaces default 422 validation error responses with the custom format.
Args:
app: FastAPI application instance
Returns:
OpenAPI schema dict
Example:
from fastapi import FastAPI
from fastapi_toolsets.exceptions import init_exceptions_handlers
app = FastAPI()
init_exceptions_handlers(app) # Automatically sets custom OpenAPI
"""
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title=app.title,
version=app.version,
openapi_version=app.openapi_version,
description=app.description,
routes=app.routes,
)
for path_data in openapi_schema.get("paths", {}).values():
for operation in path_data.values():
if isinstance(operation, dict) and "responses" in operation:
if "422" in operation["responses"]:
operation["responses"]["422"] = {
"description": "Validation Error",
"content": {
"application/json": {
"example": {
"data": {
"errors": [
{
"field": "field_name",
"message": "value is not valid",
"type": "value_error",
}
]
},
"status": ResponseStatus.FAIL.value,
"message": "Validation Error",
"description": "1 validation error(s) detected",
"error_code": "VAL-422",
}
}
},
}
app.openapi_schema = openapi_schema
return app.openapi_schema