feat: add optional data field in ApiError (#63)

This commit is contained in:
d3vyce
2026-02-14 20:37:50 +01:00
committed by GitHub
parent 19805ab376
commit 74a54b7396
5 changed files with 123 additions and 25 deletions

View File

@@ -183,7 +183,7 @@ def generate_error_responses(
"content": { "content": {
"application/json": { "application/json": {
"example": { "example": {
"data": None, "data": api_error.data,
"status": ResponseStatus.FAIL.value, "status": ResponseStatus.FAIL.value,
"message": api_error.msg, "message": api_error.msg,
"description": api_error.desc, "description": api_error.desc,

View File

@@ -7,7 +7,7 @@ from fastapi.exceptions import RequestValidationError, ResponseValidationError
from fastapi.openapi.utils import get_openapi from fastapi.openapi.utils import get_openapi
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from ..schemas import ResponseStatus from ..schemas import ErrorResponse, ResponseStatus
from .exceptions import ApiException 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: async def api_exception_handler(request: Request, exc: ApiException) -> Response:
"""Handle custom API exceptions with structured response.""" """Handle custom API exceptions with structured response."""
api_error = exc.api_error 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( return JSONResponse(
status_code=api_error.code, status_code=api_error.code,
content={ content=error_response.model_dump(),
"data": None,
"status": ResponseStatus.FAIL.value,
"message": api_error.msg,
"description": api_error.desc,
"error_code": api_error.err_code,
},
) )
@app.exception_handler(RequestValidationError) @app.exception_handler(RequestValidationError)
@@ -83,15 +83,15 @@ def _register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(Exception) @app.exception_handler(Exception)
async def generic_exception_handler(request: Request, exc: Exception) -> Response: async def generic_exception_handler(request: Request, exc: Exception) -> Response:
"""Handle all unhandled exceptions with a generic 500 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( return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={ content=error_response.model_dump(),
"data": None,
"status": ResponseStatus.FAIL.value,
"message": "Internal Server Error",
"description": "An unexpected error occurred. Please try again later.",
"error_code": "SERVER-500",
},
) )
@@ -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( return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={ content=error_response.model_dump(),
"data": {"errors": formatted_errors},
"status": ResponseStatus.FAIL.value,
"message": "Validation Error",
"description": f"{len(formatted_errors)} validation error(s) detected",
"error_code": "VAL-422",
},
) )

View File

@@ -1,7 +1,7 @@
"""Base Pydantic schemas for API responses.""" """Base Pydantic schemas for API responses."""
from enum import Enum from enum import Enum
from typing import ClassVar, Generic, TypeVar from typing import Any, ClassVar, Generic, TypeVar
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@@ -50,6 +50,7 @@ class ApiError(PydanticBase):
msg: str msg: str
desc: str desc: str
err_code: str err_code: str
data: Any | None = None
class BaseResponse(PydanticBase): class BaseResponse(PydanticBase):
@@ -84,7 +85,7 @@ class ErrorResponse(BaseResponse):
status: ResponseStatus = ResponseStatus.FAIL status: ResponseStatus = ResponseStatus.FAIL
description: str | None = None description: str | None = None
data: None = None data: Any | None = None
class Pagination(PydanticBase): class Pagination(PydanticBase):

View File

@@ -108,6 +108,24 @@ class TestGenerateErrorResponses:
assert example["status"] == "FAIL" assert example["status"] == "FAIL"
assert example["error_code"] == "RES-404" assert example["error_code"] == "RES-404"
assert example["message"] == "Not Found" 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: class TestInitExceptionsHandlers:
@@ -137,6 +155,59 @@ class TestInitExceptionsHandlers:
assert data["error_code"] == "RES-404" assert data["error_code"] == "RES-404"
assert data["message"] == "Not Found" 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): def test_handles_validation_error(self):
"""Handles validation errors with structured response.""" """Handles validation errors with structured response."""
from pydantic import BaseModel from pydantic import BaseModel

View File

@@ -46,6 +46,31 @@ class TestApiError:
assert error.desc == "The resource was not found." assert error.desc == "The resource was not found."
assert error.err_code == "RES-404" 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): def test_requires_all_fields(self):
"""ApiError requires all fields.""" """ApiError requires all fields."""
with pytest.raises(ValidationError): with pytest.raises(ValidationError):