fastapi-rfc9457 0.1.0__tar.gz
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-0.1.0/.gitignore +13 -0
- fastapi_rfc9457-0.1.0/PKG-INFO +94 -0
- fastapi_rfc9457-0.1.0/README.md +81 -0
- fastapi_rfc9457-0.1.0/example/README.md +52 -0
- fastapi_rfc9457-0.1.0/pyproject.toml +66 -0
- fastapi_rfc9457-0.1.0/src/fastapi_rfc9457/__init__.py +54 -0
- fastapi_rfc9457-0.1.0/src/fastapi_rfc9457/_version.py +24 -0
- fastapi_rfc9457-0.1.0/src/fastapi_rfc9457/builtins.py +101 -0
- fastapi_rfc9457-0.1.0/src/fastapi_rfc9457/client.py +82 -0
- fastapi_rfc9457-0.1.0/src/fastapi_rfc9457/docs.py +144 -0
- fastapi_rfc9457-0.1.0/src/fastapi_rfc9457/handlers.py +138 -0
- fastapi_rfc9457-0.1.0/src/fastapi_rfc9457/integration.py +76 -0
- fastapi_rfc9457-0.1.0/src/fastapi_rfc9457/models.py +36 -0
- fastapi_rfc9457-0.1.0/src/fastapi_rfc9457/openapi.py +401 -0
- fastapi_rfc9457-0.1.0/src/fastapi_rfc9457/problem.py +155 -0
- fastapi_rfc9457-0.1.0/src/fastapi_rfc9457/py.typed +0 -0
- fastapi_rfc9457-0.1.0/tests/conftest.py +1 -0
- fastapi_rfc9457-0.1.0/tests/static/expected_errors.py +23 -0
- fastapi_rfc9457-0.1.0/tests/static/valid_usage.py +19 -0
- fastapi_rfc9457-0.1.0/tests/test_builtins.py +49 -0
- fastapi_rfc9457-0.1.0/tests/test_client.py +79 -0
- fastapi_rfc9457-0.1.0/tests/test_docs.py +100 -0
- fastapi_rfc9457-0.1.0/tests/test_example.py +77 -0
- fastapi_rfc9457-0.1.0/tests/test_example_client.py +74 -0
- fastapi_rfc9457-0.1.0/tests/test_extension_fields_pep563.py +19 -0
- fastapi_rfc9457-0.1.0/tests/test_handlers.py +156 -0
- fastapi_rfc9457-0.1.0/tests/test_integration.py +63 -0
- fastapi_rfc9457-0.1.0/tests/test_lifespan.py +44 -0
- fastapi_rfc9457-0.1.0/tests/test_models.py +27 -0
- fastapi_rfc9457-0.1.0/tests/test_openapi.py +255 -0
- fastapi_rfc9457-0.1.0/tests/test_problem.py +128 -0
- fastapi_rfc9457-0.1.0/tests/test_public_api.py +34 -0
- fastapi_rfc9457-0.1.0/tests/test_static_typing.py +33 -0
|
@@ -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,81 @@
|
|
|
1
|
+
# fastapi-rfc9457
|
|
2
|
+
|
|
3
|
+
Typed, batteries-included [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html)
|
|
4
|
+
"Problem Details for HTTP APIs" for FastAPI & Pydantic.
|
|
5
|
+
|
|
6
|
+
Define an error once - it serializes as `application/problem+json`, documents
|
|
7
|
+
itself in OpenAPI, and parses back into a typed exception on the client.
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from fastapi import FastAPI
|
|
11
|
+
from fastapi_rfc9457 import Problem, add_problem_handlers, get_problem_docs_router, problems
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OutOfCredit(Problem):
|
|
15
|
+
"""The account does not have enough credit."""
|
|
16
|
+
type = "/problems/out-of-credit"
|
|
17
|
+
title = "Out of Credit"
|
|
18
|
+
status = 403
|
|
19
|
+
balance: int # typed extension members, checked at the raise site
|
|
20
|
+
accounts: list[str]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AccountSuspended(Problem):
|
|
24
|
+
"""The account is suspended and cannot be charged."""
|
|
25
|
+
type = "/problems/account-suspended"
|
|
26
|
+
title = "Account Suspended"
|
|
27
|
+
status = 403
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
app = FastAPI()
|
|
31
|
+
add_problem_handlers(app) # handlers + problem+json OpenAPI
|
|
32
|
+
app.include_router(get_problem_docs_router(), prefix="/problems") # dereferenceable type URIs
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.get("/charge", responses=problems(OutOfCredit, AccountSuspended))
|
|
36
|
+
async def charge() -> dict:
|
|
37
|
+
raise OutOfCredit(detail="Not enough credit.", balance=30, accounts=["/acct/12"])
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Accurate OpenAPI, for free
|
|
41
|
+
|
|
42
|
+
One route can declare several failure modes. Distinct statuses get their own
|
|
43
|
+
response; same-status problems become a `oneOf` union you flip through in
|
|
44
|
+
Swagger's **Examples** dropdown — all under `application/problem+json`.
|
|
45
|
+
|
|
46
|
+

|
|
47
|
+
|
|
48
|
+
## Dereferenceable `type` URIs
|
|
49
|
+
|
|
50
|
+
Mount the docs router and every problem `type` resolves to a live page listing
|
|
51
|
+
its typed extension members.
|
|
52
|
+
|
|
53
|
+

|
|
54
|
+
|
|
55
|
+
## Features
|
|
56
|
+
|
|
57
|
+
- **Typed + validated extension members** that round-trip through serialization, OpenAPI, and the client.
|
|
58
|
+
- **Accurate per-route OpenAPI** under `application/problem+json`.
|
|
59
|
+
- **Structured 422** that preserves field + list-index mapping (no flattening).
|
|
60
|
+
- **Client-side parsing** back into typed problems.
|
|
61
|
+
|
|
62
|
+
## Install
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
uv add fastapi-rfc9457 # add fastapi-rfc9457[client] for the httpx hook
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Example
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
cd example && uv run uvicorn main:app --reload # then open localhost:8000/docs
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
See [`example/`](./example) for the full runnable app, and
|
|
75
|
+
[`example/client.py`](./example/client.py) for the httpx hook (`fastapi-rfc9457[client]`)
|
|
76
|
+
that raises those problems back as typed exceptions on the consumer side.
|
|
77
|
+
|
|
78
|
+
## Notes
|
|
79
|
+
|
|
80
|
+
- Replaces FastAPI's default 422 body with `application/problem+json`.
|
|
81
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# fastapi-rfc9457 — example app
|
|
2
|
+
|
|
3
|
+
```bash
|
|
4
|
+
uv run uvicorn main:app --reload # open http://localhost:8000/docs
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
curl -i localhost:8000/posts/999 # 404 application/problem+json
|
|
9
|
+
curl -i "localhost:8000/search?limit=abc" # 422 errors[] with loc ["query","limit"]
|
|
10
|
+
curl -i localhost:8000/charge # 401 NotAuthenticated (no token)
|
|
11
|
+
curl -i "localhost:8000/charge?token=x" # 403 OutOfCredit + typed balance/accounts
|
|
12
|
+
curl -s localhost:8000/problems/out-of-credit # dereferenced type-doc page
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`/charge` declares three problems via `problems(...)`: a 401 and a same-status
|
|
16
|
+
403 `oneOf` union (`OutOfCredit` / `AccountSuspended`) — see them in `/docs`.
|
|
17
|
+
|
|
18
|
+
## Consuming it with the httpx hook
|
|
19
|
+
|
|
20
|
+
`fastapi-rfc9457[client]` ships an httpx event hook that turns every
|
|
21
|
+
`application/problem+json` reply back into the same typed exception the server
|
|
22
|
+
raised. [`client.py`](./client.py) is a runnable consumer of the server above.
|
|
23
|
+
|
|
24
|
+
With the demo server running (above), in another shell from this `example/`
|
|
25
|
+
directory:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uv add fastapi-rfc9457[client] # the httpx extra
|
|
29
|
+
uv run client.py # connects to http://localhost:8000
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
It prints:
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
GET /posts/1 -> {'id': '1', 'title': 'Hello'}
|
|
36
|
+
PostNotFound -> Post 999 not found
|
|
37
|
+
OutOfCredit -> balance=30, accounts=['/accounts/12']
|
|
38
|
+
NotAuthenticated -> Log in to charge this account.
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The hook is synchronous (`response.read()`), so it wires onto `httpx.Client`;
|
|
42
|
+
`httpx.AsyncClient` is not supported today.
|
|
43
|
+
|
|
44
|
+
## Notes
|
|
45
|
+
|
|
46
|
+
- This replaces FastAPI's default 422 `application/json` body with
|
|
47
|
+
`application/problem+json` carrying a structured `errors[]` extension member —
|
|
48
|
+
intended, since the goal is a uniform error surface.
|
|
49
|
+
- Under `app.debug=True`, Starlette renders a traceback page *instead of*
|
|
50
|
+
invoking the 500 handler, so the `problem+json` 500 path isn't exercised in
|
|
51
|
+
debug; Starlette also re-raises the unhandled exception after the handler runs
|
|
52
|
+
(for logging). Both are expected.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fastapi-rfc9457"
|
|
7
|
+
description = "Typed, batteries-included RFC 9457 Problem Details for FastAPI & Pydantic v2."
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dynamic = ["version"]
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Fabian Zills" }]
|
|
13
|
+
dependencies = ["fastapi>=0.137.2", "pydantic>=2.13.4"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
client = ["httpx>=0.27"]
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=8.0",
|
|
21
|
+
"pytest-asyncio>=0.24",
|
|
22
|
+
"httpx>=0.27",
|
|
23
|
+
"pyright>=1.1.390",
|
|
24
|
+
"ruff>=0.8",
|
|
25
|
+
"pre-commit>=4.0",
|
|
26
|
+
"uvicorn>=0.49.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.hatch.version]
|
|
30
|
+
source = "vcs"
|
|
31
|
+
fallback-version = "0.0.0-dev"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.hooks.vcs]
|
|
34
|
+
version-file = "src/fastapi_rfc9457/_version.py"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/fastapi_rfc9457"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.sdist]
|
|
40
|
+
include = ["src/", "tests/", "README.md", "pyproject.toml"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
asyncio_mode = "auto"
|
|
44
|
+
testpaths = ["tests"]
|
|
45
|
+
pythonpath = ["."]
|
|
46
|
+
filterwarnings = [
|
|
47
|
+
# Starlette's TestClient now prefers httpx2; the httpx warning is expected.
|
|
48
|
+
"ignore::DeprecationWarning",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[tool.ruff]
|
|
52
|
+
line-length = 100
|
|
53
|
+
src = ["src", "tests"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "C4", "PIE", "RUF"]
|
|
57
|
+
ignore = ["B008"] # FastAPI's Depends()/Query() defaults are a deliberate pattern
|
|
58
|
+
|
|
59
|
+
[tool.ruff.lint.per-file-ignores]
|
|
60
|
+
"tests/static/*" = ["F821", "F811", "B018", "E501"] # intentional type-error fixtures
|
|
61
|
+
|
|
62
|
+
[tool.pyright]
|
|
63
|
+
include = ["src", "tests/static/valid_usage.py"]
|
|
64
|
+
exclude = ["tests/static/expected_errors.py", "**/_version.py"]
|
|
65
|
+
pythonVersion = "3.11"
|
|
66
|
+
typeCheckingMode = "standard"
|
|
@@ -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
|