fastapi-rfc9457 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_rfc9457/__init__.py +54 -0
- fastapi_rfc9457/_version.py +24 -0
- fastapi_rfc9457/builtins.py +101 -0
- fastapi_rfc9457/client.py +82 -0
- fastapi_rfc9457/docs.py +144 -0
- fastapi_rfc9457/handlers.py +138 -0
- fastapi_rfc9457/integration.py +76 -0
- fastapi_rfc9457/models.py +36 -0
- fastapi_rfc9457/openapi.py +401 -0
- fastapi_rfc9457/problem.py +155 -0
- fastapi_rfc9457/py.typed +0 -0
- fastapi_rfc9457-0.1.0.dist-info/METADATA +94 -0
- fastapi_rfc9457-0.1.0.dist-info/RECORD +14 -0
- fastapi_rfc9457-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""fastapi-rfc9457 — typed RFC 9457 Problem Details for FastAPI & Pydantic v2."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
from .builtins import (
|
|
8
|
+
BadRequest,
|
|
9
|
+
Conflict,
|
|
10
|
+
Forbidden,
|
|
11
|
+
InternalServerError,
|
|
12
|
+
InvalidParam,
|
|
13
|
+
NotAuthenticated,
|
|
14
|
+
NotFound,
|
|
15
|
+
TooManyRequests,
|
|
16
|
+
UnprocessableContent,
|
|
17
|
+
ValidationProblem,
|
|
18
|
+
)
|
|
19
|
+
from .client import httpx_raise_hook, parse_problem, raise_for_problem
|
|
20
|
+
from .docs import get_problem_docs_router
|
|
21
|
+
from .integration import add_problem_handlers, problem_details_lifespan
|
|
22
|
+
from .models import PROBLEM_MEDIA_TYPE, ProblemDetail
|
|
23
|
+
from .openapi import problems
|
|
24
|
+
from .problem import Problem, ProblemError
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
__version__ = version("fastapi-rfc9457")
|
|
28
|
+
except PackageNotFoundError: # pragma: no cover
|
|
29
|
+
__version__ = "0.0.0"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"PROBLEM_MEDIA_TYPE",
|
|
33
|
+
"BadRequest",
|
|
34
|
+
"Conflict",
|
|
35
|
+
"Forbidden",
|
|
36
|
+
"InternalServerError",
|
|
37
|
+
"InvalidParam",
|
|
38
|
+
"NotAuthenticated",
|
|
39
|
+
"NotFound",
|
|
40
|
+
"Problem",
|
|
41
|
+
"ProblemDetail",
|
|
42
|
+
"ProblemError",
|
|
43
|
+
"TooManyRequests",
|
|
44
|
+
"UnprocessableContent",
|
|
45
|
+
"ValidationProblem",
|
|
46
|
+
"__version__",
|
|
47
|
+
"add_problem_handlers",
|
|
48
|
+
"get_problem_docs_router",
|
|
49
|
+
"httpx_raise_hook",
|
|
50
|
+
"parse_problem",
|
|
51
|
+
"problem_details_lifespan",
|
|
52
|
+
"problems",
|
|
53
|
+
"raise_for_problem",
|
|
54
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Ready-to-use built-in problem types and the structured-validation types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from .problem import Problem
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BadRequest(Problem):
|
|
13
|
+
"""The request was malformed."""
|
|
14
|
+
|
|
15
|
+
title = "Bad Request"
|
|
16
|
+
status = 400
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NotAuthenticated(Problem):
|
|
20
|
+
"""Authentication is required and has failed or not been provided."""
|
|
21
|
+
|
|
22
|
+
title = "Unauthorized"
|
|
23
|
+
status = 401
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Forbidden(Problem):
|
|
27
|
+
"""You do not have permission to access this resource."""
|
|
28
|
+
|
|
29
|
+
title = "Forbidden"
|
|
30
|
+
status = 403
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NotFound(Problem):
|
|
34
|
+
"""The requested resource was not found."""
|
|
35
|
+
|
|
36
|
+
title = "Not Found"
|
|
37
|
+
status = 404
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Conflict(Problem):
|
|
41
|
+
"""The request conflicts with the current state of the resource."""
|
|
42
|
+
|
|
43
|
+
title = "Conflict"
|
|
44
|
+
status = 409
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class UnprocessableContent(Problem):
|
|
48
|
+
"""The request was well-formed but could not be processed."""
|
|
49
|
+
|
|
50
|
+
title = "Unprocessable Content"
|
|
51
|
+
status = 422
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TooManyRequests(Problem):
|
|
55
|
+
"""You have sent too many requests in a given amount of time."""
|
|
56
|
+
|
|
57
|
+
title = "Too Many Requests"
|
|
58
|
+
status = 429
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class InternalServerError(Problem):
|
|
62
|
+
"""The server encountered an unexpected condition."""
|
|
63
|
+
|
|
64
|
+
title = "Internal Server Error"
|
|
65
|
+
status = 500
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class InvalidParam(BaseModel):
|
|
69
|
+
"""One field-level validation failure (RFC 9457 extension member).
|
|
70
|
+
|
|
71
|
+
Attributes
|
|
72
|
+
----------
|
|
73
|
+
loc : list[str | int]
|
|
74
|
+
Faithful FastAPI location, e.g. ``["body", 0, "task_name"]`` — the list
|
|
75
|
+
index is preserved (why we use ``loc`` rather than RFC 7807's ``name``).
|
|
76
|
+
We deliberately omit a JSON Pointer rendering: ``loc`` already carries
|
|
77
|
+
strictly more (it keeps int indices distinct from string keys and avoids
|
|
78
|
+
the ``/``-escaping ambiguity a flat pointer would introduce), and a
|
|
79
|
+
location-class-prefixed pointer like ``/query/limit`` does not point into
|
|
80
|
+
any real request document.
|
|
81
|
+
detail : str
|
|
82
|
+
The pydantic error message.
|
|
83
|
+
type : str
|
|
84
|
+
The pydantic error code, e.g. ``"missing"``.
|
|
85
|
+
input : Any
|
|
86
|
+
The offending value; populated only when ``strip_debug`` is False.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
loc: list[str | int]
|
|
90
|
+
detail: str
|
|
91
|
+
type: str
|
|
92
|
+
input: Any = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ValidationProblem(Problem):
|
|
96
|
+
"""The request failed validation."""
|
|
97
|
+
|
|
98
|
+
type = "/problems/validation"
|
|
99
|
+
title = "Unprocessable Content"
|
|
100
|
+
status = 422
|
|
101
|
+
errors: list[InvalidParam]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Client-side parsing of ``application/problem+json`` back into typed problems."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .models import PROBLEM_MEDIA_TYPE, ProblemDetail
|
|
9
|
+
from .problem import Problem, ProblemError, extension_fields, iter_problem_types
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _coerce(source: Any) -> dict[str, Any]:
|
|
13
|
+
if isinstance(source, dict):
|
|
14
|
+
return source
|
|
15
|
+
if isinstance(source, (bytes, bytearray, str)):
|
|
16
|
+
return json.loads(source)
|
|
17
|
+
if hasattr(source, "json"): # httpx.Response / requests.Response
|
|
18
|
+
return source.json()
|
|
19
|
+
raise TypeError(f"Cannot parse a problem from {type(source)!r}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_problem(source: Any) -> ProblemDetail | Problem:
|
|
23
|
+
"""Parse a problem document into a typed ``Problem`` or generic ``ProblemDetail``.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
source : Any
|
|
28
|
+
A response object (with ``.json()``), a ``dict``, ``bytes``, or ``str``.
|
|
29
|
+
|
|
30
|
+
Returns
|
|
31
|
+
-------
|
|
32
|
+
ProblemDetail | Problem
|
|
33
|
+
The registered ``Problem`` subclass (extension members restored typed)
|
|
34
|
+
when ``type`` is known, otherwise a generic ``ProblemDetail``.
|
|
35
|
+
"""
|
|
36
|
+
detail = ProblemDetail.model_validate(_coerce(source))
|
|
37
|
+
cls = next((c for c in iter_problem_types() if c.type == detail.type), None)
|
|
38
|
+
if cls is None:
|
|
39
|
+
return detail
|
|
40
|
+
extensions = {
|
|
41
|
+
name: getattr(detail, name) for name in extension_fields(cls) if hasattr(detail, name)
|
|
42
|
+
}
|
|
43
|
+
return cls(detail=detail.detail, instance=detail.instance, **extensions) # type: ignore[call-arg]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _content_type(response: Any) -> str:
|
|
47
|
+
headers = getattr(response, "headers", {})
|
|
48
|
+
return headers.get("content-type", "") if hasattr(headers, "get") else ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def raise_for_problem(response: Any) -> None:
|
|
52
|
+
"""Raise the mapped ``Problem`` / ``ProblemError`` if this is a problem response.
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
response : Any
|
|
57
|
+
A response object with ``.headers`` and ``.json()``.
|
|
58
|
+
"""
|
|
59
|
+
if PROBLEM_MEDIA_TYPE not in _content_type(response):
|
|
60
|
+
return
|
|
61
|
+
parsed = parse_problem(response)
|
|
62
|
+
if isinstance(parsed, Problem):
|
|
63
|
+
raise parsed
|
|
64
|
+
raise ProblemError(parsed)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def httpx_raise_hook():
|
|
68
|
+
"""Return an httpx ``response`` event hook that auto-raises on problem responses.
|
|
69
|
+
|
|
70
|
+
Returns
|
|
71
|
+
-------
|
|
72
|
+
Callable
|
|
73
|
+
Use as ``httpx.Client(event_hooks={"response": [httpx_raise_hook()]})``.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def hook(response: Any) -> None:
|
|
77
|
+
if PROBLEM_MEDIA_TYPE in _content_type(response):
|
|
78
|
+
if hasattr(response, "read"):
|
|
79
|
+
response.read()
|
|
80
|
+
raise_for_problem(response)
|
|
81
|
+
|
|
82
|
+
return hook
|
fastapi_rfc9457/docs.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Dereferenceable type-doc router, mounted explicitly by the user."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
9
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
10
|
+
from starlette.responses import Response
|
|
11
|
+
|
|
12
|
+
from .builtins import InternalServerError, ValidationProblem
|
|
13
|
+
from .openapi import route_problem_types
|
|
14
|
+
from .problem import Problem, extension_fields
|
|
15
|
+
|
|
16
|
+
_HTML = """<!doctype html><meta charset="utf-8">
|
|
17
|
+
<title>{title}</title>
|
|
18
|
+
<main style="font-family:system-ui;max-width:48rem;margin:3rem auto;line-height:1.6">
|
|
19
|
+
<h1>{title} <small style="color:#888">{status}</small></h1>
|
|
20
|
+
<p><code>{type}</code></p>
|
|
21
|
+
<p>{description}</p>
|
|
22
|
+
<h2>Extension members</h2>
|
|
23
|
+
<ul>{members}</ul>
|
|
24
|
+
</main>"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _format_type(typ: Any) -> str:
|
|
28
|
+
"""Render a type annotation as a short, readable name.
|
|
29
|
+
|
|
30
|
+
Plain classes use their bare ``__name__`` (``int`` rather than the repr
|
|
31
|
+
``<class 'int'>``); parameterized generics fall back to ``str`` (``list[str]``).
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
typ : Any
|
|
36
|
+
A resolved type annotation.
|
|
37
|
+
|
|
38
|
+
Returns
|
|
39
|
+
-------
|
|
40
|
+
str
|
|
41
|
+
A human-readable rendering of the annotation.
|
|
42
|
+
"""
|
|
43
|
+
return typ.__name__ if isinstance(typ, type) else str(typ)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _payload(cls: type[Problem]) -> dict[str, Any]:
|
|
47
|
+
return {
|
|
48
|
+
"type": cls.type,
|
|
49
|
+
"title": cls.title,
|
|
50
|
+
"status": cls.status,
|
|
51
|
+
"description": (cls.__doc__ or "").strip(),
|
|
52
|
+
"extensions": {name: _format_type(typ) for name, typ in extension_fields(cls).items()},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _slug(cls: type[Problem]) -> str:
|
|
57
|
+
return (cls.type or "").rsplit("/", 1)[-1]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _render(cls: type[Problem], request: Request) -> Response:
|
|
61
|
+
data = _payload(cls)
|
|
62
|
+
if "text/html" in request.headers.get("accept", ""):
|
|
63
|
+
members = "".join(
|
|
64
|
+
f"<li><code>{html.escape(name)}</code>: {html.escape(typ)}</li>"
|
|
65
|
+
for name, typ in data["extensions"].items()
|
|
66
|
+
)
|
|
67
|
+
escaped = {k: html.escape(str(v)) for k, v in data.items() if k != "extensions"}
|
|
68
|
+
return HTMLResponse(_HTML.format(members=members, **escaped))
|
|
69
|
+
return JSONResponse(data)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _documented_types(app: Any) -> list[type[Problem]]:
|
|
73
|
+
"""Types whose doc pages this app serves: those on its routes plus the two
|
|
74
|
+
builtins ``add_problem_handlers`` always emits (validation 422, unhandled 500)."""
|
|
75
|
+
seen = {cls.type: cls for cls in route_problem_types(app)}
|
|
76
|
+
for cls in (ValidationProblem, InternalServerError):
|
|
77
|
+
seen.setdefault(cls.type, cls)
|
|
78
|
+
return list(seen.values())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_problem_docs_router(*types: type[Problem]) -> APIRouter:
|
|
82
|
+
"""Return a router serving one doc page per problem type.
|
|
83
|
+
|
|
84
|
+
Two modes:
|
|
85
|
+
|
|
86
|
+
* **Route-derived (no arguments).** Serves a page for every problem type the
|
|
87
|
+
app declares via ``responses=problems(...)`` (plus the always-on validation
|
|
88
|
+
and internal-error types), resolved from the request's app at call time.
|
|
89
|
+
Adding a new type to a route is the only change needed — nothing to restate
|
|
90
|
+
here.
|
|
91
|
+
* **Explicit.** Pass specific types to publish exactly those pages (useful for
|
|
92
|
+
a standalone docs app with no routes raising them).
|
|
93
|
+
|
|
94
|
+
Parameters
|
|
95
|
+
----------
|
|
96
|
+
*types : type[Problem]
|
|
97
|
+
The types to document, or none to derive them from the app's routes.
|
|
98
|
+
|
|
99
|
+
Returns
|
|
100
|
+
-------
|
|
101
|
+
APIRouter
|
|
102
|
+
Mount it yourself with ``app.include_router(..., prefix=..., tags=...)``;
|
|
103
|
+
point your ``type`` URIs at the chosen prefix.
|
|
104
|
+
"""
|
|
105
|
+
router = APIRouter()
|
|
106
|
+
|
|
107
|
+
if not types:
|
|
108
|
+
|
|
109
|
+
async def endpoint(slug: str, request: Request) -> Response:
|
|
110
|
+
available = {_slug(cls): cls for cls in _documented_types(request.app)}
|
|
111
|
+
match = available.get(slug)
|
|
112
|
+
if match is None:
|
|
113
|
+
raise HTTPException(
|
|
114
|
+
status_code=404, detail=f"No problem type documented at {slug!r}"
|
|
115
|
+
)
|
|
116
|
+
return _render(match, request)
|
|
117
|
+
|
|
118
|
+
router.add_api_route(
|
|
119
|
+
"/{slug}",
|
|
120
|
+
endpoint,
|
|
121
|
+
methods=["GET"],
|
|
122
|
+
name="problem-doc",
|
|
123
|
+
summary="Problem type documentation",
|
|
124
|
+
)
|
|
125
|
+
return router
|
|
126
|
+
|
|
127
|
+
for cls in types:
|
|
128
|
+
slug = _slug(cls)
|
|
129
|
+
|
|
130
|
+
def make_endpoint(problem_cls: type[Problem]):
|
|
131
|
+
async def endpoint(request: Request) -> Response:
|
|
132
|
+
return _render(problem_cls, request)
|
|
133
|
+
|
|
134
|
+
return endpoint
|
|
135
|
+
|
|
136
|
+
router.add_api_route(
|
|
137
|
+
f"/{slug}",
|
|
138
|
+
make_endpoint(cls),
|
|
139
|
+
methods=["GET"],
|
|
140
|
+
name=f"problem-doc-{slug}",
|
|
141
|
+
summary=f"{cls.title} ({cls.status})",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return router
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""The four exception handlers and the per-request wire-model builder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from http import HTTPStatus
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from fastapi import Request
|
|
10
|
+
from fastapi.encoders import jsonable_encoder
|
|
11
|
+
from fastapi.exceptions import RequestValidationError
|
|
12
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
13
|
+
from starlette.responses import Response
|
|
14
|
+
|
|
15
|
+
from .builtins import InternalServerError, InvalidParam, ValidationProblem
|
|
16
|
+
from .models import PROBLEM_MEDIA_TYPE, ProblemDetail
|
|
17
|
+
from .problem import Problem, extension_fields
|
|
18
|
+
|
|
19
|
+
Handler = Callable[[Request, Exception], Awaitable[Response]]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_wire(problem: Problem, *, instance: str | None) -> ProblemDetail:
|
|
23
|
+
"""Materialize a fresh wire model from a carried problem (never mutates it).
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
problem : Problem
|
|
28
|
+
The raised problem instance (read-only input).
|
|
29
|
+
instance : str | None
|
|
30
|
+
Resolved ``instance`` to use when the problem didn't set one.
|
|
31
|
+
|
|
32
|
+
Returns
|
|
33
|
+
-------
|
|
34
|
+
ProblemDetail
|
|
35
|
+
A brand-new wire model for this request.
|
|
36
|
+
"""
|
|
37
|
+
cls = type(problem)
|
|
38
|
+
extensions = {name: getattr(problem, name) for name in extension_fields(cls)}
|
|
39
|
+
return ProblemDetail(
|
|
40
|
+
type=cls.type or "about:blank",
|
|
41
|
+
title=cls.title,
|
|
42
|
+
status=cls.status,
|
|
43
|
+
detail=problem.detail,
|
|
44
|
+
instance=problem.instance if problem.instance is not None else instance,
|
|
45
|
+
**extensions,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _respond(detail: ProblemDetail) -> Response:
|
|
50
|
+
return Response(
|
|
51
|
+
content=detail.model_dump_json(exclude_none=True),
|
|
52
|
+
status_code=detail.status,
|
|
53
|
+
media_type=PROBLEM_MEDIA_TYPE,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def make_handlers(*, strip_debug: bool, instance_from_request: bool) -> dict[type, Handler]:
|
|
58
|
+
"""Build the exception-type -> handler mapping for ``add_exception_handler``.
|
|
59
|
+
|
|
60
|
+
Parameters
|
|
61
|
+
----------
|
|
62
|
+
strip_debug : bool
|
|
63
|
+
Redact ``detail`` on 500s and the offending ``input`` on 422s.
|
|
64
|
+
instance_from_request : bool
|
|
65
|
+
Auto-fill ``instance`` from the request path when unset.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
dict[type, Handler]
|
|
70
|
+
Mapping suitable for iterating into ``app.add_exception_handler``.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def _instance(request: Request) -> str | None:
|
|
74
|
+
return request.url.path if instance_from_request else None
|
|
75
|
+
|
|
76
|
+
async def problem_handler(request: Request, exc: Problem) -> Response:
|
|
77
|
+
return _respond(build_wire(exc, instance=_instance(request)))
|
|
78
|
+
|
|
79
|
+
async def validation_handler(request: Request, exc: RequestValidationError) -> Response:
|
|
80
|
+
params: list[InvalidParam] = []
|
|
81
|
+
for err in exc.errors():
|
|
82
|
+
loc = list(err["loc"])
|
|
83
|
+
param = InvalidParam(
|
|
84
|
+
loc=loc,
|
|
85
|
+
detail=err["msg"],
|
|
86
|
+
type=err["type"],
|
|
87
|
+
input=None if strip_debug or "input" not in err else jsonable_encoder(err["input"]),
|
|
88
|
+
)
|
|
89
|
+
params.append(param)
|
|
90
|
+
n = len(params)
|
|
91
|
+
wire = ProblemDetail.model_validate(
|
|
92
|
+
{
|
|
93
|
+
"type": ValidationProblem.type or "about:blank",
|
|
94
|
+
"title": ValidationProblem.title,
|
|
95
|
+
"status": ValidationProblem.status,
|
|
96
|
+
"detail": f"Request validation failed ({n} error{'' if n == 1 else 's'}).",
|
|
97
|
+
"instance": _instance(request),
|
|
98
|
+
"errors": [p.model_dump(exclude_none=True) for p in params],
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
return _respond(wire)
|
|
102
|
+
|
|
103
|
+
async def http_handler(request: Request, exc: StarletteHTTPException) -> Response:
|
|
104
|
+
try:
|
|
105
|
+
title = HTTPStatus(exc.status_code).phrase
|
|
106
|
+
except ValueError:
|
|
107
|
+
title = "Error"
|
|
108
|
+
wire = ProblemDetail(
|
|
109
|
+
type="about:blank",
|
|
110
|
+
title=title,
|
|
111
|
+
status=exc.status_code,
|
|
112
|
+
detail=exc.detail if isinstance(exc.detail, str) else None,
|
|
113
|
+
instance=_instance(request),
|
|
114
|
+
)
|
|
115
|
+
response = _respond(wire)
|
|
116
|
+
if exc.headers:
|
|
117
|
+
response.headers.update(exc.headers)
|
|
118
|
+
return response
|
|
119
|
+
|
|
120
|
+
async def unhandled_handler(request: Request, exc: Exception) -> Response:
|
|
121
|
+
wire = ProblemDetail(
|
|
122
|
+
type=InternalServerError.type or "about:blank",
|
|
123
|
+
title=InternalServerError.title,
|
|
124
|
+
status=InternalServerError.status,
|
|
125
|
+
detail=None if strip_debug else f"{type(exc).__name__}: {exc}",
|
|
126
|
+
instance=_instance(request),
|
|
127
|
+
)
|
|
128
|
+
return _respond(wire)
|
|
129
|
+
|
|
130
|
+
return cast(
|
|
131
|
+
dict[type, Handler],
|
|
132
|
+
{
|
|
133
|
+
Problem: problem_handler,
|
|
134
|
+
RequestValidationError: validation_handler,
|
|
135
|
+
StarletteHTTPException: http_handler,
|
|
136
|
+
Exception: unhandled_handler,
|
|
137
|
+
},
|
|
138
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Explicit, named wiring: handlers + OpenAPI registration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI
|
|
10
|
+
|
|
11
|
+
from .handlers import make_handlers
|
|
12
|
+
from .openapi import register_problem_components
|
|
13
|
+
from .problem import iter_problem_types
|
|
14
|
+
|
|
15
|
+
_INSTALLED_FLAG = "_fastapi_rfc9457_installed"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def add_problem_handlers(
|
|
19
|
+
app: FastAPI,
|
|
20
|
+
*,
|
|
21
|
+
strip_debug: bool = False,
|
|
22
|
+
instance_from_request: bool = True,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Register the four problem handlers and the OpenAPI component registration.
|
|
25
|
+
|
|
26
|
+
Does **not** mount the docs router (mount it explicitly). Idempotent: a second
|
|
27
|
+
call warns and returns.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
app : FastAPI
|
|
32
|
+
The application to wire.
|
|
33
|
+
strip_debug : bool, optional
|
|
34
|
+
Redact ``detail`` on 500s and the offending ``input`` on 422s, by default
|
|
35
|
+
False.
|
|
36
|
+
instance_from_request : bool, optional
|
|
37
|
+
Auto-fill ``instance`` from the request path when unset, by default True.
|
|
38
|
+
"""
|
|
39
|
+
if getattr(app.state, _INSTALLED_FLAG, False):
|
|
40
|
+
warnings.warn(
|
|
41
|
+
"add_problem_handlers was called more than once on this app; ignoring the repeat.",
|
|
42
|
+
stacklevel=2,
|
|
43
|
+
)
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
handlers = make_handlers(strip_debug=strip_debug, instance_from_request=instance_from_request)
|
|
47
|
+
for exc_type, handler in handlers.items():
|
|
48
|
+
app.add_exception_handler(exc_type, handler)
|
|
49
|
+
|
|
50
|
+
register_problem_components(app)
|
|
51
|
+
setattr(app.state, _INSTALLED_FLAG, True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@asynccontextmanager
|
|
55
|
+
async def problem_details_lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
56
|
+
"""Composable lifespan: validate every defined problem type on startup.
|
|
57
|
+
|
|
58
|
+
Nest this inside your own lifespan (user lifespan outer, ours inner). It
|
|
59
|
+
fails fast if any defined problem type is missing ``title`` or ``status``.
|
|
60
|
+
Duplicate type URIs are rejected later, when the OpenAPI schema is built.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
app : FastAPI
|
|
65
|
+
The application (unused today; reserved for app-metadata binding).
|
|
66
|
+
|
|
67
|
+
Yields
|
|
68
|
+
------
|
|
69
|
+
None
|
|
70
|
+
"""
|
|
71
|
+
for cls in iter_problem_types():
|
|
72
|
+
if not getattr(cls, "title", None):
|
|
73
|
+
raise RuntimeError(f"Problem type {cls.__name__} ({cls.type!r}) is missing a title")
|
|
74
|
+
if not getattr(cls, "status", None):
|
|
75
|
+
raise RuntimeError(f"Problem type {cls.__name__} ({cls.type!r}) is missing a status")
|
|
76
|
+
yield
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""The canonical RFC 9457 wire model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
|
|
7
|
+
PROBLEM_MEDIA_TYPE = "application/problem+json"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ProblemDetail(BaseModel):
|
|
11
|
+
"""Serialized RFC 9457 problem document.
|
|
12
|
+
|
|
13
|
+
Unknown extension members are carried verbatim (``extra="allow"``), so this
|
|
14
|
+
one model is both the emitted wire shape and the generic client-parse result.
|
|
15
|
+
|
|
16
|
+
Attributes
|
|
17
|
+
----------
|
|
18
|
+
type : str
|
|
19
|
+
Problem-type URI reference. Defaults to ``"about:blank"`` per RFC 9457.
|
|
20
|
+
title : str
|
|
21
|
+
Short, human-readable summary of the problem type.
|
|
22
|
+
status : int
|
|
23
|
+
HTTP status code generated for this occurrence.
|
|
24
|
+
detail : str | None
|
|
25
|
+
Human-readable explanation specific to this occurrence.
|
|
26
|
+
instance : str | None
|
|
27
|
+
URI reference identifying this specific occurrence.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
model_config = ConfigDict(extra="allow")
|
|
31
|
+
|
|
32
|
+
type: str = "about:blank"
|
|
33
|
+
title: str
|
|
34
|
+
status: int
|
|
35
|
+
detail: str | None = None
|
|
36
|
+
instance: str | None = None
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""OpenAPI integration: the ``problems()`` responses helper and component wiring.
|
|
2
|
+
|
|
3
|
+
The design mirrors ``fastapi-pagination``'s ``add_pagination(app)``: routes carry
|
|
4
|
+
real model objects (via FastAPI's native ``responses={status: {"model": ...}}``),
|
|
5
|
+
and a single ``app.openapi`` wrap recovers them by walking the routes. FastAPI
|
|
6
|
+
auto-registers the component schemas for us; the wrap only normalizes each
|
|
7
|
+
response to ``application/problem+json`` (FastAPI emits models under
|
|
8
|
+
``application/json``), converts unions to ``oneOf``, rewrites the auto-generated
|
|
9
|
+
422, and validates type-URI uniqueness at build time. No global registry.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import types
|
|
16
|
+
from functools import reduce
|
|
17
|
+
from operator import or_
|
|
18
|
+
from typing import Any, Literal, Union, get_args, get_origin
|
|
19
|
+
|
|
20
|
+
from fastapi import FastAPI
|
|
21
|
+
from fastapi.openapi.utils import get_openapi
|
|
22
|
+
from fastapi.routing import APIRoute
|
|
23
|
+
from pydantic import BaseModel, ConfigDict, create_model
|
|
24
|
+
|
|
25
|
+
from .builtins import ValidationProblem
|
|
26
|
+
from .models import PROBLEM_MEDIA_TYPE, ProblemDetail
|
|
27
|
+
from .problem import Problem, extension_fields
|
|
28
|
+
|
|
29
|
+
_REF_TEMPLATE = "#/components/schemas/{model}"
|
|
30
|
+
_HTTP_VALIDATION_ERROR = _REF_TEMPLATE.format(model="HTTPValidationError")
|
|
31
|
+
|
|
32
|
+
# Memoized per-type wire models, keyed by source class identity. This is a cache
|
|
33
|
+
# for correct/stable component naming (one component per type, not per route) —
|
|
34
|
+
# not a discovery registry: what gets documented is decided by the routes.
|
|
35
|
+
_WIRE_CACHE: dict[type[Problem], type[BaseModel]] = {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _WireBase(ProblemDetail):
|
|
39
|
+
"""Closed (``extra='forbid'``) variant of :class:`ProblemDetail` for docs.
|
|
40
|
+
|
|
41
|
+
The open base carries unknown members for client parsing; a *documented*
|
|
42
|
+
problem type has a known, closed set of extensions, so its schema forbids
|
|
43
|
+
extras (no ``additionalProperties: true`` noise in the rendered docs).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
model_config = ConfigDict(extra="forbid")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _literal(value: object) -> Any:
|
|
50
|
+
"""Build a single-value ``Literal`` from a runtime value (inherently dynamic)."""
|
|
51
|
+
return Literal[value] # type: ignore[valid-type]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _union(models: list[type[BaseModel]]) -> Any:
|
|
55
|
+
"""Combine 2+ models into a single ``X | Y`` union type (for a shared status)."""
|
|
56
|
+
return reduce(or_, models)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _wire_model(cls: type[Problem]) -> type[BaseModel]:
|
|
60
|
+
"""Build (or reuse) the per-type wire model: closed, const ``type``/``status``.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
cls : type[Problem]
|
|
65
|
+
The authored problem type.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
type[BaseModel]
|
|
70
|
+
A Pydantic model named ``cls.__name__`` whose ``type`` and ``status`` are
|
|
71
|
+
pinned to the class's values, carrying ``__problem_source__`` for recovery.
|
|
72
|
+
"""
|
|
73
|
+
cached = _WIRE_CACHE.get(cls)
|
|
74
|
+
if cached is not None:
|
|
75
|
+
return cached
|
|
76
|
+
fields: dict[str, Any] = {name: (typ, ...) for name, typ in extension_fields(cls).items()}
|
|
77
|
+
model = create_model( # type: ignore[call-overload]
|
|
78
|
+
cls.__name__,
|
|
79
|
+
__base__=_WireBase,
|
|
80
|
+
type=(_literal(cls.type), cls.type),
|
|
81
|
+
status=(_literal(cls.status), cls.status),
|
|
82
|
+
title=(str, cls.title),
|
|
83
|
+
**fields,
|
|
84
|
+
)
|
|
85
|
+
model.__problem_source__ = cls # type: ignore[attr-defined]
|
|
86
|
+
_WIRE_CACHE[cls] = model
|
|
87
|
+
return model
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def problems(*types_: type[Problem]) -> dict[int | str, dict[str, Any]]:
|
|
91
|
+
"""Build a ``responses=`` mapping for the given problem types.
|
|
92
|
+
|
|
93
|
+
Types sharing a status are merged into one response. The result spreads into
|
|
94
|
+
any ``responses=``. The actual ``application/problem+json`` media type and
|
|
95
|
+
``oneOf`` shaping are applied at OpenAPI build time by
|
|
96
|
+
:func:`register_problem_components` (wired through :func:`add_problem_handlers`).
|
|
97
|
+
|
|
98
|
+
Parameters
|
|
99
|
+
----------
|
|
100
|
+
*types_ : type[Problem]
|
|
101
|
+
The problem types a route can raise.
|
|
102
|
+
|
|
103
|
+
Returns
|
|
104
|
+
-------
|
|
105
|
+
dict[int | str, dict[str, Any]]
|
|
106
|
+
A ``responses=`` mapping keyed by status code.
|
|
107
|
+
"""
|
|
108
|
+
grouped: dict[int, list[type[Problem]]] = {}
|
|
109
|
+
for problem_type in types_:
|
|
110
|
+
grouped.setdefault(problem_type.status, []).append(problem_type)
|
|
111
|
+
|
|
112
|
+
responses: dict[int | str, dict[str, Any]] = {}
|
|
113
|
+
for status, group in grouped.items():
|
|
114
|
+
wires = [_wire_model(t) for t in group]
|
|
115
|
+
model: Any = wires[0] if len(wires) == 1 else _union(wires)
|
|
116
|
+
descriptions = [(t.__doc__ or t.title).strip() for t in group]
|
|
117
|
+
responses[status] = {"model": model, "description": " / ".join(descriptions)}
|
|
118
|
+
return responses
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _problem_wire_members(model: Any) -> list[type[BaseModel]] | None:
|
|
122
|
+
"""Return the problem wire models behind a response ``model``, or ``None``.
|
|
123
|
+
|
|
124
|
+
Handles both a single wire model and a ``Union`` of them. Returns ``None`` for
|
|
125
|
+
anything that isn't (entirely) our wire models, so user responses are untouched.
|
|
126
|
+
"""
|
|
127
|
+
if model is None:
|
|
128
|
+
return None
|
|
129
|
+
is_union = get_origin(model) in (Union, types.UnionType)
|
|
130
|
+
members = list(get_args(model)) if is_union else [model]
|
|
131
|
+
if members and all(getattr(m, "__problem_source__", None) is not None for m in members):
|
|
132
|
+
return members
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def route_problem_types(app: FastAPI) -> list[type[Problem]]:
|
|
137
|
+
"""Return the problem types an app declares on its routes, in first-seen order.
|
|
138
|
+
|
|
139
|
+
Recovers the authored :class:`Problem` classes from each route's
|
|
140
|
+
``responses=problems(...)`` (the same recovery the OpenAPI wrap performs), so
|
|
141
|
+
callers never restate the type list. Deduplicated by class identity.
|
|
142
|
+
|
|
143
|
+
Parameters
|
|
144
|
+
----------
|
|
145
|
+
app : FastAPI
|
|
146
|
+
The application to inspect.
|
|
147
|
+
|
|
148
|
+
Returns
|
|
149
|
+
-------
|
|
150
|
+
list[type[Problem]]
|
|
151
|
+
Every problem type referenced by the app's route responses.
|
|
152
|
+
"""
|
|
153
|
+
seen: dict[type[Problem], None] = {}
|
|
154
|
+
for route in app.routes:
|
|
155
|
+
if not isinstance(route, APIRoute):
|
|
156
|
+
continue
|
|
157
|
+
for entry in route.responses.values():
|
|
158
|
+
members = _problem_wire_members(entry.get("model"))
|
|
159
|
+
if members is None:
|
|
160
|
+
continue
|
|
161
|
+
for member in members:
|
|
162
|
+
seen.setdefault(member.__problem_source__, None) # type: ignore[attr-defined]
|
|
163
|
+
return list(seen)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _ref_schema(
|
|
167
|
+
members: list[type[BaseModel]], seen_uris: dict[str, type[Problem]]
|
|
168
|
+
) -> dict[str, Any]:
|
|
169
|
+
"""Build the ``$ref``/``oneOf`` schema for a response, checking URI uniqueness."""
|
|
170
|
+
refs: list[dict[str, Any]] = []
|
|
171
|
+
for member in members:
|
|
172
|
+
source: type[Problem] = member.__problem_source__ # type: ignore[attr-defined]
|
|
173
|
+
uri = source.type or "about:blank"
|
|
174
|
+
existing = seen_uris.get(uri)
|
|
175
|
+
if existing is not None and existing is not source:
|
|
176
|
+
raise ValueError(
|
|
177
|
+
f"Duplicate problem type URI {uri!r}: {existing.__name__} and "
|
|
178
|
+
f"{source.__name__}. Give each Problem subclass a distinct `type`."
|
|
179
|
+
)
|
|
180
|
+
seen_uris[uri] = source
|
|
181
|
+
refs.append({"$ref": _REF_TEMPLATE.format(model=member.__name__)})
|
|
182
|
+
return refs[0] if len(refs) == 1 else {"oneOf": refs}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
_JSON_SAMPLES: dict[str | None, Any] = {
|
|
186
|
+
"string": "string",
|
|
187
|
+
"integer": 0,
|
|
188
|
+
"number": 0.0,
|
|
189
|
+
"boolean": True,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _sample_value(prop: dict[str, Any], defs: dict[str, Any]) -> Any:
|
|
194
|
+
"""Produce a representative sample value for one JSON-Schema property.
|
|
195
|
+
|
|
196
|
+
Pinned values win (``const`` then ``default``); otherwise a placeholder is
|
|
197
|
+
chosen by JSON type. ``$ref`` is resolved through ``defs`` and ``anyOf``/
|
|
198
|
+
``oneOf`` follow the first non-null branch, mirroring the concrete shape a
|
|
199
|
+
client would send.
|
|
200
|
+
|
|
201
|
+
Parameters
|
|
202
|
+
----------
|
|
203
|
+
prop : dict[str, Any]
|
|
204
|
+
A JSON-Schema property subschema.
|
|
205
|
+
defs : dict[str, Any]
|
|
206
|
+
The owning schema's ``$defs`` map, for resolving ``$ref``.
|
|
207
|
+
|
|
208
|
+
Returns
|
|
209
|
+
-------
|
|
210
|
+
Any
|
|
211
|
+
A JSON-serializable sample value.
|
|
212
|
+
"""
|
|
213
|
+
if "const" in prop:
|
|
214
|
+
return prop["const"]
|
|
215
|
+
if "default" in prop:
|
|
216
|
+
return prop["default"]
|
|
217
|
+
if "$ref" in prop:
|
|
218
|
+
return _sample_value(defs.get(prop["$ref"].rsplit("/", 1)[-1], {}), defs)
|
|
219
|
+
for branch_key in ("anyOf", "oneOf"):
|
|
220
|
+
branches = [b for b in prop.get(branch_key, ()) if b.get("type") != "null"]
|
|
221
|
+
if branches:
|
|
222
|
+
return _sample_value(branches[0], defs)
|
|
223
|
+
json_type = prop.get("type")
|
|
224
|
+
if json_type == "array":
|
|
225
|
+
items = prop.get("items")
|
|
226
|
+
return [_sample_value(items, defs)] if items else []
|
|
227
|
+
if json_type == "object":
|
|
228
|
+
return {n: _sample_value(s, defs) for n, s in prop.get("properties", {}).items()}
|
|
229
|
+
return _JSON_SAMPLES.get(json_type)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _example_for(model: type[BaseModel]) -> dict[str, Any]:
|
|
233
|
+
"""Build one representative example payload for a problem wire model.
|
|
234
|
+
|
|
235
|
+
Includes the identity members the type fixes (the ``type``/``status`` consts
|
|
236
|
+
and the ``title`` default) plus every required extension member, each filled
|
|
237
|
+
with a JSON-type-appropriate sample. Optional members (``detail``,
|
|
238
|
+
``instance``) are omitted — they carry no canonical per-type example value.
|
|
239
|
+
|
|
240
|
+
Parameters
|
|
241
|
+
----------
|
|
242
|
+
model : type[BaseModel]
|
|
243
|
+
A problem wire model (from :func:`_wire_model`).
|
|
244
|
+
|
|
245
|
+
Returns
|
|
246
|
+
-------
|
|
247
|
+
dict[str, Any]
|
|
248
|
+
A JSON object suitable as an OpenAPI example ``value``.
|
|
249
|
+
"""
|
|
250
|
+
schema = model.model_json_schema(ref_template=_REF_TEMPLATE)
|
|
251
|
+
defs = schema.get("$defs", {})
|
|
252
|
+
required = set(schema.get("required", ()))
|
|
253
|
+
example: dict[str, Any] = {}
|
|
254
|
+
for name, prop in schema.get("properties", {}).items():
|
|
255
|
+
if name in required or "const" in prop or prop.get("default") is not None:
|
|
256
|
+
example[name] = _sample_value(prop, defs)
|
|
257
|
+
return example
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _problem_examples(members: list[type[BaseModel]]) -> dict[str, Any]:
|
|
261
|
+
"""Build a named OpenAPI ``examples`` map, one entry per ``oneOf`` member.
|
|
262
|
+
|
|
263
|
+
Swagger UI synthesizes only a single example from a ``oneOf`` schema (the
|
|
264
|
+
first branch), hiding every sibling. Explicit named examples make it render
|
|
265
|
+
a dropdown with one labelled payload per problem type.
|
|
266
|
+
|
|
267
|
+
Parameters
|
|
268
|
+
----------
|
|
269
|
+
members : list[type[BaseModel]]
|
|
270
|
+
The problem wire models behind a shared-status response.
|
|
271
|
+
|
|
272
|
+
Returns
|
|
273
|
+
-------
|
|
274
|
+
dict[str, Any]
|
|
275
|
+
An OpenAPI ``examples`` map keyed by wire-model name.
|
|
276
|
+
"""
|
|
277
|
+
examples: dict[str, Any] = {}
|
|
278
|
+
for member in members:
|
|
279
|
+
source: type[Problem] = member.__problem_source__ # type: ignore[attr-defined]
|
|
280
|
+
examples[member.__name__] = {
|
|
281
|
+
"summary": f"{source.title} ({source.status})",
|
|
282
|
+
"value": _example_for(member),
|
|
283
|
+
}
|
|
284
|
+
return examples
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _ensure_component(components: dict[str, Any], model: type[BaseModel]) -> None:
|
|
288
|
+
"""Add ``model``'s JSON schema (lifting nested ``$defs``) if not already present."""
|
|
289
|
+
schema = model.model_json_schema(ref_template=_REF_TEMPLATE)
|
|
290
|
+
defs = schema.pop("$defs", {})
|
|
291
|
+
components.setdefault(model.__name__, schema)
|
|
292
|
+
for name, sub_schema in defs.items():
|
|
293
|
+
components.setdefault(name, sub_schema)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _rewrite_validation_responses(paths: dict[str, Any], components: dict[str, Any]) -> None:
|
|
297
|
+
"""Rewrite FastAPI's auto-generated 422 to ``application/problem+json``.
|
|
298
|
+
|
|
299
|
+
The runtime already emits ``ValidationProblem`` as ``application/problem+json``;
|
|
300
|
+
this aligns the *documentation* (RFC 9457 §3) for every route FastAPI gave a
|
|
301
|
+
default ``HTTPValidationError`` 422.
|
|
302
|
+
"""
|
|
303
|
+
rewrote = False
|
|
304
|
+
for path_item in paths.values():
|
|
305
|
+
if not isinstance(path_item, dict):
|
|
306
|
+
continue
|
|
307
|
+
for operation in path_item.values():
|
|
308
|
+
if not isinstance(operation, dict) or "responses" not in operation:
|
|
309
|
+
continue
|
|
310
|
+
response = operation["responses"].get("422")
|
|
311
|
+
if not isinstance(response, dict):
|
|
312
|
+
continue
|
|
313
|
+
schema = response.get("content", {}).get("application/json", {}).get("schema", {})
|
|
314
|
+
if schema.get("$ref") == _HTTP_VALIDATION_ERROR:
|
|
315
|
+
response["content"] = {
|
|
316
|
+
PROBLEM_MEDIA_TYPE: {
|
|
317
|
+
"schema": {"$ref": _REF_TEMPLATE.format(model="ValidationProblem")}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
rewrote = True
|
|
321
|
+
if rewrote:
|
|
322
|
+
_ensure_component(components, _wire_model(ValidationProblem))
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _prune_unreferenced(schema: dict[str, Any], names: tuple[str, ...]) -> None:
|
|
326
|
+
"""Drop named components no longer pointed at by any ``$ref`` (cleanup pass)."""
|
|
327
|
+
schemas = schema.get("components", {}).get("schemas", {})
|
|
328
|
+
for name in names:
|
|
329
|
+
# Re-serialize each pass: removing one component frees refs held only by it.
|
|
330
|
+
if f'"{_REF_TEMPLATE.format(model=name)}"' not in json.dumps(schema):
|
|
331
|
+
schemas.pop(name, None)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def register_problem_components(app: FastAPI) -> None:
|
|
335
|
+
"""Wrap ``app.openapi`` to normalize problem responses and components.
|
|
336
|
+
|
|
337
|
+
The FastAPI-endorsed *Extending OpenAPI* pattern: idempotent and lazy, so it
|
|
338
|
+
sees every route regardless of when it is called. It recovers the problem
|
|
339
|
+
types each route declares (from the response models), rewrites them to
|
|
340
|
+
``application/problem+json`` (``oneOf`` for a shared status), aligns the
|
|
341
|
+
auto-generated 422, and raises if two types share a ``type`` URI.
|
|
342
|
+
|
|
343
|
+
Parameters
|
|
344
|
+
----------
|
|
345
|
+
app : FastAPI
|
|
346
|
+
The application whose ``openapi`` callable is wrapped.
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
def custom_openapi() -> dict[str, Any]:
|
|
350
|
+
if app.openapi_schema:
|
|
351
|
+
return app.openapi_schema
|
|
352
|
+
schema = get_openapi(
|
|
353
|
+
title=app.title,
|
|
354
|
+
version=app.version,
|
|
355
|
+
openapi_version=app.openapi_version,
|
|
356
|
+
summary=app.summary,
|
|
357
|
+
description=app.description,
|
|
358
|
+
routes=app.routes,
|
|
359
|
+
webhooks=app.webhooks.routes,
|
|
360
|
+
tags=app.openapi_tags,
|
|
361
|
+
servers=app.servers,
|
|
362
|
+
terms_of_service=app.terms_of_service,
|
|
363
|
+
contact=app.contact,
|
|
364
|
+
license_info=app.license_info,
|
|
365
|
+
)
|
|
366
|
+
paths: dict[str, Any] = schema.get("paths", {})
|
|
367
|
+
components: dict[str, Any] = schema.setdefault("components", {}).setdefault("schemas", {})
|
|
368
|
+
|
|
369
|
+
seen_uris: dict[str, type[Problem]] = {}
|
|
370
|
+
for route in app.routes:
|
|
371
|
+
if not isinstance(route, APIRoute) or not route.methods:
|
|
372
|
+
continue
|
|
373
|
+
path_item = paths.get(route.path_format)
|
|
374
|
+
if path_item is None:
|
|
375
|
+
continue
|
|
376
|
+
for status, entry in route.responses.items():
|
|
377
|
+
members = _problem_wire_members(entry.get("model"))
|
|
378
|
+
if members is None:
|
|
379
|
+
continue
|
|
380
|
+
content_schema = _ref_schema(members, seen_uris)
|
|
381
|
+
media: dict[str, Any] = {"schema": content_schema}
|
|
382
|
+
if len(members) > 1:
|
|
383
|
+
# Swagger UI samples only the first oneOf branch; name an
|
|
384
|
+
# example per member so it offers a per-type dropdown.
|
|
385
|
+
media["examples"] = _problem_examples(members)
|
|
386
|
+
for method in route.methods:
|
|
387
|
+
operation = path_item.get(method.lower())
|
|
388
|
+
if not isinstance(operation, dict):
|
|
389
|
+
continue
|
|
390
|
+
response = operation.get("responses", {}).get(str(status))
|
|
391
|
+
if response is not None:
|
|
392
|
+
response["content"] = {PROBLEM_MEDIA_TYPE: media}
|
|
393
|
+
|
|
394
|
+
_rewrite_validation_responses(paths, components)
|
|
395
|
+
_ensure_component(components, ProblemDetail) # canonical open base shape
|
|
396
|
+
_prune_unreferenced(schema, ("HTTPValidationError", "ValidationError"))
|
|
397
|
+
|
|
398
|
+
app.openapi_schema = schema
|
|
399
|
+
return schema
|
|
400
|
+
|
|
401
|
+
app.openapi = custom_openapi # type: ignore[method-assign]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""The ``Problem`` authoring surface: a frozen Pydantic dataclass + ``Exception``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from typing import ClassVar, dataclass_transform, get_type_hints
|
|
9
|
+
|
|
10
|
+
import pydantic
|
|
11
|
+
|
|
12
|
+
from .models import ProblemDetail
|
|
13
|
+
|
|
14
|
+
_STANDARD_FIELDS = frozenset({"detail", "instance"})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _derive_type(name: str) -> str:
|
|
18
|
+
"""Derive a kebab-case type id from a class name.
|
|
19
|
+
|
|
20
|
+
A trailing ``Problem`` or ``Error`` is stripped, then the CamelCase name is
|
|
21
|
+
lower-kebab-cased. ``OutOfCredit`` -> ``"out-of-credit"``.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
name : str
|
|
26
|
+
The class ``__name__``.
|
|
27
|
+
|
|
28
|
+
Returns
|
|
29
|
+
-------
|
|
30
|
+
str
|
|
31
|
+
The derived relative type-URI reference (RFC 9457 §3.1.1 permits these).
|
|
32
|
+
|
|
33
|
+
Notes
|
|
34
|
+
-----
|
|
35
|
+
Derivation is lossy for acronym-heavy names (``HTTPError`` -> ``"http"``);
|
|
36
|
+
set an explicit ``type`` ClassVar when the derived id would be ambiguous.
|
|
37
|
+
"""
|
|
38
|
+
base = re.sub(r"(Problem|Error)$", "", name) or name
|
|
39
|
+
step = re.sub(r"(.)([A-Z][a-z]+)", r"\1-\2", base)
|
|
40
|
+
return re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", step).lower()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def extension_fields(cls: type) -> dict[str, type]:
|
|
44
|
+
"""Return the typed extension members of a problem class.
|
|
45
|
+
|
|
46
|
+
Annotations are resolved with :func:`typing.get_type_hints` so that string
|
|
47
|
+
annotations (PEP 563 / ``from __future__ import annotations``) come back as
|
|
48
|
+
real types rather than strings.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
cls : type
|
|
53
|
+
A ``Problem`` subclass.
|
|
54
|
+
|
|
55
|
+
Returns
|
|
56
|
+
-------
|
|
57
|
+
dict[str, type]
|
|
58
|
+
Field name -> resolved annotation, excluding the standard
|
|
59
|
+
``detail``/``instance`` members.
|
|
60
|
+
"""
|
|
61
|
+
hints = get_type_hints(cls)
|
|
62
|
+
return {
|
|
63
|
+
field.name: hints[field.name]
|
|
64
|
+
for field in dataclasses.fields(cls)
|
|
65
|
+
if field.name not in _STANDARD_FIELDS
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass_transform(kw_only_default=True, frozen_default=True)
|
|
70
|
+
class _ProblemMeta(type):
|
|
71
|
+
"""Metaclass that makes ``Problem`` and every subclass a frozen kw-only dataclass.
|
|
72
|
+
|
|
73
|
+
The ``@dataclass_transform`` decoration tells static checkers to treat each
|
|
74
|
+
subclass as a dataclass (so field declarations become kw-only, type-checked
|
|
75
|
+
constructor parameters); ``__new__`` applies the runtime transform.
|
|
76
|
+
|
|
77
|
+
The transform is configured ``extra="forbid"`` so an unknown constructor
|
|
78
|
+
keyword — a typo, or an attempt to pass a ``ClassVar`` constant such as
|
|
79
|
+
``status=404`` — raises rather than being silently dropped.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __new__(
|
|
83
|
+
mcs,
|
|
84
|
+
name: str,
|
|
85
|
+
bases: tuple[type, ...],
|
|
86
|
+
namespace: dict[str, object],
|
|
87
|
+
**kwargs: object,
|
|
88
|
+
):
|
|
89
|
+
cls = super().__new__(mcs, name, bases, namespace, **kwargs)
|
|
90
|
+
config = pydantic.ConfigDict(extra="forbid")
|
|
91
|
+
return pydantic.dataclasses.dataclass(config=config, kw_only=True, frozen=True)(cls)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Problem(Exception, metaclass=_ProblemMeta):
|
|
95
|
+
"""Base class for authored RFC 9457 problem types.
|
|
96
|
+
|
|
97
|
+
Subclasses declare ``title``/``status`` (and optionally ``type``) as plain
|
|
98
|
+
class attributes — they are ``ClassVar`` constants, not fields — and declare
|
|
99
|
+
extension members as annotated fields. Raise instances directly.
|
|
100
|
+
|
|
101
|
+
Notes
|
|
102
|
+
-----
|
|
103
|
+
No decorator is needed on subclasses: the metaclass carries the dataclass
|
|
104
|
+
machinery. Instances are frozen, so module-level constants are safe to reuse.
|
|
105
|
+
Unknown constructor keywords are rejected (``extra="forbid"``): passing a
|
|
106
|
+
``ClassVar`` constant like ``status=404`` raises rather than being ignored.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
title: ClassVar[str]
|
|
110
|
+
status: ClassVar[int]
|
|
111
|
+
type: ClassVar[str | None] = None
|
|
112
|
+
detail: str | None = None
|
|
113
|
+
instance: str | None = None
|
|
114
|
+
|
|
115
|
+
def __init_subclass__(cls, **kwargs) -> None:
|
|
116
|
+
super().__init_subclass__(**kwargs)
|
|
117
|
+
cls.type = cls.type if cls.type is not None else _derive_type(cls.__name__)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def iter_problem_types() -> Iterator[type[Problem]]:
|
|
121
|
+
"""Yield every defined :class:`Problem` subclass, transitively.
|
|
122
|
+
|
|
123
|
+
Walks ``Problem.__subclasses__()`` (Python's own weakly-held subclass list),
|
|
124
|
+
so no explicit registry is kept: a type is "known" exactly while it is a live
|
|
125
|
+
class. Used to map an incoming ``type`` URI back to its authored class and to
|
|
126
|
+
validate the type set at startup.
|
|
127
|
+
|
|
128
|
+
Yields
|
|
129
|
+
------
|
|
130
|
+
type[Problem]
|
|
131
|
+
Each distinct subclass, deduplicated.
|
|
132
|
+
"""
|
|
133
|
+
seen: set[type[Problem]] = set()
|
|
134
|
+
stack: list[type[Problem]] = list(Problem.__subclasses__())
|
|
135
|
+
while stack:
|
|
136
|
+
cls = stack.pop()
|
|
137
|
+
if cls in seen:
|
|
138
|
+
continue
|
|
139
|
+
seen.add(cls)
|
|
140
|
+
yield cls
|
|
141
|
+
stack.extend(cls.__subclasses__())
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ProblemError(Exception):
|
|
145
|
+
"""Client-side fallback for a problem response whose ``type`` isn't registered.
|
|
146
|
+
|
|
147
|
+
Parameters
|
|
148
|
+
----------
|
|
149
|
+
problem : ProblemDetail
|
|
150
|
+
The parsed wire model.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def __init__(self, problem: ProblemDetail) -> None:
|
|
154
|
+
self.problem = problem
|
|
155
|
+
super().__init__(f"{problem.status} {problem.title}")
|
fastapi_rfc9457/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-rfc9457
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed, batteries-included RFC 9457 Problem Details for FastAPI & Pydantic v2.
|
|
5
|
+
Author: Fabian Zills
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: fastapi>=0.137.2
|
|
9
|
+
Requires-Dist: pydantic>=2.13.4
|
|
10
|
+
Provides-Extra: client
|
|
11
|
+
Requires-Dist: httpx>=0.27; extra == 'client'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# fastapi-rfc9457
|
|
15
|
+
|
|
16
|
+
Typed, batteries-included [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html)
|
|
17
|
+
"Problem Details for HTTP APIs" for FastAPI & Pydantic.
|
|
18
|
+
|
|
19
|
+
Define an error once - it serializes as `application/problem+json`, documents
|
|
20
|
+
itself in OpenAPI, and parses back into a typed exception on the client.
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from fastapi import FastAPI
|
|
24
|
+
from fastapi_rfc9457 import Problem, add_problem_handlers, get_problem_docs_router, problems
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OutOfCredit(Problem):
|
|
28
|
+
"""The account does not have enough credit."""
|
|
29
|
+
type = "/problems/out-of-credit"
|
|
30
|
+
title = "Out of Credit"
|
|
31
|
+
status = 403
|
|
32
|
+
balance: int # typed extension members, checked at the raise site
|
|
33
|
+
accounts: list[str]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AccountSuspended(Problem):
|
|
37
|
+
"""The account is suspended and cannot be charged."""
|
|
38
|
+
type = "/problems/account-suspended"
|
|
39
|
+
title = "Account Suspended"
|
|
40
|
+
status = 403
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
app = FastAPI()
|
|
44
|
+
add_problem_handlers(app) # handlers + problem+json OpenAPI
|
|
45
|
+
app.include_router(get_problem_docs_router(), prefix="/problems") # dereferenceable type URIs
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.get("/charge", responses=problems(OutOfCredit, AccountSuspended))
|
|
49
|
+
async def charge() -> dict:
|
|
50
|
+
raise OutOfCredit(detail="Not enough credit.", balance=30, accounts=["/acct/12"])
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Accurate OpenAPI, for free
|
|
54
|
+
|
|
55
|
+
One route can declare several failure modes. Distinct statuses get their own
|
|
56
|
+
response; same-status problems become a `oneOf` union you flip through in
|
|
57
|
+
Swagger's **Examples** dropdown — all under `application/problem+json`.
|
|
58
|
+
|
|
59
|
+

|
|
60
|
+
|
|
61
|
+
## Dereferenceable `type` URIs
|
|
62
|
+
|
|
63
|
+
Mount the docs router and every problem `type` resolves to a live page listing
|
|
64
|
+
its typed extension members.
|
|
65
|
+
|
|
66
|
+

|
|
67
|
+
|
|
68
|
+
## Features
|
|
69
|
+
|
|
70
|
+
- **Typed + validated extension members** that round-trip through serialization, OpenAPI, and the client.
|
|
71
|
+
- **Accurate per-route OpenAPI** under `application/problem+json`.
|
|
72
|
+
- **Structured 422** that preserves field + list-index mapping (no flattening).
|
|
73
|
+
- **Client-side parsing** back into typed problems.
|
|
74
|
+
|
|
75
|
+
## Install
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
uv add fastapi-rfc9457 # add fastapi-rfc9457[client] for the httpx hook
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Example
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
cd example && uv run uvicorn main:app --reload # then open localhost:8000/docs
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
See [`example/`](./example) for the full runnable app, and
|
|
88
|
+
[`example/client.py`](./example/client.py) for the httpx hook (`fastapi-rfc9457[client]`)
|
|
89
|
+
that raises those problems back as typed exceptions on the consumer side.
|
|
90
|
+
|
|
91
|
+
## Notes
|
|
92
|
+
|
|
93
|
+
- Replaces FastAPI's default 422 body with `application/problem+json`.
|
|
94
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
fastapi_rfc9457/__init__.py,sha256=hKww54qZTUAzn4aw_zE5zXb9xqpPx-ntAGGXZZv4ja8,1348
|
|
2
|
+
fastapi_rfc9457/_version.py,sha256=n_5vdJsPNu7wZ57LGuRL585uvll-hiuvZUBWzdG0RQU,520
|
|
3
|
+
fastapi_rfc9457/builtins.py,sha256=0xXGj2UwjrzFehXZ3W1v9JUAUFihr7MlK33G4A9-nQs,2487
|
|
4
|
+
fastapi_rfc9457/client.py,sha256=MBYaw_YUP9kYBs5DzZJJFJdJA8hZEPE45qkjhaBYtEw,2625
|
|
5
|
+
fastapi_rfc9457/docs.py,sha256=Nssizu67w57VOE_Og2rlaaAUX62nGFgRq2vTZLFK-ek,4679
|
|
6
|
+
fastapi_rfc9457/handlers.py,sha256=0QEBv8jnXv-puhaJaDjKE5yD9vi74qrCG3iXz0MaZDc,4795
|
|
7
|
+
fastapi_rfc9457/integration.py,sha256=XgsfLU_talKPk8ELGrn_jzOmg1LhGxVR_Diy4SR6U6A,2485
|
|
8
|
+
fastapi_rfc9457/models.py,sha256=Eow8kiCqjwBxF-An-GzJ-1jZDgdfNCW30e_4x97tZJo,1038
|
|
9
|
+
fastapi_rfc9457/openapi.py,sha256=B4GfNBLvUz1cIHVIxs8WZUpEpHOC56jUhpb_UNfDaZw,15731
|
|
10
|
+
fastapi_rfc9457/problem.py,sha256=y_W6pUQW_WCwSnpyiQnq_9qbCMWjKHnBFMlf9AP1wJY,5050
|
|
11
|
+
fastapi_rfc9457/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
fastapi_rfc9457-0.1.0.dist-info/METADATA,sha256=FUqQizujUvQycqQDMXoB0N3By1LYcWeTrEPZ5H7iC-A,3076
|
|
13
|
+
fastapi_rfc9457-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
14
|
+
fastapi_rfc9457-0.1.0.dist-info/RECORD,,
|