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.
@@ -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
@@ -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}")
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
+ ![Swagger error responses with a problem+json examples dropdown](docs/img/swagger-errors.png)
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
+ ![Problem type documentation page](docs/img/doc-page.png)
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any