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.
@@ -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,11 @@
1
+ from .error import Error
2
+ from .problem import Problem
3
+ from .registry import ErrorRegistry
4
+ from .types import ErrorConfigurationError
5
+
6
+ __all__ = [
7
+ "Error",
8
+ "ErrorConfigurationError",
9
+ "ErrorRegistry",
10
+ "Problem",
11
+ ]
@@ -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)