Refactor contract of errors
This commit is contained in:
parent
3d134fb7f9
commit
280dabfd95
@ -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"
|
message = str(detail) if detail else "HTTP error"
|
||||||
field = None
|
return status_code, status_code, message, None
|
||||||
|
|
||||||
if status_code == status.HTTP_422_UNPROCESSABLE_ENTITY:
|
|
||||||
code, parsed_field = _parse_prefixed_validation_error(message)
|
|
||||||
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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 не существует",
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user