Refactor contract of errors

This commit is contained in:
Raykov-MS 2026-05-14 11:16:35 +03:00
parent 3d134fb7f9
commit 280dabfd95
4 changed files with 46 additions and 36 deletions

View File

@ -24,15 +24,13 @@ VALIDATION_PREFIXES = (
) )
def _error_payload(code: str, message: str, field: str | None = None) -> dict: def _error_payload(code: str | int, message: str, field: str | None = None) -> dict:
"""Формирует структурированный payload ошибки по спеке.""" """Формирует структурированный payload ошибки по спеке."""
payload = { return {
"code": code, "code": code,
"field": field,
"message": message, "message": message,
} }
if field:
payload["field"] = field
return payload
def _parse_prefixed_validation_error(message: str) -> tuple[str, str | None]: def _parse_prefixed_validation_error(message: str) -> tuple[str, str | None]:
@ -80,7 +78,7 @@ def map_sqlalchemy_error(exc: SQLAlchemyError) -> tuple[int, str, str, str | Non
return status.HTTP_400_BAD_REQUEST, "db_error", message, None return status.HTTP_400_BAD_REQUEST, "db_error", message, None
def map_http_exception(exc: HTTPException) -> tuple[int, str, str, str | None]: def map_http_exception(exc: HTTPException) -> tuple[int, str | int, str, str | None]:
"""Маппинг HTTPException в единый формат ошибки API.""" """Маппинг HTTPException в единый формат ошибки API."""
status_code = int(exc.status_code) status_code = int(exc.status_code)
detail = exc.detail detail = exc.detail
@ -90,26 +88,11 @@ def map_http_exception(exc: HTTPException) -> tuple[int, str, str, str | None]:
field = detail.get("field") field = detail.get("field")
if field is not None: if field is not None:
field = str(field) field = str(field)
code = detail.get("code") code = detail.get("code", status_code)
if code is not None: return status_code, code, message, field
return status_code, str(code), message, field
else:
message = str(detail) if detail else "HTTP error"
field = None
if status_code == status.HTTP_422_UNPROCESSABLE_ENTITY: message = str(detail) if detail else "HTTP error"
code, parsed_field = _parse_prefixed_validation_error(message) return status_code, status_code, message, None
return status_code, code, message, parsed_field or field
if status_code == status.HTTP_401_UNAUTHORIZED:
return status_code, "unauthorized", message, field
if status_code == status.HTTP_403_FORBIDDEN:
return status_code, "access_denied", message, field
if status_code == status.HTTP_404_NOT_FOUND:
return status_code, "not_found", message, field
if status_code == status.HTTP_409_CONFLICT:
return status_code, "conflict", message, field
return status_code, "http_error", message, field
def register_exception_handlers(app: FastAPI) -> None: def register_exception_handlers(app: FastAPI) -> None:

View File

@ -1,3 +1,6 @@
import uuid
def _auth_headers(tokens: dict) -> dict: def _auth_headers(tokens: dict) -> dict:
return {"Authorization": f"Bearer {tokens['access_token']}"} return {"Authorization": f"Bearer {tokens['access_token']}"}
@ -8,8 +11,19 @@ def test_users_me(client, admin_tokens):
assert response.json()["username"] == "admin" assert response.json()["username"] == "admin"
def test_users_list(client, admin_tokens): def test_users_create_smoke(client, admin_tokens):
listed = client.get("/api/v1/users/", headers=_auth_headers(admin_tokens)) suffix = uuid.uuid4().hex[:8]
assert listed.status_code == 200 username = f"user_{suffix}"
usernames = [row["username"] for row in listed.json()["result"]] created = client.put(
assert "admin" in usernames "/api/v1/users/",
json={
"email": f"{username}@example.com",
"username": username,
"password": "pass123",
"full_name": "User One",
"role_id": 2,
},
headers=_auth_headers(admin_tokens),
)
assert created.status_code == 200
assert created.json()["result"]["username"] == username

View File

@ -81,9 +81,9 @@ def test_map_http_exception_validation_prefix():
status_code, code, message, field = map_http_exception(exc) status_code, code, message, field = map_http_exception(exc)
assert status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert code == "unknown_column" assert code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert message == "unknown_column: q5.plan" assert message == "unknown_column: q5.plan"
assert field == "q5.plan" assert field is None
def test_map_http_exception_from_structured_detail(): def test_map_http_exception_from_structured_detail():
@ -106,6 +106,17 @@ def test_map_http_exception_unauthorized():
status_code, code, message, field = map_http_exception(exc) status_code, code, message, field = map_http_exception(exc)
assert status_code == status.HTTP_401_UNAUTHORIZED assert status_code == status.HTTP_401_UNAUTHORIZED
assert code == "unauthorized" assert code == status.HTTP_401_UNAUTHORIZED
assert message == "Недействительный токен" assert message == "Недействительный токен"
assert field is None assert field is None
def test_map_http_exception_not_found_maps_code_from_status():
exc = HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not found")
status_code, code, message, field = map_http_exception(exc)
assert status_code == status.HTTP_404_NOT_FOUND
assert code == status.HTTP_404_NOT_FOUND
assert message == "not found"
assert field is None

View File

@ -38,7 +38,8 @@ def test_http_exception_returns_structured_payload():
assert response.status_code == status.HTTP_401_UNAUTHORIZED assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == { assert response.json() == {
"code": "unauthorized", "code": status.HTTP_401_UNAUTHORIZED,
"field": None,
"message": "Недействительный токен", "message": "Недействительный токен",
} }
@ -49,8 +50,8 @@ def test_http_validation_exception_extracts_field():
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
assert response.json() == { assert response.json() == {
"code": "unknown_column", "code": status.HTTP_422_UNPROCESSABLE_ENTITY,
"field": "q5.plan", "field": None,
"message": "unknown_column: q5.plan", "message": "unknown_column: q5.plan",
} }
@ -62,5 +63,6 @@ def test_sqlalchemy_exception_returns_structured_payload():
assert response.status_code == status.HTTP_404_NOT_FOUND assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == { assert response.json() == {
"code": "not_found", "code": "not_found",
"field": None,
"message": "budget_form #99 не существует", "message": "budget_form #99 не существует",
} }