90 lines
2.5 KiB
Python
90 lines
2.5 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class ProblemDetails(BaseModel):
|
|
"""RFC 9457 compatible error response used by the REST contract."""
|
|
|
|
type: str
|
|
title: str
|
|
status: int
|
|
detail: str
|
|
instance: str
|
|
code: str
|
|
trace_id: str
|
|
errors: list[dict[str, Any]] = Field(default_factory=list)
|
|
|
|
|
|
def _trace_id(request: Request) -> str:
|
|
return request.headers.get("X-Request-Id") or str(uuid4())
|
|
|
|
|
|
def _problem_response(
|
|
request: Request,
|
|
*,
|
|
status_code: int,
|
|
title: str,
|
|
detail: str,
|
|
code: str,
|
|
errors: list[dict[str, Any]] | None = None,
|
|
) -> JSONResponse:
|
|
problem = ProblemDetails(
|
|
type=f"https://tjwater.example/problems/{code.replace('_', '-')}",
|
|
title=title,
|
|
status=status_code,
|
|
detail=detail,
|
|
instance=request.url.path,
|
|
code=code,
|
|
trace_id=_trace_id(request),
|
|
errors=errors or [],
|
|
)
|
|
return JSONResponse(
|
|
status_code=status_code,
|
|
content=problem.model_dump(mode="json"),
|
|
media_type="application/problem+json",
|
|
)
|
|
|
|
|
|
def install_problem_details_handlers(app: FastAPI) -> None:
|
|
@app.exception_handler(RequestValidationError)
|
|
async def validation_error_handler(
|
|
request: Request,
|
|
exc: RequestValidationError,
|
|
) -> JSONResponse:
|
|
return _problem_response(
|
|
request,
|
|
status_code=422,
|
|
title="Validation error",
|
|
detail="Request validation failed",
|
|
code="validation_error",
|
|
errors=exc.errors(),
|
|
)
|
|
|
|
@app.exception_handler(HTTPException)
|
|
async def http_error_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
|
detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
|
|
code_by_status = {
|
|
401: "unauthenticated",
|
|
403: "forbidden",
|
|
404: "not_found",
|
|
409: "conflict",
|
|
422: "validation_error",
|
|
503: "dependency_unavailable",
|
|
}
|
|
return _problem_response(
|
|
request,
|
|
status_code=exc.status_code,
|
|
title=code_by_status.get(exc.status_code, "request_error")
|
|
.replace("_", " ")
|
|
.title(),
|
|
detail=detail,
|
|
code=code_by_status.get(exc.status_code, "request_error"),
|
|
)
|