diff --git a/api/src/core/exception_handlers.py b/api/src/core/exception_handlers.py index 5518839..6c2cfa2 100644 --- a/api/src/core/exception_handlers.py +++ b/api/src/core/exception_handlers.py @@ -1,4 +1,5 @@ from fastapi import FastAPI, HTTPException, status +from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError, DBAPIError @@ -94,7 +95,28 @@ def map_http_exception(exc: HTTPException) -> tuple[int, int, str, str]: return status_code, status_code, message, "" +def map_request_validation_error(exc: RequestValidationError) -> tuple[int, int, str, str]: + status_code = status.HTTP_422_UNPROCESSABLE_ENTITY + errors = exc.errors() + if not errors: + return status_code, status_code, "Validation error", "" + + first_error = errors[0] + message = str(first_error.get("msg") or "Validation error") + loc = first_error.get("loc") or () + field = ".".join(str(part) for part in loc if part not in ("body", "query", "path", "header", "cookie")) + return status_code, status_code, message, field + + def register_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(RequestValidationError) + async def request_validation_exception_handler(request, exc: RequestValidationError): + status_code, code, message, field = map_request_validation_error(exc) + return JSONResponse( + status_code=status_code, + content=_error_payload(code, message, field=field), + ) + @app.exception_handler(HTTPException) async def http_exception_handler(request, exc: HTTPException): status_code, code, message, field = map_http_exception(exc) diff --git a/api/tests/unit/test_exception_handlers_http_shape.py b/api/tests/unit/test_exception_handlers_http_shape.py index b6cb485..3a90a87 100644 --- a/api/tests/unit/test_exception_handlers_http_shape.py +++ b/api/tests/unit/test_exception_handlers_http_shape.py @@ -29,6 +29,10 @@ def _make_app() -> FastAPI: exc.orig = Exception("budget_form #99 не существует") raise exc + @app.get("/form/{form_id}") + async def form_by_id(form_id: int): + return {"form_id": form_id} + return app @@ -66,3 +70,15 @@ def test_sqlalchemy_exception_returns_structured_payload(): "field": "", "message": "budget_form #99 не существует", } + + +def test_request_validation_422_returns_structured_payload(): + client = TestClient(_make_app()) + response = client.get("/form/not-int") + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert response.json() == { + "code": status.HTTP_422_UNPROCESSABLE_ENTITY, + "field": "form_id", + "message": "Input should be a valid integer, unable to parse string as an integer", + }