diff --git a/src/fastapi_toolsets/exceptions/exceptions.py b/src/fastapi_toolsets/exceptions/exceptions.py index c00d0dc..7b40cb9 100644 --- a/src/fastapi_toolsets/exceptions/exceptions.py +++ b/src/fastapi_toolsets/exceptions/exceptions.py @@ -183,7 +183,7 @@ def generate_error_responses( "content": { "application/json": { "example": { - "data": None, + "data": api_error.data, "status": ResponseStatus.FAIL.value, "message": api_error.msg, "description": api_error.desc, diff --git a/src/fastapi_toolsets/exceptions/handler.py b/src/fastapi_toolsets/exceptions/handler.py index 55c77dd..6ae27b8 100644 --- a/src/fastapi_toolsets/exceptions/handler.py +++ b/src/fastapi_toolsets/exceptions/handler.py @@ -7,7 +7,7 @@ from fastapi.exceptions import RequestValidationError, ResponseValidationError from fastapi.openapi.utils import get_openapi from fastapi.responses import JSONResponse -from ..schemas import ResponseStatus +from ..schemas import ErrorResponse, ResponseStatus from .exceptions import ApiException @@ -54,16 +54,16 @@ def _register_exception_handlers(app: FastAPI) -> None: async def api_exception_handler(request: Request, exc: ApiException) -> Response: """Handle custom API exceptions with structured response.""" api_error = exc.api_error + error_response = ErrorResponse( + data=api_error.data, + message=api_error.msg, + description=api_error.desc, + error_code=api_error.err_code, + ) 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, - }, + content=error_response.model_dump(), ) @app.exception_handler(RequestValidationError) @@ -83,15 +83,15 @@ def _register_exception_handlers(app: FastAPI) -> None: @app.exception_handler(Exception) async def generic_exception_handler(request: Request, exc: Exception) -> Response: """Handle all unhandled exceptions with a generic 500 response.""" + error_response = ErrorResponse( + message="Internal Server Error", + description="An unexpected error occurred. Please try again later.", + error_code="SERVER-500", + ) + 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", - }, + content=error_response.model_dump(), ) @@ -116,15 +116,16 @@ def _format_validation_error( } ) + error_response = ErrorResponse( + data={"errors": formatted_errors}, + message="Validation Error", + description=f"{len(formatted_errors)} validation error(s) detected", + error_code="VAL-422", + ) + 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", - }, + content=error_response.model_dump(), ) diff --git a/src/fastapi_toolsets/schemas.py b/src/fastapi_toolsets/schemas.py index 9c733e0..cc7e122 100644 --- a/src/fastapi_toolsets/schemas.py +++ b/src/fastapi_toolsets/schemas.py @@ -1,7 +1,7 @@ """Base Pydantic schemas for API responses.""" from enum import Enum -from typing import ClassVar, Generic, TypeVar +from typing import Any, ClassVar, Generic, TypeVar from pydantic import BaseModel, ConfigDict @@ -50,6 +50,7 @@ class ApiError(PydanticBase): msg: str desc: str err_code: str + data: Any | None = None class BaseResponse(PydanticBase): @@ -84,7 +85,7 @@ class ErrorResponse(BaseResponse): status: ResponseStatus = ResponseStatus.FAIL description: str | None = None - data: None = None + data: Any | None = None class Pagination(PydanticBase): diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 80bfe8a..6b7ae25 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -108,6 +108,24 @@ class TestGenerateErrorResponses: assert example["status"] == "FAIL" assert example["error_code"] == "RES-404" assert example["message"] == "Not Found" + assert example["data"] is None + + def test_response_example_with_data(self): + """Generated response includes data when set on ApiError.""" + + class ErrorWithData(ApiException): + api_error = ApiError( + code=400, + msg="Bad Request", + desc="Invalid input.", + err_code="BAD-400", + data={"details": "some context"}, + ) + + responses = generate_error_responses(ErrorWithData) + example = responses[400]["content"]["application/json"]["example"] + + assert example["data"] == {"details": "some context"} class TestInitExceptionsHandlers: @@ -137,6 +155,59 @@ class TestInitExceptionsHandlers: assert data["error_code"] == "RES-404" assert data["message"] == "Not Found" + def test_handles_api_exception_without_data(self): + """ApiException without data returns null data field.""" + app = FastAPI() + init_exceptions_handlers(app) + + @app.get("/error") + async def raise_error(): + raise NotFoundError() + + client = TestClient(app) + response = client.get("/error") + + assert response.status_code == 404 + assert response.json()["data"] is None + + def test_handles_api_exception_with_data(self): + """ApiException with data returns the data payload.""" + app = FastAPI() + init_exceptions_handlers(app) + + class CustomValidationError(ApiException): + api_error = ApiError( + code=422, + msg="Validation Error", + desc="1 validation error(s) detected", + err_code="CUSTOM-422", + data={ + "errors": [ + { + "field": "email", + "message": "invalid format", + "type": "value_error", + } + ] + }, + ) + + @app.get("/error") + async def raise_error(): + raise CustomValidationError() + + client = TestClient(app) + response = client.get("/error") + + assert response.status_code == 422 + data = response.json() + assert data["data"] == { + "errors": [ + {"field": "email", "message": "invalid format", "type": "value_error"} + ] + } + assert data["error_code"] == "CUSTOM-422" + def test_handles_validation_error(self): """Handles validation errors with structured response.""" from pydantic import BaseModel diff --git a/tests/test_schemas.py b/tests/test_schemas.py index 35a65c5..abea9ac 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -46,6 +46,31 @@ class TestApiError: assert error.desc == "The resource was not found." assert error.err_code == "RES-404" + def test_data_defaults_to_none(self): + """ApiError data field defaults to None.""" + error = ApiError( + code=404, + msg="Not Found", + desc="The resource was not found.", + err_code="RES-404", + ) + assert error.data is None + + def test_create_with_data(self): + """ApiError can be created with a data payload.""" + error = ApiError( + code=422, + msg="Validation Error", + desc="2 validation error(s) detected", + err_code="VAL-422", + data={ + "errors": [{"field": "name", "message": "required", "type": "missing"}] + }, + ) + assert error.data == { + "errors": [{"field": "name", "message": "required", "type": "missing"}] + } + def test_requires_all_fields(self): """ApiError requires all fields.""" with pytest.raises(ValidationError):