Align API error envelopes
This commit is contained in:
+42
-10
@@ -11,13 +11,17 @@ from app.core.errors import AppError
|
||||
from app.core.logging import configure_logging
|
||||
|
||||
|
||||
def _to_error_payload(code: str, message: str, details: dict | list | None = None) -> dict:
|
||||
def _to_error_payload(
|
||||
code: str,
|
||||
message: str,
|
||||
details: dict | list | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
"details": details or {},
|
||||
},
|
||||
"error": code,
|
||||
"message": message,
|
||||
"details": details or {},
|
||||
"request_id": request_id,
|
||||
}
|
||||
|
||||
|
||||
@@ -58,28 +62,56 @@ def create_app() -> FastAPI:
|
||||
async def app_error(request: Request, exc: AppError): # noqa: ARG001
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=_to_error_payload(exc.code, exc.message, exc.details),
|
||||
content=_to_error_payload(
|
||||
exc.code,
|
||||
exc.message,
|
||||
exc.details,
|
||||
request_id=request.headers.get("x-request-id"),
|
||||
),
|
||||
)
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_error(request: Request, exc: HTTPException): # noqa: ARG001
|
||||
code = "HTTP_ERROR"
|
||||
message = str(exc.detail)
|
||||
details = {}
|
||||
if isinstance(exc.detail, dict):
|
||||
code = str(exc.detail.get("error") or exc.detail.get("code") or code)
|
||||
message = str(exc.detail.get("message") or message)
|
||||
raw_details = exc.detail.get("details")
|
||||
details = raw_details if isinstance(raw_details, (dict, list)) else {}
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=_to_error_payload("HTTP_ERROR", str(exc.detail), {}),
|
||||
content=_to_error_payload(
|
||||
code,
|
||||
message,
|
||||
details,
|
||||
request_id=request.headers.get("x-request-id"),
|
||||
),
|
||||
)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_error(request: Request, exc: RequestValidationError): # noqa: ARG001
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=_to_error_payload("VALIDATION_ERROR", "Validation failed", exc.errors()),
|
||||
content=_to_error_payload(
|
||||
"VALIDATION_ERROR",
|
||||
"Validation failed",
|
||||
exc.errors(),
|
||||
request_id=request.headers.get("x-request-id"),
|
||||
),
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unexpected_error(request: Request, exc: Exception): # noqa: ARG001
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=_to_error_payload("INTERNAL_ERROR", "Unexpected server error", {"type": exc.__class__.__name__}),
|
||||
content=_to_error_payload(
|
||||
"INTERNAL_ERROR",
|
||||
"Unexpected server error",
|
||||
{"type": exc.__class__.__name__},
|
||||
request_id=request.headers.get("x-request-id"),
|
||||
),
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app, create_app
|
||||
|
||||
|
||||
def assert_contract_error(payload: dict, code: str) -> None:
|
||||
assert payload["error"] == code
|
||||
assert isinstance(payload["message"], str)
|
||||
assert "details" in payload
|
||||
assert "request_id" in payload
|
||||
|
||||
|
||||
def test_app_error_uses_top_level_api_error_contract() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/v1/external/providers/unknown")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert_contract_error(response.json(), "PROVIDER_NOT_FOUND")
|
||||
assert response.json()["message"] == "Provider not found"
|
||||
|
||||
|
||||
def test_http_exception_uses_top_level_api_error_contract() -> None:
|
||||
test_app = create_app()
|
||||
|
||||
@test_app.get("/__test__/http-error")
|
||||
def raise_http_error() -> None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
client = TestClient(test_app)
|
||||
response = client.get("/__test__/http-error")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert_contract_error(response.json(), "HTTP_ERROR")
|
||||
assert response.json()["message"] == "Project not found"
|
||||
|
||||
|
||||
def test_validation_error_uses_top_level_api_error_contract() -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/v1/projects/not-a-uuid")
|
||||
|
||||
assert response.status_code == 422
|
||||
assert_contract_error(response.json(), "VALIDATION_ERROR")
|
||||
assert response.json()["message"] == "Validation failed"
|
||||
assert isinstance(response.json()["details"], list)
|
||||
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_frontend_api_client_accepts_top_level_error_contract() -> None:
|
||||
client = (ROOT / "frontend" / "src" / "services" / "api" / "client.ts").read_text(encoding="utf-8")
|
||||
|
||||
assert 'typeof payload?.error === "string"' in client
|
||||
assert "payload?.message" in client
|
||||
assert "payload?.details" in client
|
||||
|
||||
|
||||
def test_frontend_api_client_remains_legacy_error_tolerant() -> None:
|
||||
client = (ROOT / "frontend" / "src" / "services" / "api" / "client.ts").read_text(encoding="utf-8")
|
||||
|
||||
assert "legacyError?.code" in client
|
||||
assert "legacyError?.message" in client
|
||||
assert "legacyError?.details" in client
|
||||
@@ -103,7 +103,8 @@ def test_provider_api_envelopes_and_invalid_provider() -> None:
|
||||
|
||||
invalid_response = client.get("/api/v1/external/providers/unknown")
|
||||
assert invalid_response.status_code == 404
|
||||
assert invalid_response.json()["error"]["code"] == "PROVIDER_NOT_FOUND"
|
||||
assert invalid_response.json()["error"] == "PROVIDER_NOT_FOUND"
|
||||
assert invalid_response.json()["message"] == "Provider not found"
|
||||
|
||||
|
||||
def test_provider_import_api_returns_clear_not_configured_response() -> None:
|
||||
|
||||
Reference in New Issue
Block a user