fastapi-canon 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_canon/__init__.py +22 -0
- fastapi_canon/error/__init__.py +11 -0
- fastapi_canon/error/contracts.py +71 -0
- fastapi_canon/error/error.py +273 -0
- fastapi_canon/error/handlers.py +277 -0
- fastapi_canon/error/openapi.py +437 -0
- fastapi_canon/error/problem.py +96 -0
- fastapi_canon/error/registry.py +273 -0
- fastapi_canon/error/rendering.py +91 -0
- fastapi_canon/error/types.py +73 -0
- fastapi_canon/error/validation.py +18 -0
- fastapi_canon/feature.py +395 -0
- fastapi_canon/py.typed +0 -0
- fastapi_canon-0.1.0.dist-info/METADATA +234 -0
- fastapi_canon-0.1.0.dist-info/RECORD +16 -0
- fastapi_canon-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Opinionated FastAPI feature composition with Dishka and Problem Details."""
|
|
2
|
+
|
|
3
|
+
from .error import Error, ErrorConfigurationError, ErrorRegistry, Problem
|
|
4
|
+
from .feature import (
|
|
5
|
+
Composition,
|
|
6
|
+
ErrorOptions,
|
|
7
|
+
ExceptionHandlerSpec,
|
|
8
|
+
Feature,
|
|
9
|
+
FeatureConfigurationError,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Composition",
|
|
14
|
+
"Error",
|
|
15
|
+
"ErrorConfigurationError",
|
|
16
|
+
"ErrorOptions",
|
|
17
|
+
"ErrorRegistry",
|
|
18
|
+
"ExceptionHandlerSpec",
|
|
19
|
+
"Feature",
|
|
20
|
+
"FeatureConfigurationError",
|
|
21
|
+
"Problem",
|
|
22
|
+
]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
4
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter
|
|
7
|
+
from fastapi.routing import APIRoute
|
|
8
|
+
from starlette.routing import BaseRoute
|
|
9
|
+
|
|
10
|
+
from fastapi_canon.error.types import ErrorConfigurationError
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from fastapi_canon.error.registry import AnyError, ErrorRegistry
|
|
14
|
+
|
|
15
|
+
ERRORS_EXTENSION = "x-fastapi-canon-errors"
|
|
16
|
+
INSTALLED_REGISTRY_STATE_KEY = "_fastapi_canon_error_registry"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@runtime_checkable
|
|
20
|
+
class _IncludedRouterRoute(Protocol):
|
|
21
|
+
original_router: APIRouter
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def iter_http_contracts(
|
|
25
|
+
router: APIRouter, registry: ErrorRegistry
|
|
26
|
+
) -> Iterator[tuple[APIRoute, tuple[AnyError, ...]]]:
|
|
27
|
+
"""Yield standard FastAPI routes and errors declared through responses=."""
|
|
28
|
+
yield from _walk_http_contracts(router.routes, registry)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _walk_http_contracts(
|
|
32
|
+
routes: Sequence[BaseRoute], registry: ErrorRegistry
|
|
33
|
+
) -> Iterator[tuple[APIRoute, tuple[AnyError, ...]]]:
|
|
34
|
+
for route in routes:
|
|
35
|
+
if isinstance(route, _IncludedRouterRoute):
|
|
36
|
+
yield from _walk_http_contracts(route.original_router.routes, registry)
|
|
37
|
+
continue
|
|
38
|
+
if isinstance(route, APIRoute):
|
|
39
|
+
yield route, _errors_from_responses(route, registry)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _errors_from_responses(
|
|
43
|
+
route: APIRoute, registry: ErrorRegistry
|
|
44
|
+
) -> tuple[AnyError, ...]:
|
|
45
|
+
by_identity = {str(id(error)): error for error in registry.errors}
|
|
46
|
+
result: list[AnyError] = []
|
|
47
|
+
seen: set[int] = set()
|
|
48
|
+
|
|
49
|
+
for configured_response in route.responses.values():
|
|
50
|
+
response: object = configured_response
|
|
51
|
+
if not isinstance(response, Mapping):
|
|
52
|
+
continue
|
|
53
|
+
identities = response.get(ERRORS_EXTENSION, ())
|
|
54
|
+
if not isinstance(identities, list) or not all(
|
|
55
|
+
isinstance(identity, str) for identity in identities
|
|
56
|
+
):
|
|
57
|
+
msg = f"route {route.path!r} contains invalid fastapi-canon error metadata"
|
|
58
|
+
raise ErrorConfigurationError(msg)
|
|
59
|
+
for identity in identities:
|
|
60
|
+
error = by_identity.get(identity)
|
|
61
|
+
if error is None:
|
|
62
|
+
msg = (
|
|
63
|
+
f"route {route.path!r} declares an error whose exact definition "
|
|
64
|
+
"is missing from the installed registry"
|
|
65
|
+
)
|
|
66
|
+
raise ErrorConfigurationError(msg)
|
|
67
|
+
if id(error) not in seen:
|
|
68
|
+
seen.add(id(error))
|
|
69
|
+
result.append(error)
|
|
70
|
+
|
|
71
|
+
return tuple(result)
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import builtins
|
|
2
|
+
import math
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ValidationError
|
|
10
|
+
|
|
11
|
+
from fastapi_canon.error.types import (
|
|
12
|
+
CODE_PATTERN,
|
|
13
|
+
Detail,
|
|
14
|
+
ErrorConfigurationError,
|
|
15
|
+
Extensions,
|
|
16
|
+
Headers,
|
|
17
|
+
JsonValue,
|
|
18
|
+
OpenAPIHeader,
|
|
19
|
+
is_absolute_uri,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_RESERVED_MEMBERS = frozenset({"type", "title", "status", "detail", "instance", "code"})
|
|
23
|
+
_HEADER_NAME_PATTERN = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
|
|
24
|
+
_FORBIDDEN_RESPONSE_HEADERS = frozenset(
|
|
25
|
+
{"connection", "content-length", "content-type", "transfer-encoding"}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True, eq=False)
|
|
30
|
+
class Error[ExceptionT: Exception]:
|
|
31
|
+
"""An immutable error contract shared by runtime handling and OpenAPI."""
|
|
32
|
+
|
|
33
|
+
exception: builtins.type[ExceptionT]
|
|
34
|
+
status: int = field(kw_only=True)
|
|
35
|
+
code: str = field(kw_only=True)
|
|
36
|
+
title: str = field(kw_only=True)
|
|
37
|
+
type: str | None = field(default=None, kw_only=True)
|
|
38
|
+
detail: Detail[ExceptionT] = field(default=None, kw_only=True)
|
|
39
|
+
extensions_model: builtins.type[BaseModel] | None = field(
|
|
40
|
+
default=None, kw_only=True
|
|
41
|
+
)
|
|
42
|
+
extensions: Extensions[ExceptionT] = field(default=None, kw_only=True)
|
|
43
|
+
headers: Headers[ExceptionT] = field(default=None, kw_only=True)
|
|
44
|
+
openapi_headers: Mapping[str, OpenAPIHeader] | None = field(
|
|
45
|
+
default=None, kw_only=True
|
|
46
|
+
)
|
|
47
|
+
description: str | None = field(default=None, kw_only=True)
|
|
48
|
+
example: Mapping[str, JsonValue] | None = field(default=None, kw_only=True)
|
|
49
|
+
schema_name: str | None = field(default=None, kw_only=True)
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
"""Validate and detach all static inputs from caller-owned mutation."""
|
|
53
|
+
self._validate_identity()
|
|
54
|
+
self._validate_detail()
|
|
55
|
+
self._validate_extensions()
|
|
56
|
+
self._validate_headers()
|
|
57
|
+
self._validate_documentation()
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def effective_schema_name(self) -> str:
|
|
61
|
+
"""Return the explicit or deterministic OpenAPI component name."""
|
|
62
|
+
if self.schema_name is not None:
|
|
63
|
+
return self.schema_name
|
|
64
|
+
return "".join(part.capitalize() for part in self.code.split("_")) + "Problem"
|
|
65
|
+
|
|
66
|
+
def _validate_identity(self) -> None:
|
|
67
|
+
exception: object = self.exception
|
|
68
|
+
status: object = self.status
|
|
69
|
+
code: object = self.code
|
|
70
|
+
title: object = self.title
|
|
71
|
+
problem_type: object = self.type
|
|
72
|
+
|
|
73
|
+
if not isinstance(exception, builtins.type) or not issubclass(
|
|
74
|
+
exception, Exception
|
|
75
|
+
):
|
|
76
|
+
msg = "exception must be an Exception subclass"
|
|
77
|
+
raise ErrorConfigurationError(msg)
|
|
78
|
+
if (
|
|
79
|
+
not isinstance(status, int)
|
|
80
|
+
or isinstance(status, bool)
|
|
81
|
+
or not 400 <= status <= 599
|
|
82
|
+
):
|
|
83
|
+
msg = "status must be between 400 and 599"
|
|
84
|
+
raise ErrorConfigurationError(msg)
|
|
85
|
+
if not isinstance(code, str) or CODE_PATTERN.fullmatch(code) is None:
|
|
86
|
+
msg = "code must match ^[a-z][a-z0-9_]{2,}$"
|
|
87
|
+
raise ErrorConfigurationError(msg)
|
|
88
|
+
if (
|
|
89
|
+
not isinstance(title, str)
|
|
90
|
+
or not title.strip()
|
|
91
|
+
or "\r" in title
|
|
92
|
+
or "\n" in title
|
|
93
|
+
):
|
|
94
|
+
msg = "title must be a non-empty, single-line string"
|
|
95
|
+
raise ErrorConfigurationError(msg)
|
|
96
|
+
if problem_type is not None and (
|
|
97
|
+
not isinstance(problem_type, str) or not is_absolute_uri(problem_type)
|
|
98
|
+
):
|
|
99
|
+
msg = "type must be an absolute URI without a fragment"
|
|
100
|
+
raise ErrorConfigurationError(msg)
|
|
101
|
+
|
|
102
|
+
def _validate_detail(self) -> None:
|
|
103
|
+
detail: object = self.detail
|
|
104
|
+
if detail is not None and not isinstance(detail, str) and not callable(detail):
|
|
105
|
+
msg = "detail must be a string, callable, or None"
|
|
106
|
+
raise ErrorConfigurationError(msg)
|
|
107
|
+
|
|
108
|
+
def _validate_extensions(self) -> None:
|
|
109
|
+
model: object = self.extensions_model
|
|
110
|
+
extensions: object = self.extensions
|
|
111
|
+
|
|
112
|
+
if model is not None and (
|
|
113
|
+
not isinstance(model, builtins.type) or not issubclass(model, BaseModel)
|
|
114
|
+
):
|
|
115
|
+
msg = "extensions_model must be a Pydantic BaseModel subclass"
|
|
116
|
+
raise ErrorConfigurationError(msg)
|
|
117
|
+
if model is not None:
|
|
118
|
+
self._validate_extension_model(model)
|
|
119
|
+
|
|
120
|
+
if extensions is None:
|
|
121
|
+
if model is not None:
|
|
122
|
+
msg = "extensions is required when extensions_model is provided"
|
|
123
|
+
raise ErrorConfigurationError(msg)
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
if not isinstance(extensions, Mapping):
|
|
127
|
+
if not callable(extensions):
|
|
128
|
+
msg = "extensions must be a mapping, callable, or None"
|
|
129
|
+
raise ErrorConfigurationError(msg)
|
|
130
|
+
if model is None:
|
|
131
|
+
msg = "callable extensions require extensions_model"
|
|
132
|
+
raise ErrorConfigurationError(msg)
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
overlap = _RESERVED_MEMBERS.intersection(extensions)
|
|
136
|
+
if overlap:
|
|
137
|
+
names = ", ".join(sorted(overlap))
|
|
138
|
+
msg = f"extensions must not redefine reserved members: {names}"
|
|
139
|
+
raise ErrorConfigurationError(msg)
|
|
140
|
+
|
|
141
|
+
frozen_extensions = _freeze_json_mapping(extensions, path="extensions")
|
|
142
|
+
if model is not None:
|
|
143
|
+
try:
|
|
144
|
+
validated = model.model_validate(dict(frozen_extensions))
|
|
145
|
+
except ValidationError as error:
|
|
146
|
+
msg = "static extensions do not validate against extensions_model"
|
|
147
|
+
raise ErrorConfigurationError(msg) from error
|
|
148
|
+
serialized = cast(
|
|
149
|
+
dict[str, JsonValue],
|
|
150
|
+
validated.model_dump(mode="json", by_alias=True),
|
|
151
|
+
)
|
|
152
|
+
frozen_extensions = _freeze_json_mapping(serialized, path="extensions")
|
|
153
|
+
object.__setattr__(self, "extensions", frozen_extensions)
|
|
154
|
+
|
|
155
|
+
def _validate_extension_model(self, model: builtins.type[BaseModel]) -> None:
|
|
156
|
+
serialized_names = {
|
|
157
|
+
field.serialization_alias or field.alias or name
|
|
158
|
+
for name, field in model.model_fields.items()
|
|
159
|
+
}
|
|
160
|
+
overlap = _RESERVED_MEMBERS.intersection(serialized_names)
|
|
161
|
+
if overlap:
|
|
162
|
+
names = ", ".join(sorted(overlap))
|
|
163
|
+
msg = f"extensions_model must not define reserved members: {names}"
|
|
164
|
+
raise ErrorConfigurationError(msg)
|
|
165
|
+
|
|
166
|
+
def _validate_headers(self) -> None:
|
|
167
|
+
headers: object = self.headers
|
|
168
|
+
openapi_headers: object = self.openapi_headers
|
|
169
|
+
if (
|
|
170
|
+
headers is not None
|
|
171
|
+
and not isinstance(headers, Mapping)
|
|
172
|
+
and not callable(headers)
|
|
173
|
+
):
|
|
174
|
+
msg = "headers must be a mapping, callable, or None"
|
|
175
|
+
raise ErrorConfigurationError(msg)
|
|
176
|
+
if isinstance(headers, Mapping):
|
|
177
|
+
frozen_headers = freeze_headers(headers, path="headers")
|
|
178
|
+
object.__setattr__(self, "headers", frozen_headers)
|
|
179
|
+
|
|
180
|
+
if openapi_headers is not None:
|
|
181
|
+
if not isinstance(openapi_headers, Mapping):
|
|
182
|
+
msg = "openapi_headers must be a mapping or None"
|
|
183
|
+
raise ErrorConfigurationError(msg)
|
|
184
|
+
definitions: dict[str, OpenAPIHeader] = {}
|
|
185
|
+
for name, definition in openapi_headers.items():
|
|
186
|
+
header_name = _validate_header_name(name, path="openapi_headers")
|
|
187
|
+
if not isinstance(definition, Mapping):
|
|
188
|
+
msg = f"openapi_headers.{header_name} must be a mapping"
|
|
189
|
+
raise ErrorConfigurationError(msg)
|
|
190
|
+
definitions[header_name] = _freeze_json_mapping(
|
|
191
|
+
definition, path=f"openapi_headers.{header_name}"
|
|
192
|
+
)
|
|
193
|
+
object.__setattr__(self, "openapi_headers", MappingProxyType(definitions))
|
|
194
|
+
|
|
195
|
+
def _validate_documentation(self) -> None:
|
|
196
|
+
description: object = self.description
|
|
197
|
+
schema_name: object = self.schema_name
|
|
198
|
+
example: object = self.example
|
|
199
|
+
if description is not None and not isinstance(description, str):
|
|
200
|
+
msg = "description must be a string or None"
|
|
201
|
+
raise ErrorConfigurationError(msg)
|
|
202
|
+
if schema_name is not None and (
|
|
203
|
+
not isinstance(schema_name, str) or not schema_name.strip()
|
|
204
|
+
):
|
|
205
|
+
msg = "schema_name must be a non-empty string or None"
|
|
206
|
+
raise ErrorConfigurationError(msg)
|
|
207
|
+
if example is not None:
|
|
208
|
+
if not isinstance(example, Mapping):
|
|
209
|
+
msg = "example must be a mapping or None"
|
|
210
|
+
raise ErrorConfigurationError(msg)
|
|
211
|
+
object.__setattr__(
|
|
212
|
+
self,
|
|
213
|
+
"example",
|
|
214
|
+
_freeze_json_mapping(example, path="example"),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _freeze_json_mapping[KeyT, ValueT](
|
|
219
|
+
value: Mapping[KeyT, ValueT], *, path: str
|
|
220
|
+
) -> Mapping[str, JsonValue]:
|
|
221
|
+
frozen: dict[str, JsonValue] = {}
|
|
222
|
+
for key, item in value.items():
|
|
223
|
+
if not isinstance(key, str):
|
|
224
|
+
msg = f"{path} keys must be strings"
|
|
225
|
+
raise ErrorConfigurationError(msg)
|
|
226
|
+
frozen[key] = _freeze_json(item, path=f"{path}.{key}")
|
|
227
|
+
return MappingProxyType(frozen)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _freeze_json(value: object, *, path: str) -> JsonValue:
|
|
231
|
+
if value is None or isinstance(value, bool | int | str):
|
|
232
|
+
return value
|
|
233
|
+
if isinstance(value, float):
|
|
234
|
+
if not math.isfinite(value):
|
|
235
|
+
msg = f"{path} must contain only finite JSON numbers"
|
|
236
|
+
raise ErrorConfigurationError(msg)
|
|
237
|
+
return value
|
|
238
|
+
if isinstance(value, list):
|
|
239
|
+
return cast(
|
|
240
|
+
JsonValue,
|
|
241
|
+
tuple(
|
|
242
|
+
_freeze_json(item, path=f"{path}[{index}]")
|
|
243
|
+
for index, item in enumerate(value)
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
if isinstance(value, Mapping):
|
|
247
|
+
return cast(JsonValue, _freeze_json_mapping(value, path=path))
|
|
248
|
+
msg = f"{path} contains a non-JSON value of type {type(value).__name__}"
|
|
249
|
+
raise ErrorConfigurationError(msg)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def freeze_headers[KeyT, ValueT](
|
|
253
|
+
value: Mapping[KeyT, ValueT], *, path: str
|
|
254
|
+
) -> Mapping[str, str]:
|
|
255
|
+
"""Validate response headers and return an immutable detached mapping."""
|
|
256
|
+
frozen: dict[str, str] = {}
|
|
257
|
+
for name, header_value in value.items():
|
|
258
|
+
header_name = _validate_header_name(name, path=path)
|
|
259
|
+
if not isinstance(header_value, str):
|
|
260
|
+
msg = f"{path}.{header_name} must be a string"
|
|
261
|
+
raise ErrorConfigurationError(msg)
|
|
262
|
+
frozen[header_name] = header_value
|
|
263
|
+
return MappingProxyType(frozen)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _validate_header_name(name: object, *, path: str) -> str:
|
|
267
|
+
if not isinstance(name, str) or _HEADER_NAME_PATTERN.fullmatch(name) is None:
|
|
268
|
+
msg = f"{path} contains an invalid HTTP header name"
|
|
269
|
+
raise ErrorConfigurationError(msg)
|
|
270
|
+
if name.lower() in _FORBIDDEN_RESPONSE_HEADERS:
|
|
271
|
+
msg = f"{path} must not define the managed response header {name!r}"
|
|
272
|
+
raise ErrorConfigurationError(msg)
|
|
273
|
+
return name
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from http import HTTPStatus
|
|
6
|
+
from typing import TYPE_CHECKING, cast
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI, Request
|
|
9
|
+
from fastapi.exception_handlers import (
|
|
10
|
+
http_exception_handler,
|
|
11
|
+
request_validation_exception_handler,
|
|
12
|
+
)
|
|
13
|
+
from fastapi.exceptions import RequestValidationError, ResponseValidationError
|
|
14
|
+
from fastapi.responses import JSONResponse
|
|
15
|
+
from starlette.exceptions import HTTPException
|
|
16
|
+
from starlette.responses import Response
|
|
17
|
+
from starlette.types import ExceptionHandler
|
|
18
|
+
|
|
19
|
+
from fastapi_canon.error.contracts import (
|
|
20
|
+
INSTALLED_REGISTRY_STATE_KEY,
|
|
21
|
+
iter_http_contracts,
|
|
22
|
+
)
|
|
23
|
+
from fastapi_canon.error.openapi import install_openapi
|
|
24
|
+
from fastapi_canon.error.rendering import render_problem
|
|
25
|
+
from fastapi_canon.error.types import ErrorConfigurationError, JsonValue
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from fastapi_canon.error.registry import ErrorRegistry
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def install_handlers(
|
|
34
|
+
registry: ErrorRegistry,
|
|
35
|
+
app: FastAPI,
|
|
36
|
+
*,
|
|
37
|
+
include_validation_error: bool,
|
|
38
|
+
include_http_exceptions: bool,
|
|
39
|
+
include_unhandled_error: bool,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Install handlers once while preserving user-owned handler conflicts."""
|
|
42
|
+
installed = getattr(app.state, INSTALLED_REGISTRY_STATE_KEY, None)
|
|
43
|
+
if installed is registry:
|
|
44
|
+
return
|
|
45
|
+
if installed is not None:
|
|
46
|
+
msg = "a different ErrorRegistry is already installed on this application"
|
|
47
|
+
raise ErrorConfigurationError(msg)
|
|
48
|
+
if app.openapi_schema is not None:
|
|
49
|
+
msg = "install ErrorRegistry before generating or caching OpenAPI"
|
|
50
|
+
raise ErrorConfigurationError(msg)
|
|
51
|
+
|
|
52
|
+
registry.require_resolved()
|
|
53
|
+
_validate_route_contracts(registry, app)
|
|
54
|
+
if (
|
|
55
|
+
include_validation_error or include_unhandled_error
|
|
56
|
+
) and registry.type_base is None:
|
|
57
|
+
msg = (
|
|
58
|
+
"type_base is required when validation or unhandled-error normalization "
|
|
59
|
+
"is enabled"
|
|
60
|
+
)
|
|
61
|
+
raise ErrorConfigurationError(msg)
|
|
62
|
+
|
|
63
|
+
domain_classes = {
|
|
64
|
+
*(error.exception for error in registry.errors),
|
|
65
|
+
}
|
|
66
|
+
for exception_class in domain_classes:
|
|
67
|
+
_ensure_handler_available(app, exception_class)
|
|
68
|
+
|
|
69
|
+
domain_handler = _domain_handler(registry)
|
|
70
|
+
for exception_class in domain_classes:
|
|
71
|
+
app.add_exception_handler(exception_class, domain_handler)
|
|
72
|
+
|
|
73
|
+
if include_http_exceptions:
|
|
74
|
+
_ensure_default_or_available(app, HTTPException, http_exception_handler)
|
|
75
|
+
app.add_exception_handler(HTTPException, _http_exception_handler)
|
|
76
|
+
if include_validation_error:
|
|
77
|
+
_ensure_default_or_available(
|
|
78
|
+
app, RequestValidationError, request_validation_exception_handler
|
|
79
|
+
)
|
|
80
|
+
app.add_exception_handler(
|
|
81
|
+
RequestValidationError, _request_validation_handler(registry)
|
|
82
|
+
)
|
|
83
|
+
_ensure_handler_available(app, ResponseValidationError)
|
|
84
|
+
app.add_exception_handler(
|
|
85
|
+
ResponseValidationError, _response_validation_handler(registry)
|
|
86
|
+
)
|
|
87
|
+
if include_unhandled_error:
|
|
88
|
+
_ensure_handler_available(app, Exception)
|
|
89
|
+
app.add_exception_handler(Exception, _unhandled_handler(registry))
|
|
90
|
+
|
|
91
|
+
install_openapi(
|
|
92
|
+
registry,
|
|
93
|
+
app,
|
|
94
|
+
include_validation_error=include_validation_error,
|
|
95
|
+
)
|
|
96
|
+
setattr(app.state, INSTALLED_REGISTRY_STATE_KEY, registry)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _validate_route_contracts(registry: ErrorRegistry, app: FastAPI) -> None:
|
|
100
|
+
for http_route, http_errors in iter_http_contracts(app.router, registry):
|
|
101
|
+
for http_error in http_errors:
|
|
102
|
+
if not registry.contains(http_error):
|
|
103
|
+
msg = (
|
|
104
|
+
f"route {http_route.path!r} declares error "
|
|
105
|
+
f"{http_error.code!r}, but the "
|
|
106
|
+
"installed registry does not contain that exact definition"
|
|
107
|
+
)
|
|
108
|
+
raise ErrorConfigurationError(msg)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _domain_handler(registry: ErrorRegistry) -> ExceptionHandler:
|
|
112
|
+
async def handler(connection: Request, exception: Exception) -> Response:
|
|
113
|
+
error = registry.resolve(exception)
|
|
114
|
+
if error is None:
|
|
115
|
+
raise exception
|
|
116
|
+
type_uri = registry.type_uri_for(error)
|
|
117
|
+
if type_uri is None:
|
|
118
|
+
msg = f"error {error.code!r} has no resolved problem type URI"
|
|
119
|
+
raise ErrorConfigurationError(msg)
|
|
120
|
+
try:
|
|
121
|
+
problem, headers = render_problem(error, exception, type_uri=type_uri)
|
|
122
|
+
except Exception:
|
|
123
|
+
logger.exception(
|
|
124
|
+
"Error rendering callback failed",
|
|
125
|
+
extra={
|
|
126
|
+
"code": error.code,
|
|
127
|
+
"exception_class": type(exception).__qualname__,
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
return _internal_error_response(registry)
|
|
131
|
+
return _problem_response(problem.as_dict(), error.status, headers=headers)
|
|
132
|
+
|
|
133
|
+
return cast(ExceptionHandler, handler)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def _http_exception_handler(request: Request, exception: Exception) -> Response:
|
|
137
|
+
del request
|
|
138
|
+
if not isinstance(exception, HTTPException):
|
|
139
|
+
raise exception
|
|
140
|
+
status = exception.status_code
|
|
141
|
+
try:
|
|
142
|
+
title = HTTPStatus(status).phrase
|
|
143
|
+
except ValueError:
|
|
144
|
+
title = "HTTP Error"
|
|
145
|
+
detail = exception.detail if isinstance(exception.detail, str) else None
|
|
146
|
+
payload: dict[str, JsonValue] = {
|
|
147
|
+
"type": "about:blank",
|
|
148
|
+
"title": title,
|
|
149
|
+
"status": status,
|
|
150
|
+
"code": f"http_{status}",
|
|
151
|
+
}
|
|
152
|
+
if detail is not None:
|
|
153
|
+
payload["detail"] = detail
|
|
154
|
+
return _problem_response(payload, status, headers=exception.headers)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _request_validation_handler(registry: ErrorRegistry) -> ExceptionHandler:
|
|
158
|
+
async def handler(request: Request, exception: Exception) -> Response:
|
|
159
|
+
del request
|
|
160
|
+
if not isinstance(exception, RequestValidationError):
|
|
161
|
+
raise exception
|
|
162
|
+
errors: list[JsonValue] = []
|
|
163
|
+
for error in exception.errors():
|
|
164
|
+
errors.append(_validation_error(error))
|
|
165
|
+
payload: dict[str, JsonValue] = {
|
|
166
|
+
"type": _builtin_type(registry, "request_validation_error"),
|
|
167
|
+
"title": "Request validation failed",
|
|
168
|
+
"status": 422,
|
|
169
|
+
"code": "request_validation_error",
|
|
170
|
+
"errors": errors,
|
|
171
|
+
}
|
|
172
|
+
return _problem_response(payload, 422)
|
|
173
|
+
|
|
174
|
+
return cast(ExceptionHandler, handler)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _response_validation_handler(registry: ErrorRegistry) -> ExceptionHandler:
|
|
178
|
+
async def handler(request: Request, exception: Exception) -> Response:
|
|
179
|
+
del request
|
|
180
|
+
if not isinstance(exception, ResponseValidationError):
|
|
181
|
+
raise exception
|
|
182
|
+
logger.error(
|
|
183
|
+
"FastAPI response validation failed",
|
|
184
|
+
exc_info=(type(exception), exception, exception.__traceback__),
|
|
185
|
+
)
|
|
186
|
+
return _internal_error_response(registry)
|
|
187
|
+
|
|
188
|
+
return cast(ExceptionHandler, handler)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _unhandled_handler(registry: ErrorRegistry) -> ExceptionHandler:
|
|
192
|
+
async def handler(request: Request, exception: Exception) -> Response:
|
|
193
|
+
del request
|
|
194
|
+
logger.error(
|
|
195
|
+
"Unhandled application exception",
|
|
196
|
+
exc_info=(type(exception), exception, exception.__traceback__),
|
|
197
|
+
)
|
|
198
|
+
return _internal_error_response(registry)
|
|
199
|
+
|
|
200
|
+
return cast(ExceptionHandler, handler)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _validation_error(error: dict[str, object]) -> JsonValue:
|
|
204
|
+
location = error.get("loc")
|
|
205
|
+
parts = tuple(location) if isinstance(location, tuple | list) else ()
|
|
206
|
+
source = parts[0] if parts else None
|
|
207
|
+
result: dict[str, JsonValue] = {
|
|
208
|
+
"code": str(error.get("type", "validation_error")),
|
|
209
|
+
"detail": str(error.get("msg", "Invalid input")),
|
|
210
|
+
}
|
|
211
|
+
if source == "body":
|
|
212
|
+
pointer = "#/" + "/".join(_escape_pointer(part) for part in parts[1:])
|
|
213
|
+
result["pointer"] = pointer.rstrip("/") or "#"
|
|
214
|
+
elif source in {"path", "query", "header", "cookie"} and len(parts) > 1:
|
|
215
|
+
result["parameter"] = str(parts[-1])
|
|
216
|
+
result["in"] = cast(str, source)
|
|
217
|
+
return result
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _escape_pointer(value: object) -> str:
|
|
221
|
+
return str(value).replace("~", "~0").replace("/", "~1")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _internal_error_response(registry: ErrorRegistry) -> Response:
|
|
225
|
+
return _problem_response(
|
|
226
|
+
{
|
|
227
|
+
"type": _builtin_type(registry, "internal_server_error"),
|
|
228
|
+
"title": "Internal Server Error",
|
|
229
|
+
"status": 500,
|
|
230
|
+
"code": "internal_server_error",
|
|
231
|
+
},
|
|
232
|
+
500,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _builtin_type(registry: ErrorRegistry, code: str) -> str:
|
|
237
|
+
if registry.type_base is None:
|
|
238
|
+
msg = f"type_base is required to render built-in problem {code!r}"
|
|
239
|
+
raise ErrorConfigurationError(msg)
|
|
240
|
+
return f"{registry.type_base}/{code}"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _problem_response(
|
|
244
|
+
payload: dict[str, JsonValue],
|
|
245
|
+
status: int,
|
|
246
|
+
*,
|
|
247
|
+
headers: Mapping[str, str] | None = None,
|
|
248
|
+
) -> Response:
|
|
249
|
+
return JSONResponse(
|
|
250
|
+
payload,
|
|
251
|
+
status_code=status,
|
|
252
|
+
headers=headers,
|
|
253
|
+
media_type="application/problem+json",
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _ensure_handler_available(app: FastAPI, exception_class: type[Exception]) -> None:
|
|
258
|
+
if exception_class in app.exception_handlers:
|
|
259
|
+
msg = (
|
|
260
|
+
f"application already defines an exception handler for "
|
|
261
|
+
f"{exception_class.__qualname__}"
|
|
262
|
+
)
|
|
263
|
+
raise ErrorConfigurationError(msg)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _ensure_default_or_available(
|
|
267
|
+
app: FastAPI,
|
|
268
|
+
exception_class: type[Exception],
|
|
269
|
+
known_default: object,
|
|
270
|
+
) -> None:
|
|
271
|
+
existing = app.exception_handlers.get(exception_class)
|
|
272
|
+
if existing is not None and existing is not known_default:
|
|
273
|
+
msg = (
|
|
274
|
+
f"application already defines an exception handler for "
|
|
275
|
+
f"{exception_class.__qualname__}"
|
|
276
|
+
)
|
|
277
|
+
raise ErrorConfigurationError(msg)
|