pythonasyncioapiasyncfastapiframeworkjsonjson-schemaopenapiopenapi3pydanticpython-typespython3redocreststarletteswaggerswagger-uiuvicornweb
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
113 lines
3.2 KiB
113 lines
3.2 KiB
"""RFC 9457 Problem Details for HTTP APIs."""
|
|
|
|
import http.client
|
|
from collections.abc import Mapping, Sequence
|
|
from typing import Any
|
|
|
|
from fastapi.encoders import jsonable_encoder
|
|
from pydantic import BaseModel, ConfigDict
|
|
|
|
|
|
class ProblemDetails(BaseModel):
|
|
type: str = "about:blank"
|
|
title: str | None = None
|
|
status: int | None = None
|
|
detail: str | None = None
|
|
instance: str | None = None
|
|
|
|
model_config = ConfigDict(extra="allow")
|
|
|
|
|
|
def _slugify(text: str) -> str:
|
|
return text.lower().replace(" ", "-").replace("'", "")
|
|
|
|
|
|
def resolve_problem_type(
|
|
*,
|
|
explicit_type: str | None = None,
|
|
status_code: int | None = None,
|
|
error_kind: str | None = None,
|
|
type_base_uri: str | None = None,
|
|
problem_types: Mapping[int | type, str] | None = None,
|
|
) -> str:
|
|
segment: str | None = None
|
|
|
|
if explicit_type:
|
|
segment = explicit_type
|
|
elif problem_types and status_code is not None:
|
|
segment = problem_types.get(status_code)
|
|
|
|
if segment is not None:
|
|
if segment.startswith(("http://", "https://", "about:", "tag:", "urn:")):
|
|
return segment
|
|
if type_base_uri:
|
|
return f"{type_base_uri.rstrip('/')}/{segment.lstrip('/')}"
|
|
return segment
|
|
|
|
if error_kind:
|
|
if type_base_uri:
|
|
return f"{type_base_uri.rstrip('/')}/{error_kind}"
|
|
return error_kind
|
|
|
|
if type_base_uri and status_code is not None:
|
|
reason = http.client.responses.get(status_code)
|
|
if reason:
|
|
segment = _slugify(reason)
|
|
return f"{type_base_uri.rstrip('/')}/{segment}"
|
|
|
|
return "about:blank"
|
|
|
|
|
|
def problem_details_from_http_exception(
|
|
exc: Any,
|
|
*,
|
|
url: str | None = None,
|
|
type_base_uri: str | None = None,
|
|
problem_types: Mapping[int | type, str] | None = None,
|
|
) -> ProblemDetails:
|
|
status_code: int = exc.status_code
|
|
detail: Any = getattr(exc, "detail", None)
|
|
detail_str = detail if isinstance(detail, str) else None
|
|
kwargs: dict[str, Any] = {}
|
|
if not isinstance(detail, str) and detail is not None:
|
|
kwargs["errors"] = jsonable_encoder(detail)
|
|
problem_type = resolve_problem_type(
|
|
explicit_type=getattr(exc, "type", None),
|
|
status_code=status_code,
|
|
type_base_uri=type_base_uri,
|
|
problem_types=problem_types,
|
|
)
|
|
return ProblemDetails(
|
|
type=problem_type,
|
|
title=http.client.responses.get(status_code),
|
|
status=status_code,
|
|
detail=detail_str,
|
|
instance=url,
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
def problem_details_from_request_validation(
|
|
errors: Sequence[Any],
|
|
*,
|
|
url: str | None = None,
|
|
type_base_uri: str | None = None,
|
|
problem_types: Mapping[int | type, str] | None = None,
|
|
) -> ProblemDetails:
|
|
errors_list = list(errors)
|
|
num_errors = len(errors_list)
|
|
problem_type = resolve_problem_type(
|
|
status_code=422,
|
|
error_kind="validation-error",
|
|
type_base_uri=type_base_uri,
|
|
problem_types=problem_types,
|
|
)
|
|
extra: dict[str, Any] = {"errors": jsonable_encoder(errors_list)}
|
|
return ProblemDetails(
|
|
type=problem_type,
|
|
title="Validation Error",
|
|
status=422,
|
|
detail=f"{num_errors} validation error{'s' if num_errors != 1 else ''}",
|
|
instance=url,
|
|
**extra,
|
|
)
|
|
|