fastapi-errors-plus 0.7.0__py3-none-any.whl → 0.8.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_errors_plus/__init__.py +9 -6
- fastapi_errors_plus/base.py +43 -57
- fastapi_errors_plus/error_doc.py +59 -0
- fastapi_errors_plus/errors.py +196 -105
- fastapi_errors_plus/example_utils.py +52 -0
- fastapi_errors_plus/protocol.py +26 -46
- {fastapi_errors_plus-0.7.0.dist-info → fastapi_errors_plus-0.8.0.dist-info}/METADATA +88 -24
- fastapi_errors_plus-0.8.0.dist-info/RECORD +12 -0
- {fastapi_errors_plus-0.7.0.dist-info → fastapi_errors_plus-0.8.0.dist-info}/WHEEL +1 -1
- fastapi_errors_plus-0.7.0.dist-info/RECORD +0 -10
- {fastapi_errors_plus-0.7.0.dist-info → fastapi_errors_plus-0.8.0.dist-info}/licenses/LICENSE +0 -0
- {fastapi_errors_plus-0.7.0.dist-info → fastapi_errors_plus-0.8.0.dist-info}/top_level.txt +0 -0
fastapi_errors_plus/__init__.py
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
"""Universal library for documenting errors in FastAPI endpoints."""
|
|
2
2
|
|
|
3
3
|
from fastapi_errors_plus.base import BaseErrorDTO, StandardErrorDTO
|
|
4
|
+
from fastapi_errors_plus.error_doc import ErrorDoc
|
|
4
5
|
from fastapi_errors_plus.errors import Errors
|
|
5
|
-
from fastapi_errors_plus.protocol import ErrorDTO
|
|
6
|
+
from fastapi_errors_plus.protocol import ErrorDTO, ErrorDTOLike, LegacyErrorDTO
|
|
6
7
|
|
|
7
8
|
__all__ = [
|
|
8
9
|
"Errors",
|
|
9
|
-
"ErrorDTO",
|
|
10
|
-
"
|
|
11
|
-
"
|
|
10
|
+
"ErrorDTO",
|
|
11
|
+
"LegacyErrorDTO",
|
|
12
|
+
"ErrorDTOLike",
|
|
13
|
+
"ErrorDoc",
|
|
14
|
+
"BaseErrorDTO",
|
|
15
|
+
"StandardErrorDTO",
|
|
12
16
|
]
|
|
13
|
-
__version__ = "0.
|
|
14
|
-
|
|
17
|
+
__version__ = "0.8.0"
|
fastapi_errors_plus/base.py
CHANGED
|
@@ -1,28 +1,31 @@
|
|
|
1
1
|
"""Base implementations of ErrorDTO protocol for convenience."""
|
|
2
2
|
|
|
3
|
+
import warnings
|
|
3
4
|
from dataclasses import dataclass, field
|
|
4
5
|
from typing import Any, Dict, Optional
|
|
5
6
|
|
|
7
|
+
from fastapi_errors_plus.example_utils import ExampleSpec, _normalize_example_specs
|
|
8
|
+
|
|
6
9
|
|
|
7
10
|
@dataclass
|
|
8
11
|
class BaseErrorDTO:
|
|
9
12
|
"""Base implementation of ErrorDTO Protocol.
|
|
10
|
-
|
|
13
|
+
|
|
11
14
|
Projects can use this class directly or create their own implementation
|
|
12
15
|
that implements ErrorDTO Protocol through structural typing.
|
|
13
|
-
|
|
16
|
+
|
|
14
17
|
This class provides a simple way to create error DTOs without writing
|
|
15
18
|
boilerplate code in every project.
|
|
16
|
-
|
|
19
|
+
|
|
17
20
|
Example:
|
|
18
21
|
```python
|
|
19
22
|
from fastapi_errors_plus import Errors, BaseErrorDTO
|
|
20
|
-
|
|
23
|
+
|
|
21
24
|
notification_error = BaseErrorDTO(
|
|
22
25
|
status_code=404,
|
|
23
26
|
message="Notification not found",
|
|
24
27
|
)
|
|
25
|
-
|
|
28
|
+
|
|
26
29
|
@router.delete(
|
|
27
30
|
"/{id}",
|
|
28
31
|
responses=Errors(notification_error),
|
|
@@ -31,51 +34,48 @@ class BaseErrorDTO:
|
|
|
31
34
|
pass
|
|
32
35
|
```
|
|
33
36
|
"""
|
|
37
|
+
|
|
34
38
|
status_code: int
|
|
35
39
|
"""HTTP status code for the error."""
|
|
36
|
-
|
|
40
|
+
|
|
37
41
|
message: str
|
|
38
42
|
"""Error message description."""
|
|
39
|
-
|
|
43
|
+
|
|
40
44
|
openapi_json_extras: Optional[Dict[str, Any]] = field(default=None)
|
|
41
45
|
"""Optional OpenAPI fragment under ``content['application/json']`` besides examples,
|
|
42
46
|
typically ``{\"schema\": ...}`` or ``encoding``. Do **not** put ``example`` / ``examples``
|
|
43
47
|
here — use :meth:`to_example`. Arbitrary implementations may instead implement
|
|
44
48
|
:meth:`to_openapi_json_media_type_extras`; that return value wins over this attribute."""
|
|
45
|
-
|
|
46
|
-
def
|
|
47
|
-
"""Generate
|
|
48
|
-
|
|
49
|
-
Returns:
|
|
50
|
-
Dict in format: {"key": {"value": {"detail": "message"}}}
|
|
51
|
-
|
|
52
|
-
Example:
|
|
53
|
-
```python
|
|
54
|
-
{
|
|
55
|
-
"Notification not found": {
|
|
56
|
-
"value": {"detail": "Notification not found"},
|
|
57
|
-
},
|
|
58
|
-
}
|
|
59
|
-
```
|
|
60
|
-
"""
|
|
49
|
+
|
|
50
|
+
def to_examples(self) -> Dict[str, Any]:
|
|
51
|
+
"""Generate OpenAPI ``examples`` for this error."""
|
|
61
52
|
return {
|
|
62
53
|
self.message: {
|
|
63
54
|
"value": {"detail": self.message},
|
|
64
55
|
},
|
|
65
56
|
}
|
|
66
57
|
|
|
58
|
+
def to_example(self) -> Dict[str, Any]:
|
|
59
|
+
"""Deprecated alias for :meth:`to_examples`."""
|
|
60
|
+
warnings.warn(
|
|
61
|
+
"to_example() is deprecated; use to_examples() instead.",
|
|
62
|
+
DeprecationWarning,
|
|
63
|
+
stacklevel=2,
|
|
64
|
+
)
|
|
65
|
+
return self.to_examples()
|
|
66
|
+
|
|
67
67
|
|
|
68
68
|
@dataclass
|
|
69
69
|
class StandardErrorDTO(BaseErrorDTO):
|
|
70
70
|
"""Extended implementation for errors with multiple examples.
|
|
71
|
-
|
|
71
|
+
|
|
72
72
|
Useful for standard HTTP errors (401, 403) that can have different
|
|
73
73
|
causes with different messages.
|
|
74
|
-
|
|
74
|
+
|
|
75
75
|
Example:
|
|
76
76
|
```python
|
|
77
77
|
from fastapi_errors_plus import Errors, StandardErrorDTO
|
|
78
|
-
|
|
78
|
+
|
|
79
79
|
unauthorized_error = StandardErrorDTO(
|
|
80
80
|
status_code=401,
|
|
81
81
|
message="Unauthorized",
|
|
@@ -84,7 +84,7 @@ class StandardErrorDTO(BaseErrorDTO):
|
|
|
84
84
|
"SessionNotFound": "Сессия пользователя не была найдена.",
|
|
85
85
|
},
|
|
86
86
|
)
|
|
87
|
-
|
|
87
|
+
|
|
88
88
|
@router.delete(
|
|
89
89
|
"/{id}",
|
|
90
90
|
responses=Errors(unauthorized_error),
|
|
@@ -93,39 +93,25 @@ class StandardErrorDTO(BaseErrorDTO):
|
|
|
93
93
|
pass
|
|
94
94
|
```
|
|
95
95
|
"""
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
|
|
97
|
+
examples: Optional[Dict[str, ExampleSpec]] = field(default=None)
|
|
98
|
+
"""Examples: ``str`` detail text or full OpenAPI example object (with ``summary``)."""
|
|
99
|
+
|
|
99
100
|
def __post_init__(self) -> None:
|
|
100
101
|
"""Initialize examples with default value if not provided."""
|
|
101
102
|
if self.examples is None:
|
|
102
103
|
self.examples = {self.message: self.message}
|
|
103
|
-
|
|
104
|
-
def to_example(self) -> Dict[str, Any]:
|
|
105
|
-
"""Generate examples for OpenAPI with multiple examples.
|
|
106
|
-
|
|
107
|
-
Returns:
|
|
108
|
-
Dict in format: {"key": {"value": {"detail": "message"}}, ...}
|
|
109
|
-
|
|
110
|
-
Example:
|
|
111
|
-
```python
|
|
112
|
-
{
|
|
113
|
-
"InvalidToken": {
|
|
114
|
-
"value": {"detail": "Ошибка декодирования токена."},
|
|
115
|
-
},
|
|
116
|
-
"SessionNotFound": {
|
|
117
|
-
"value": {"detail": "Сессия пользователя не была найдена."},
|
|
118
|
-
},
|
|
119
|
-
}
|
|
120
|
-
```
|
|
121
|
-
"""
|
|
122
|
-
return {
|
|
123
|
-
key: {
|
|
124
|
-
"value": {"detail": message},
|
|
125
|
-
}
|
|
126
|
-
for key, message in self.examples.items()
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
104
|
|
|
105
|
+
def to_examples(self) -> Dict[str, Any]:
|
|
106
|
+
"""Generate OpenAPI ``examples`` with optional summaries."""
|
|
107
|
+
assert self.examples is not None
|
|
108
|
+
return _normalize_example_specs(self.examples)
|
|
131
109
|
|
|
110
|
+
def to_example(self) -> Dict[str, Any]:
|
|
111
|
+
"""Deprecated alias for :meth:`to_examples`."""
|
|
112
|
+
warnings.warn(
|
|
113
|
+
"to_example() is deprecated; use to_examples() instead.",
|
|
114
|
+
DeprecationWarning,
|
|
115
|
+
stacklevel=2,
|
|
116
|
+
)
|
|
117
|
+
return self.to_examples()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Declarative error documentation for OpenAPI."""
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from fastapi_errors_plus.example_utils import ExampleSpec, _normalize_example_specs
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ErrorDoc:
|
|
12
|
+
"""Declarative ErrorDTO with arbitrary example bodies and optional summaries.
|
|
13
|
+
|
|
14
|
+
``examples`` values:
|
|
15
|
+
|
|
16
|
+
* **str** — shorthand ``{"detail": text}`` (same as bundled DTOs).
|
|
17
|
+
* **dict** with only OpenAPI Example Object keys (``value``, ``summary``, …) —
|
|
18
|
+
used as the Example Object.
|
|
19
|
+
* **any other dict** — treated as the full response **body** (e.g.
|
|
20
|
+
``{"code": "...", "detail": "...", "value": 150}``).
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
```python
|
|
24
|
+
ErrorDoc(
|
|
25
|
+
status_code=403,
|
|
26
|
+
message="Insufficient permissions",
|
|
27
|
+
examples={
|
|
28
|
+
"MissingRole": {
|
|
29
|
+
"summary": "User lacks required role",
|
|
30
|
+
"value": {"code": "FORBIDDEN", "detail": "Role admin required"},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
)
|
|
34
|
+
```
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
status_code: int
|
|
38
|
+
message: str
|
|
39
|
+
examples: Optional[Dict[str, ExampleSpec]] = None
|
|
40
|
+
body: Optional[Dict[str, Any]] = None
|
|
41
|
+
example_key: Optional[str] = None
|
|
42
|
+
openapi_json_extras: Optional[Dict[str, Any]] = field(default=None)
|
|
43
|
+
|
|
44
|
+
def to_examples(self) -> Dict[str, Any]:
|
|
45
|
+
"""Build OpenAPI ``examples`` map for ``application/json``."""
|
|
46
|
+
if self.examples is not None:
|
|
47
|
+
return _normalize_example_specs(self.examples)
|
|
48
|
+
key = self.example_key or self.message
|
|
49
|
+
value = self.body if self.body is not None else {"detail": self.message}
|
|
50
|
+
return {key: {"value": value}}
|
|
51
|
+
|
|
52
|
+
def to_example(self) -> Dict[str, Any]:
|
|
53
|
+
"""Deprecated alias for :meth:`to_examples`."""
|
|
54
|
+
warnings.warn(
|
|
55
|
+
"to_example() is deprecated; use to_examples() instead.",
|
|
56
|
+
DeprecationWarning,
|
|
57
|
+
stacklevel=2,
|
|
58
|
+
)
|
|
59
|
+
return self.to_examples()
|
fastapi_errors_plus/errors.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
"""Main Errors class for documenting errors in FastAPI endpoints."""
|
|
2
2
|
|
|
3
|
+
import copy
|
|
4
|
+
import warnings
|
|
3
5
|
from collections.abc import Mapping
|
|
6
|
+
from http import HTTPStatus
|
|
4
7
|
from typing import Any, Dict, Iterator, Optional, Union
|
|
5
8
|
|
|
6
9
|
from fastapi import status
|
|
@@ -10,9 +13,12 @@ from fastapi_errors_plus.protocol import ErrorDTO
|
|
|
10
13
|
# Starlette (via FastAPI) prefers HTTP_422_UNPROCESSABLE_CONTENT over ENTITY.
|
|
11
14
|
# Older pins may lack CONTENT (import crash); avoid nested getattr(, default=)
|
|
12
15
|
# touching ENTITY when CONTENT exists (DeprecationWarning).
|
|
13
|
-
_HTTP_422
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
_HTTP_422: int
|
|
17
|
+
_http_422_attr = getattr(status, "HTTP_422_UNPROCESSABLE_CONTENT", None)
|
|
18
|
+
if _http_422_attr is None:
|
|
19
|
+
_HTTP_422 = int(getattr(status, "HTTP_422_UNPROCESSABLE_ENTITY", 422))
|
|
20
|
+
else:
|
|
21
|
+
_HTTP_422 = int(_http_422_attr)
|
|
16
22
|
|
|
17
23
|
# OpenAPI Media Type: merge example/examples separately; other keys (schema, encoding, …) from an extra dict win.
|
|
18
24
|
_OPENAPI_MEDIA_TYPE_EXAMPLE_KEYS = frozenset({"example", "examples"})
|
|
@@ -45,24 +51,89 @@ def _pick_error_dto_application_json_extra(error_dto: Any) -> Optional[Dict[str,
|
|
|
45
51
|
return None
|
|
46
52
|
|
|
47
53
|
|
|
54
|
+
def _example_defining_class(cls: type, method: str) -> Optional[type]:
|
|
55
|
+
"""First class in MRO that defines *method* in its own ``__dict__``."""
|
|
56
|
+
for base in cls.__mro__:
|
|
57
|
+
if method in base.__dict__:
|
|
58
|
+
return base
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _collect_dto_examples(error_dto: Any) -> Dict[str, Any]:
|
|
63
|
+
"""Return a deep copy of examples from an ErrorDTO.
|
|
64
|
+
|
|
65
|
+
Resolution walks the MRO: the most specific ``to_examples`` wins over an
|
|
66
|
+
inherited ``to_example`` on the same branch; legacy ``to_example`` only on
|
|
67
|
+
the defining class emits a single actionable deprecation warning.
|
|
68
|
+
"""
|
|
69
|
+
cls = type(error_dto)
|
|
70
|
+
examples_cls = _example_defining_class(cls, "to_examples")
|
|
71
|
+
legacy_cls = _example_defining_class(cls, "to_example")
|
|
72
|
+
|
|
73
|
+
if examples_cls is not None:
|
|
74
|
+
use_examples = legacy_cls is None or cls.__mro__.index(
|
|
75
|
+
examples_cls
|
|
76
|
+
) <= cls.__mro__.index(legacy_cls)
|
|
77
|
+
if use_examples:
|
|
78
|
+
return copy.deepcopy(error_dto.to_examples())
|
|
79
|
+
|
|
80
|
+
if legacy_cls is not None:
|
|
81
|
+
with warnings.catch_warnings():
|
|
82
|
+
warnings.simplefilter("ignore", DeprecationWarning)
|
|
83
|
+
result = error_dto.to_example()
|
|
84
|
+
warnings.warn(
|
|
85
|
+
f"{cls.__name__} implements deprecated to_example(); use to_examples() instead.",
|
|
86
|
+
DeprecationWarning,
|
|
87
|
+
stacklevel=3,
|
|
88
|
+
)
|
|
89
|
+
return copy.deepcopy(result)
|
|
90
|
+
|
|
91
|
+
raise TypeError(f"{cls.__name__} has no to_examples() or to_example()")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _apply_dto_description(
|
|
95
|
+
existing: Dict[str, Any],
|
|
96
|
+
error_dto: Any,
|
|
97
|
+
standard_descriptions: Dict[int, str],
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Set ``description`` from DTO when missing, empty, or still a standard flag label."""
|
|
100
|
+
desc = existing.get("description")
|
|
101
|
+
if desc is None or (isinstance(desc, str) and not desc.strip()):
|
|
102
|
+
existing["description"] = error_dto.message
|
|
103
|
+
elif desc == standard_descriptions.get(error_dto.status_code):
|
|
104
|
+
existing["description"] = error_dto.message
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _ensure_response_descriptions(responses: Dict[int, Dict[str, Any]]) -> None:
|
|
108
|
+
"""Fill missing descriptions so FastAPI OpenAPI generation does not assert."""
|
|
109
|
+
for code, response in responses.items():
|
|
110
|
+
desc = response.get("description")
|
|
111
|
+
if desc is not None and isinstance(desc, str) and desc.strip():
|
|
112
|
+
continue
|
|
113
|
+
try:
|
|
114
|
+
response["description"] = HTTPStatus(code).phrase
|
|
115
|
+
except ValueError:
|
|
116
|
+
response["description"] = f"HTTP {code}"
|
|
117
|
+
|
|
118
|
+
|
|
48
119
|
class Errors(Mapping):
|
|
49
120
|
"""Universal class for documenting errors in FastAPI endpoints.
|
|
50
|
-
|
|
121
|
+
|
|
51
122
|
Works with any FastAPI project. Can accept:
|
|
52
123
|
- Standard HTTP statuses via boolean flags
|
|
53
124
|
- Dict in FastAPI responses format
|
|
54
125
|
- Objects implementing ErrorDTO protocol (for project compatibility)
|
|
55
|
-
|
|
126
|
+
|
|
56
127
|
Implements Mapping protocol, so can be used directly in FastAPI responses
|
|
57
128
|
parameter without calling ().
|
|
58
|
-
|
|
129
|
+
|
|
59
130
|
Example:
|
|
60
131
|
```python
|
|
61
132
|
from fastapi import APIRouter
|
|
62
133
|
from fastapi_errors_plus import Errors
|
|
63
|
-
|
|
134
|
+
|
|
64
135
|
router = APIRouter()
|
|
65
|
-
|
|
136
|
+
|
|
66
137
|
@router.delete(
|
|
67
138
|
"/{id}",
|
|
68
139
|
responses=Errors(
|
|
@@ -83,25 +154,29 @@ class Errors(Mapping):
|
|
|
83
154
|
pass
|
|
84
155
|
```
|
|
85
156
|
"""
|
|
86
|
-
|
|
157
|
+
|
|
87
158
|
def __init__(
|
|
88
159
|
self,
|
|
89
160
|
# Arbitrary errors (dict or ErrorDTO) - must come first
|
|
90
161
|
*errors: Union[Dict[int, Dict[str, Any]], ErrorDTO],
|
|
91
162
|
# Standard HTTP statuses (boolean flags)
|
|
92
163
|
# Old parameters (for backward compatibility)
|
|
93
|
-
unauthorized: bool = False,
|
|
94
|
-
forbidden: bool = False,
|
|
95
|
-
validation_error: Optional[
|
|
164
|
+
unauthorized: bool = False, # 401
|
|
165
|
+
forbidden: bool = False, # 403
|
|
166
|
+
validation_error: Optional[
|
|
167
|
+
bool
|
|
168
|
+
] = None, # 422 (None = use default True, False = disable, True = enable)
|
|
96
169
|
internal_server_error: bool = False, # 500
|
|
97
170
|
# New parameters with explicit status codes (recommended)
|
|
98
|
-
unauthorized_401: bool = False,
|
|
99
|
-
forbidden_403: bool = False,
|
|
100
|
-
validation_error_422: Optional[
|
|
171
|
+
unauthorized_401: bool = False, # 401 (explicit)
|
|
172
|
+
forbidden_403: bool = False, # 403 (explicit)
|
|
173
|
+
validation_error_422: Optional[
|
|
174
|
+
bool
|
|
175
|
+
] = None, # 422 (explicit, None = use default True, False = disable, True = enable)
|
|
101
176
|
internal_server_error_500: bool = False, # 500 (explicit)
|
|
102
177
|
) -> None:
|
|
103
178
|
"""Initialize Errors instance.
|
|
104
|
-
|
|
179
|
+
|
|
105
180
|
Args:
|
|
106
181
|
*errors: Arbitrary errors as dict or ErrorDTO objects.
|
|
107
182
|
Dict should be in FastAPI responses format: {status_code: {...}}.
|
|
@@ -110,7 +185,7 @@ class Errors(Mapping):
|
|
|
110
185
|
Deprecated: Use `unauthorized_401` instead for explicit status code.
|
|
111
186
|
forbidden: Add 403 Forbidden error. Defaults to False.
|
|
112
187
|
Deprecated: Use `forbidden_403` instead for explicit status code.
|
|
113
|
-
validation_error: Add 422 Unprocessable Entity error.
|
|
188
|
+
validation_error: Add 422 Unprocessable Entity error.
|
|
114
189
|
- None (default): Add 422 (True by default, FastAPI validates all parameters)
|
|
115
190
|
- False: Explicitly disable 422
|
|
116
191
|
- True: Explicitly enable 422
|
|
@@ -121,25 +196,25 @@ class Errors(Mapping):
|
|
|
121
196
|
Deprecated: Use `internal_server_error_500` instead for explicit status code.
|
|
122
197
|
unauthorized_401: Add 401 Unauthorized error (explicit). Defaults to False.
|
|
123
198
|
forbidden_403: Add 403 Forbidden error (explicit). Defaults to False.
|
|
124
|
-
validation_error_422: Add 422 Unprocessable Entity error (explicit).
|
|
199
|
+
validation_error_422: Add 422 Unprocessable Entity error (explicit).
|
|
125
200
|
- None (default): Add 422 (True by default, FastAPI validates all parameters)
|
|
126
201
|
- False: Explicitly disable 422
|
|
127
202
|
- True: Explicitly enable 422
|
|
128
203
|
FastAPI automatically validates all parameters (Path, Query, Body), so 422 is relevant
|
|
129
204
|
in 95%+ of endpoints. Set to False only for endpoints without parameters.
|
|
130
205
|
internal_server_error_500: Add 500 Internal Server Error (explicit). Defaults to False.
|
|
131
|
-
|
|
206
|
+
|
|
132
207
|
Example:
|
|
133
208
|
```python
|
|
134
209
|
# Standard flags only
|
|
135
210
|
errors = Errors(unauthorized=True, forbidden=True)
|
|
136
|
-
|
|
211
|
+
|
|
137
212
|
# Dict errors
|
|
138
213
|
errors = Errors({404: {"description": "Not found", ...}})
|
|
139
|
-
|
|
214
|
+
|
|
140
215
|
# ErrorDTO
|
|
141
216
|
errors = Errors(MyErrorDTO())
|
|
142
|
-
|
|
217
|
+
|
|
143
218
|
# Mixed
|
|
144
219
|
errors = Errors(
|
|
145
220
|
{409: {...}},
|
|
@@ -149,7 +224,7 @@ class Errors(Mapping):
|
|
|
149
224
|
```
|
|
150
225
|
"""
|
|
151
226
|
self._responses: Dict[int, Dict[str, Any]] = {}
|
|
152
|
-
|
|
227
|
+
|
|
153
228
|
# Add standard errors
|
|
154
229
|
# New parameters with explicit codes have priority, but old ones still work
|
|
155
230
|
if unauthorized_401 or unauthorized:
|
|
@@ -174,7 +249,7 @@ class Errors(Mapping):
|
|
|
174
249
|
if validation_error is False or validation_error_422 is False:
|
|
175
250
|
# At least one is explicitly False - don't add
|
|
176
251
|
add_422 = False
|
|
177
|
-
|
|
252
|
+
|
|
178
253
|
if add_422:
|
|
179
254
|
self._add_standard_error(
|
|
180
255
|
_HTTP_422,
|
|
@@ -187,7 +262,7 @@ class Errors(Mapping):
|
|
|
187
262
|
"Internal Server Error",
|
|
188
263
|
{"detail": "Internal Server Error"},
|
|
189
264
|
)
|
|
190
|
-
|
|
265
|
+
|
|
191
266
|
# Add arbitrary errors
|
|
192
267
|
for error in errors:
|
|
193
268
|
if isinstance(error, dict):
|
|
@@ -198,16 +273,18 @@ class Errors(Mapping):
|
|
|
198
273
|
self._validate_error_dto(error)
|
|
199
274
|
# ErrorDTO (compatible with ApiErrorDTO via Protocol)
|
|
200
275
|
self._add_error_dto(error)
|
|
201
|
-
|
|
276
|
+
|
|
277
|
+
_ensure_response_descriptions(self._responses)
|
|
278
|
+
|
|
202
279
|
def _validate_error_dto(self, error: Any) -> None:
|
|
203
280
|
"""Validate that error object implements ErrorDTO protocol.
|
|
204
|
-
|
|
281
|
+
|
|
205
282
|
Args:
|
|
206
283
|
error: Object to validate
|
|
207
|
-
|
|
284
|
+
|
|
208
285
|
Raises:
|
|
209
286
|
TypeError: If object doesn't implement ErrorDTO protocol
|
|
210
|
-
|
|
287
|
+
|
|
211
288
|
Example:
|
|
212
289
|
```python
|
|
213
290
|
# Valid object
|
|
@@ -215,49 +292,47 @@ class Errors(Mapping):
|
|
|
215
292
|
status_code = 404
|
|
216
293
|
message = "Not found"
|
|
217
294
|
def to_example(self): ...
|
|
218
|
-
|
|
295
|
+
|
|
219
296
|
errors = Errors()
|
|
220
297
|
errors._validate_error_dto(MyError()) # OK
|
|
221
|
-
|
|
298
|
+
|
|
222
299
|
# Invalid object
|
|
223
300
|
class BadError:
|
|
224
301
|
status_code = 404
|
|
225
302
|
# Missing message and to_example
|
|
226
|
-
|
|
303
|
+
|
|
227
304
|
errors._validate_error_dto(BadError()) # Raises TypeError
|
|
228
305
|
```
|
|
229
306
|
"""
|
|
230
307
|
required_attrs = ("status_code", "message")
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
]
|
|
241
|
-
|
|
308
|
+
has_to_examples = callable(getattr(error, "to_examples", None))
|
|
309
|
+
has_to_example = callable(getattr(error, "to_example", None))
|
|
310
|
+
|
|
311
|
+
missing_attrs = [attr for attr in required_attrs if not hasattr(error, attr)]
|
|
312
|
+
if not has_to_examples and not has_to_example:
|
|
313
|
+
missing_methods = ["to_examples or to_example"]
|
|
314
|
+
else:
|
|
315
|
+
missing_methods = []
|
|
316
|
+
|
|
242
317
|
if missing_attrs or missing_methods:
|
|
243
318
|
missing = missing_attrs + missing_methods
|
|
244
319
|
raise TypeError(
|
|
245
320
|
f"ErrorDTO object must have {', '.join(required_attrs)} attributes "
|
|
246
|
-
f"and
|
|
321
|
+
f"and to_examples() or to_example() method. "
|
|
247
322
|
f"Missing: {', '.join(missing)}. "
|
|
248
323
|
f"Got {type(error).__name__}"
|
|
249
324
|
)
|
|
250
|
-
|
|
325
|
+
|
|
251
326
|
def _unique_key(self, examples: Dict[str, Any], base: str) -> str:
|
|
252
327
|
"""Generate unique key for examples dict.
|
|
253
|
-
|
|
328
|
+
|
|
254
329
|
Args:
|
|
255
330
|
examples: Existing examples dict
|
|
256
331
|
base: Base key name
|
|
257
|
-
|
|
332
|
+
|
|
258
333
|
Returns:
|
|
259
334
|
Unique key that doesn't exist in examples
|
|
260
|
-
|
|
335
|
+
|
|
261
336
|
Example:
|
|
262
337
|
>>> errors = Errors()
|
|
263
338
|
>>> errors._unique_key({"default": {}}, "default")
|
|
@@ -271,15 +346,15 @@ class Errors(Mapping):
|
|
|
271
346
|
key = f"{base}_{i}"
|
|
272
347
|
i += 1
|
|
273
348
|
return key
|
|
274
|
-
|
|
349
|
+
|
|
275
350
|
# Standard descriptions for priority checking
|
|
276
|
-
STANDARD_DESCRIPTIONS = {
|
|
351
|
+
STANDARD_DESCRIPTIONS: Dict[int, str] = {
|
|
277
352
|
status.HTTP_401_UNAUTHORIZED: "Unauthorized",
|
|
278
353
|
status.HTTP_403_FORBIDDEN: "Forbidden",
|
|
279
354
|
_HTTP_422: "Validation Error",
|
|
280
355
|
status.HTTP_500_INTERNAL_SERVER_ERROR: "Internal Server Error",
|
|
281
356
|
}
|
|
282
|
-
|
|
357
|
+
|
|
283
358
|
def _add_standard_error(
|
|
284
359
|
self,
|
|
285
360
|
status_code: int,
|
|
@@ -287,10 +362,10 @@ class Errors(Mapping):
|
|
|
287
362
|
example: Dict[str, Any],
|
|
288
363
|
) -> None:
|
|
289
364
|
"""Add standard error.
|
|
290
|
-
|
|
365
|
+
|
|
291
366
|
If the status code already exists, merges the example with existing examples
|
|
292
367
|
using a unique key instead of overwriting.
|
|
293
|
-
|
|
368
|
+
|
|
294
369
|
Args:
|
|
295
370
|
status_code: HTTP status code (e.g., 401, 403, 422, 500)
|
|
296
371
|
description: Error description for OpenAPI
|
|
@@ -304,7 +379,7 @@ class Errors(Mapping):
|
|
|
304
379
|
status.HTTP_500_INTERNAL_SERVER_ERROR: "StandardInternalServerError",
|
|
305
380
|
}
|
|
306
381
|
example_key = standard_keys.get(status_code, f"Standard{status_code}")
|
|
307
|
-
|
|
382
|
+
|
|
308
383
|
if status_code not in self._responses:
|
|
309
384
|
self._responses[status_code] = {
|
|
310
385
|
"description": description,
|
|
@@ -320,7 +395,7 @@ class Errors(Mapping):
|
|
|
320
395
|
existing = self._responses[status_code]
|
|
321
396
|
existing_content = existing.setdefault("content", {})
|
|
322
397
|
content = existing_content.setdefault("application/json", {})
|
|
323
|
-
|
|
398
|
+
|
|
324
399
|
# Convert example to examples if needed
|
|
325
400
|
if "examples" not in content:
|
|
326
401
|
if "example" in content:
|
|
@@ -330,51 +405,56 @@ class Errors(Mapping):
|
|
|
330
405
|
}
|
|
331
406
|
else:
|
|
332
407
|
content["examples"] = {}
|
|
333
|
-
|
|
408
|
+
|
|
334
409
|
# Add new example with unique key (don't overwrite existing!)
|
|
335
410
|
# Check if example_key already exists in examples, use unique key if needed
|
|
336
411
|
unique_key = self._unique_key(content["examples"], example_key)
|
|
337
412
|
content["examples"][unique_key] = {"value": example}
|
|
338
|
-
|
|
413
|
+
|
|
339
414
|
# Don't overwrite description if it already exists
|
|
340
415
|
# Priority: dict > DTO > standard flags
|
|
341
|
-
|
|
416
|
+
|
|
342
417
|
def _add_dict_error(self, error_dict: Dict[int, Dict[str, Any]]) -> None:
|
|
343
418
|
"""Add error from dict in FastAPI responses format.
|
|
344
|
-
|
|
419
|
+
|
|
345
420
|
Merges examples instead of overwriting the entire response entry.
|
|
346
|
-
|
|
421
|
+
|
|
347
422
|
Args:
|
|
348
423
|
error_dict: Dict in format {status_code: {"description": ..., "content": {...}}}
|
|
349
424
|
"""
|
|
350
425
|
for status_code, response_data in error_dict.items():
|
|
426
|
+
response_data = copy.deepcopy(response_data)
|
|
351
427
|
if status_code not in self._responses:
|
|
352
|
-
# New status code -
|
|
428
|
+
# New status code - deep copy so caller dict is not aliased
|
|
353
429
|
self._responses[status_code] = response_data
|
|
354
430
|
else:
|
|
355
431
|
# Status code already exists - merge
|
|
356
432
|
existing = self._responses[status_code]
|
|
357
|
-
|
|
433
|
+
|
|
358
434
|
# Merge description (dict takes priority)
|
|
359
435
|
if "description" in response_data:
|
|
360
436
|
existing["description"] = response_data["description"]
|
|
361
|
-
|
|
437
|
+
|
|
362
438
|
# FastAPI-style shorthand on the response object (incoming dict wins)
|
|
363
439
|
if "model" in response_data:
|
|
364
440
|
existing["model"] = response_data["model"]
|
|
365
|
-
|
|
441
|
+
|
|
366
442
|
# Merge content
|
|
367
443
|
if "content" in response_data:
|
|
368
444
|
existing_content = existing.setdefault("content", {})
|
|
369
445
|
response_content = response_data["content"]
|
|
370
|
-
|
|
446
|
+
|
|
371
447
|
# Merge application/json
|
|
372
448
|
if "application/json" in response_content:
|
|
373
|
-
existing_json = existing_content.setdefault(
|
|
449
|
+
existing_json = existing_content.setdefault(
|
|
450
|
+
"application/json", {}
|
|
451
|
+
)
|
|
374
452
|
response_json = response_content["application/json"]
|
|
375
|
-
|
|
376
|
-
_merge_openapi_application_json_non_example(
|
|
377
|
-
|
|
453
|
+
|
|
454
|
+
_merge_openapi_application_json_non_example(
|
|
455
|
+
existing_json, response_json
|
|
456
|
+
)
|
|
457
|
+
|
|
378
458
|
# Handle example/examples
|
|
379
459
|
if "examples" in response_json:
|
|
380
460
|
# Response has examples
|
|
@@ -387,9 +467,11 @@ class Errors(Mapping):
|
|
|
387
467
|
}
|
|
388
468
|
else:
|
|
389
469
|
existing_json["examples"] = {}
|
|
390
|
-
|
|
470
|
+
|
|
391
471
|
# Merge examples
|
|
392
|
-
existing_json["examples"].update(
|
|
472
|
+
existing_json["examples"].update(
|
|
473
|
+
copy.deepcopy(response_json["examples"])
|
|
474
|
+
)
|
|
393
475
|
elif "example" in response_json:
|
|
394
476
|
# Response has example - convert and merge
|
|
395
477
|
if "examples" not in existing_json:
|
|
@@ -405,30 +487,38 @@ class Errors(Mapping):
|
|
|
405
487
|
"Internal Server Error": "StandardInternalServerError",
|
|
406
488
|
}
|
|
407
489
|
existing_detail = existing_example.get("detail", "")
|
|
408
|
-
example_key = standard_messages.get(
|
|
490
|
+
example_key = standard_messages.get(
|
|
491
|
+
existing_detail, "default"
|
|
492
|
+
)
|
|
409
493
|
existing_json["examples"] = {
|
|
410
494
|
example_key: {"value": existing_example},
|
|
411
495
|
}
|
|
412
496
|
else:
|
|
413
497
|
existing_json["examples"] = {}
|
|
414
|
-
|
|
498
|
+
|
|
415
499
|
# Add new example - use "default" if available, otherwise unique key
|
|
416
500
|
if "default" not in existing_json["examples"]:
|
|
417
|
-
existing_json["examples"]["default"] = {
|
|
501
|
+
existing_json["examples"]["default"] = {
|
|
502
|
+
"value": response_json["example"]
|
|
503
|
+
}
|
|
418
504
|
else:
|
|
419
505
|
# If default exists, add with a unique key
|
|
420
|
-
unique_key = self._unique_key(
|
|
421
|
-
|
|
422
|
-
|
|
506
|
+
unique_key = self._unique_key(
|
|
507
|
+
existing_json["examples"], "CustomExample"
|
|
508
|
+
)
|
|
509
|
+
existing_json["examples"][unique_key] = {
|
|
510
|
+
"value": response_json["example"]
|
|
511
|
+
}
|
|
512
|
+
|
|
423
513
|
def _add_error_dto(self, error_dto: ErrorDTO) -> None:
|
|
424
514
|
"""Add error from ErrorDTO (via Protocol).
|
|
425
|
-
|
|
515
|
+
|
|
426
516
|
If the status code already exists, merges examples with existing examples.
|
|
427
|
-
|
|
517
|
+
|
|
428
518
|
Args:
|
|
429
|
-
error_dto:
|
|
430
|
-
|
|
431
|
-
|
|
519
|
+
error_dto: Object with ``status_code``, ``message``, and
|
|
520
|
+
``to_examples()`` and/or legacy ``to_example()``.
|
|
521
|
+
|
|
432
522
|
Example:
|
|
433
523
|
```python
|
|
434
524
|
class MyError:
|
|
@@ -436,19 +526,23 @@ class Errors(Mapping):
|
|
|
436
526
|
message = "Not found"
|
|
437
527
|
def to_example(self):
|
|
438
528
|
return {"Not found": {"value": {"detail": "Not found"}}}
|
|
439
|
-
|
|
529
|
+
|
|
440
530
|
errors = Errors()
|
|
441
531
|
errors._add_error_dto(MyError())
|
|
442
532
|
```
|
|
443
533
|
"""
|
|
444
534
|
status_code = error_dto.status_code
|
|
445
|
-
examples = error_dto
|
|
535
|
+
examples = _collect_dto_examples(error_dto)
|
|
446
536
|
dto_extras = _pick_error_dto_application_json_extra(error_dto)
|
|
537
|
+
if dto_extras is not None:
|
|
538
|
+
dto_extras = copy.deepcopy(dto_extras)
|
|
447
539
|
|
|
448
540
|
if status_code not in self._responses:
|
|
449
541
|
application_json: Dict[str, Any] = {"examples": examples}
|
|
450
542
|
if dto_extras is not None:
|
|
451
|
-
_merge_openapi_application_json_non_example(
|
|
543
|
+
_merge_openapi_application_json_non_example(
|
|
544
|
+
application_json, dto_extras
|
|
545
|
+
)
|
|
452
546
|
self._responses[status_code] = {
|
|
453
547
|
"description": error_dto.message,
|
|
454
548
|
"content": {
|
|
@@ -459,20 +553,18 @@ class Errors(Mapping):
|
|
|
459
553
|
# Merge examples for the same status code
|
|
460
554
|
# Safely access content/application/json with defaults
|
|
461
555
|
existing = self._responses[status_code]
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
existing["description"] = error_dto.message
|
|
466
|
-
|
|
556
|
+
|
|
557
|
+
_apply_dto_description(existing, error_dto, self.STANDARD_DESCRIPTIONS)
|
|
558
|
+
|
|
467
559
|
existing_content = existing.setdefault("content", {})
|
|
468
560
|
content_json = existing_content.setdefault("application/json", {})
|
|
469
561
|
existing_examples = content_json.get("examples", {})
|
|
470
|
-
|
|
562
|
+
|
|
471
563
|
if "example" in content_json:
|
|
472
564
|
# Convert example to examples with correct key for standard flags
|
|
473
565
|
existing_example = content_json.pop("example")
|
|
474
566
|
existing_detail = existing_example.get("detail", "")
|
|
475
|
-
|
|
567
|
+
|
|
476
568
|
# Check if existing example is from a standard flag
|
|
477
569
|
standard_messages = {
|
|
478
570
|
"Unauthorized": "StandardUnauthorized",
|
|
@@ -481,48 +573,47 @@ class Errors(Mapping):
|
|
|
481
573
|
"Internal Server Error": "StandardInternalServerError",
|
|
482
574
|
}
|
|
483
575
|
example_key = standard_messages.get(existing_detail, "default")
|
|
484
|
-
|
|
576
|
+
|
|
485
577
|
existing_examples = {
|
|
486
578
|
example_key: {
|
|
487
579
|
"value": existing_example,
|
|
488
580
|
},
|
|
489
581
|
}
|
|
490
|
-
|
|
582
|
+
|
|
491
583
|
# Merge new examples from ErrorDTO
|
|
492
|
-
existing_examples.update(examples)
|
|
584
|
+
existing_examples.update(copy.deepcopy(examples))
|
|
493
585
|
content_json["examples"] = existing_examples
|
|
494
|
-
|
|
586
|
+
|
|
495
587
|
if dto_extras is not None:
|
|
496
588
|
_merge_openapi_application_json_non_example(content_json, dto_extras)
|
|
497
589
|
|
|
498
590
|
# Mapping protocol implementation
|
|
499
591
|
def __getitem__(self, key: int) -> Dict[str, Any]:
|
|
500
592
|
"""Get response for status code.
|
|
501
|
-
|
|
593
|
+
|
|
502
594
|
Args:
|
|
503
595
|
key: HTTP status code (e.g., 401, 403, 404)
|
|
504
|
-
|
|
596
|
+
|
|
505
597
|
Returns:
|
|
506
598
|
Response dict in FastAPI format
|
|
507
|
-
|
|
599
|
+
|
|
508
600
|
Raises:
|
|
509
601
|
KeyError: If status code not found
|
|
510
602
|
"""
|
|
511
|
-
return self._responses[key]
|
|
512
|
-
|
|
603
|
+
return copy.deepcopy(self._responses[key])
|
|
604
|
+
|
|
513
605
|
def __iter__(self) -> Iterator[int]:
|
|
514
606
|
"""Iterate over status codes.
|
|
515
|
-
|
|
607
|
+
|
|
516
608
|
Returns:
|
|
517
609
|
Iterator over status codes
|
|
518
610
|
"""
|
|
519
611
|
return iter(self._responses)
|
|
520
|
-
|
|
612
|
+
|
|
521
613
|
def __len__(self) -> int:
|
|
522
614
|
"""Get number of status codes.
|
|
523
|
-
|
|
615
|
+
|
|
524
616
|
Returns:
|
|
525
617
|
Number of documented error status codes
|
|
526
618
|
"""
|
|
527
619
|
return len(self._responses)
|
|
528
|
-
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""OpenAPI example normalization shared by ErrorDoc and bundled DTOs."""
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from typing import Any, Dict, Union
|
|
5
|
+
|
|
6
|
+
# OpenAPI 3 Example Object allowed keys (subset used by this library).
|
|
7
|
+
_OPENAPI_EXAMPLE_OBJECT_KEYS = frozenset(
|
|
8
|
+
{"value", "summary", "description", "externalValue"}
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
ExampleSpec = Union[str, Dict[str, Any]]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _is_openapi_example_object(spec: Dict[str, Any]) -> bool:
|
|
15
|
+
"""True if *spec* looks like an OpenAPI Example Object, not a response body."""
|
|
16
|
+
if not spec:
|
|
17
|
+
return False
|
|
18
|
+
return set(spec.keys()) <= _OPENAPI_EXAMPLE_OBJECT_KEYS
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _normalize_example_specs(specs: Dict[str, ExampleSpec]) -> Dict[str, Any]:
|
|
22
|
+
"""Normalize user specs to OpenAPI ``examples`` map.
|
|
23
|
+
|
|
24
|
+
* ``str`` — shorthand for ``{"value": {"detail": str}}`` (legacy detail shape).
|
|
25
|
+
* ``dict`` — if keys are only OpenAPI Example Object fields, used as-is;
|
|
26
|
+
otherwise the whole dict is treated as the response **body** wrapped in
|
|
27
|
+
``{"value": body}`` (avoids mis-parsing bodies that contain a ``value`` field).
|
|
28
|
+
"""
|
|
29
|
+
out: Dict[str, Any] = {}
|
|
30
|
+
for key, spec in specs.items():
|
|
31
|
+
if isinstance(spec, str):
|
|
32
|
+
out[key] = {"value": {"detail": spec}}
|
|
33
|
+
elif isinstance(spec, dict):
|
|
34
|
+
if _is_openapi_example_object(spec):
|
|
35
|
+
out[key] = dict(spec)
|
|
36
|
+
else:
|
|
37
|
+
extra = set(spec.keys()) - _OPENAPI_EXAMPLE_OBJECT_KEYS
|
|
38
|
+
if "value" in spec and extra:
|
|
39
|
+
warnings.warn(
|
|
40
|
+
f"Example {key!r}: keys {sorted(extra)} look like a response body, "
|
|
41
|
+
"not an OpenAPI Example Object; wrapping the dict as the example value. "
|
|
42
|
+
"Use explicit Example Object keys only "
|
|
43
|
+
"(value, summary, description, externalValue) at the top level.",
|
|
44
|
+
UserWarning,
|
|
45
|
+
stacklevel=3,
|
|
46
|
+
)
|
|
47
|
+
out[key] = {"value": spec}
|
|
48
|
+
else:
|
|
49
|
+
raise TypeError(
|
|
50
|
+
f"Example spec for key {key!r} must be str or dict, got {type(spec).__name__}"
|
|
51
|
+
)
|
|
52
|
+
return out
|
fastapi_errors_plus/protocol.py
CHANGED
|
@@ -1,60 +1,40 @@
|
|
|
1
1
|
"""Protocol for error DTOs compatible with fastapi-errors-plus."""
|
|
2
2
|
|
|
3
|
-
from typing import Any, Dict, Protocol
|
|
3
|
+
from typing import Any, Dict, Protocol, Union, runtime_checkable
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
@runtime_checkable
|
|
6
7
|
class ErrorDTO(Protocol):
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
non-empty ``dict``, it **takes precedence** over ``openapi_json_extras``.
|
|
17
|
-
|
|
18
|
-
Example:
|
|
19
|
-
```python
|
|
20
|
-
class MyError:
|
|
21
|
-
status_code = 404
|
|
22
|
-
message = "Not found"
|
|
23
|
-
|
|
24
|
-
def to_example(self) -> Dict[str, Any]:
|
|
25
|
-
return {
|
|
26
|
-
"Not found": {
|
|
27
|
-
"value": {"detail": "Not found"},
|
|
28
|
-
},
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
errors = Errors(MyError())
|
|
32
|
-
```
|
|
8
|
+
"""Target interface for error objects used with :class:`Errors`.
|
|
9
|
+
|
|
10
|
+
Implement ``to_examples()`` returning an OpenAPI ``examples`` map::
|
|
11
|
+
|
|
12
|
+
{"ExampleKey": {"value": {...}, "summary": "..."}}
|
|
13
|
+
|
|
14
|
+
Legacy classes that only define ``to_example()`` are accepted at **runtime**
|
|
15
|
+
by :class:`Errors` but do **not** satisfy this protocol for static typing.
|
|
16
|
+
See :class:`LegacyErrorDTO`.
|
|
33
17
|
"""
|
|
18
|
+
|
|
34
19
|
status_code: int
|
|
35
|
-
"""HTTP status code for the error."""
|
|
36
|
-
|
|
37
20
|
message: str
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"""Generate example for OpenAPI.
|
|
42
|
-
|
|
43
|
-
Returns:
|
|
44
|
-
Dict in format: {"key": {"value": {"detail": "message"}}}
|
|
45
|
-
|
|
46
|
-
Example:
|
|
47
|
-
```python
|
|
48
|
-
{
|
|
49
|
-
"Not found": {
|
|
50
|
-
"value": {"detail": "Not found"},
|
|
51
|
-
},
|
|
52
|
-
}
|
|
53
|
-
```
|
|
54
|
-
"""
|
|
21
|
+
|
|
22
|
+
def to_examples(self) -> Dict[str, Any]:
|
|
23
|
+
"""Generate OpenAPI ``examples`` for this error."""
|
|
55
24
|
...
|
|
56
25
|
|
|
57
26
|
|
|
27
|
+
@runtime_checkable
|
|
28
|
+
class LegacyErrorDTO(Protocol):
|
|
29
|
+
"""Deprecated: only ``to_example()`` — migrate to :class:`ErrorDTO`."""
|
|
58
30
|
|
|
31
|
+
status_code: int
|
|
32
|
+
message: str
|
|
33
|
+
|
|
34
|
+
def to_example(self) -> Dict[str, Any]:
|
|
35
|
+
"""Deprecated OpenAPI ``examples`` map."""
|
|
36
|
+
...
|
|
59
37
|
|
|
60
38
|
|
|
39
|
+
# Annotation helper for codebases still on legacy ``to_example()`` only.
|
|
40
|
+
ErrorDTOLike = Union[ErrorDTO, LegacyErrorDTO]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-errors-plus
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Universal library for documenting errors in FastAPI endpoints
|
|
5
5
|
Author-email: Igor Selivanov <seligoroff@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -22,6 +22,9 @@ Provides-Extra: dev
|
|
|
22
22
|
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
23
23
|
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
24
24
|
Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
|
|
25
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
26
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
27
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
25
28
|
Dynamic: license-file
|
|
26
29
|
|
|
27
30
|
# fastapi-errors-plus
|
|
@@ -30,7 +33,7 @@ Dynamic: license-file
|
|
|
30
33
|
[](https://opensource.org/licenses/MIT)
|
|
31
34
|
[](https://www.python.org/downloads/)
|
|
32
35
|
[](https://fastapi.tiangolo.com/)
|
|
33
|
-
[](https://github.com/seligoroff/fastapi-errors-plus)
|
|
34
37
|
[](https://github.com/seligoroff/fastapi-errors-plus)
|
|
35
38
|
|
|
36
39
|
Universal library for documenting errors in FastAPI endpoints.
|
|
@@ -164,7 +167,7 @@ class MyErrorDTO:
|
|
|
164
167
|
status_code = 404
|
|
165
168
|
message = "Not found"
|
|
166
169
|
|
|
167
|
-
def
|
|
170
|
+
def to_examples(self):
|
|
168
171
|
return {
|
|
169
172
|
"Not found": {
|
|
170
173
|
"value": {"detail": "Not found"},
|
|
@@ -209,7 +212,7 @@ def delete_item(id: int):
|
|
|
209
212
|
|
|
210
213
|
#### OpenAPI extras (`schema`) next to examples
|
|
211
214
|
|
|
212
|
-
ADR-style payloads (`code`, `detail`, optional `context`) need a **`schema`** in the spec besides **`examples`**. On **`BaseErrorDTO`** / **`StandardErrorDTO`** use **`openapi_json_extras`** (a dict merged under `content["application/json"]` — omit `example` / `examples` there; **`
|
|
215
|
+
ADR-style payloads (`code`, `detail`, optional `context`) need a **`schema`** in the spec besides **`examples`**. On **`BaseErrorDTO`** / **`StandardErrorDTO`** / **`ErrorDoc`** use **`openapi_json_extras`** (a dict merged under `content["application/json"]` — omit `example` / `examples` there; **`to_examples()`** still defines examples):
|
|
213
216
|
|
|
214
217
|
```python
|
|
215
218
|
from fastapi import APIRouter, status
|
|
@@ -284,6 +287,37 @@ def delete_item(id: int):
|
|
|
284
287
|
- Reusable across all endpoints
|
|
285
288
|
- Supports inheritance for custom logic
|
|
286
289
|
|
|
290
|
+
`examples` values may be **strings** (shorthand for `{"detail": text}`) or full OpenAPI Example Objects with **`summary`** and **`value`**.
|
|
291
|
+
|
|
292
|
+
#### ErrorDoc
|
|
293
|
+
|
|
294
|
+
For arbitrary response bodies (ADR `code` / `detail` / `context`, not only `detail` strings), use **`ErrorDoc`**:
|
|
295
|
+
|
|
296
|
+
```python
|
|
297
|
+
from fastapi_errors_plus import ErrorDoc, Errors
|
|
298
|
+
|
|
299
|
+
permission_denied = ErrorDoc(
|
|
300
|
+
status_code=403,
|
|
301
|
+
message="Insufficient permissions",
|
|
302
|
+
examples={
|
|
303
|
+
"MissingRole": {
|
|
304
|
+
"summary": "User lacks required role",
|
|
305
|
+
"value": {
|
|
306
|
+
"code": "FORBIDDEN",
|
|
307
|
+
"detail": "Role admin required",
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
openapi_json_extras={"schema": ADR_ERROR_BODY_SCHEMA},
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
@router.delete("/{id}", responses=Errors(permission_denied))
|
|
315
|
+
def delete_item(id: int):
|
|
316
|
+
...
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
A **plain dict** as an example value is treated as the full response **body** unless it looks like an OpenAPI Example Object (keys only among `value`, `summary`, `description`, `externalValue`).
|
|
320
|
+
|
|
287
321
|
### 5. Mixed Usage
|
|
288
322
|
|
|
289
323
|
Combine flags, dict, and ErrorDTO:
|
|
@@ -363,7 +397,7 @@ Order matters only for overlaps: whichever **`dict`** is applied **later** in th
|
|
|
363
397
|
|
|
364
398
|
## ErrorDTO Protocol
|
|
365
399
|
|
|
366
|
-
The
|
|
400
|
+
The canonical **`ErrorDTO`** protocol defines the interface for error objects compatible with the library:
|
|
367
401
|
|
|
368
402
|
```python
|
|
369
403
|
from typing import Protocol, Dict, Any
|
|
@@ -372,27 +406,30 @@ class ErrorDTO(Protocol):
|
|
|
372
406
|
status_code: int
|
|
373
407
|
message: str
|
|
374
408
|
|
|
375
|
-
def
|
|
376
|
-
"""
|
|
377
|
-
|
|
378
|
-
Returns:
|
|
379
|
-
Dict in format: {"key": {"value": {"detail": "message"}}}
|
|
380
|
-
"""
|
|
409
|
+
def to_examples(self) -> Dict[str, Any]:
|
|
410
|
+
"""OpenAPI ``examples`` map: {"Key": {"value": {...}, "summary": "..."}}."""
|
|
381
411
|
...
|
|
382
412
|
```
|
|
383
413
|
|
|
384
414
|
Any class implementing this protocol (through structural typing) can be used with `Errors()`.
|
|
385
415
|
|
|
416
|
+
**Legacy migration:** classes that only define **`to_example()`** still work at **runtime** (`DeprecationWarning` once per class per `Errors()` call). For static typing use **`LegacyErrorDTO`** or the union alias **`ErrorDTOLike`** (`ErrorDTO | LegacyErrorDTO`).
|
|
417
|
+
|
|
386
418
|
**Best Practice:** For maximum clarity, consider making your domain exceptions implement the ErrorDTO protocol directly. See [Best Practice: Connecting Exceptions and ErrorDTO](#best-practice-connecting-exceptions-and-errordto) for details.
|
|
387
419
|
|
|
388
420
|
### When to Use Protocol vs BaseErrorDTO
|
|
389
421
|
|
|
422
|
+
Optional on custom DTOs (OpenAPI media-type extras without duplicating a status `dict`):
|
|
423
|
+
|
|
424
|
+
- attribute **`openapi_json_extras`**: fragment for **`content["application/json"]`** (often `{"schema": ...}` — not for `example` / `examples`);
|
|
425
|
+
- or method **`to_openapi_json_media_type_extras() -> Optional[dict]`** — when non-empty, overrides **`openapi_json_extras`**.
|
|
426
|
+
|
|
390
427
|
**Use Protocol (structural typing)** when:
|
|
391
428
|
- Your project already has error DTOs that implement the protocol
|
|
392
429
|
- You need maximum flexibility and custom implementations
|
|
393
430
|
- You want to keep your existing error infrastructure
|
|
394
431
|
|
|
395
|
-
**Use BaseErrorDTO/StandardErrorDTO** when:
|
|
432
|
+
**Use BaseErrorDTO/StandardErrorDTO/ErrorDoc** when:
|
|
396
433
|
- Starting a new project or adding error documentation
|
|
397
434
|
- You want a ready-to-use implementation without boilerplate
|
|
398
435
|
- You need multiple examples for standard HTTP errors (401, 403, etc.)
|
|
@@ -403,7 +440,7 @@ Both approaches work together — you can mix them in the same `Errors()` call!
|
|
|
403
440
|
|
|
404
441
|
**Note:** Pydantic is **not required** to use this library. This section is for projects that already use Pydantic and want to integrate it with the ErrorDTO protocol.
|
|
405
442
|
|
|
406
|
-
Since the library uses structural typing (Protocol), any class that implements the required attributes (`status_code`, `message`, `
|
|
443
|
+
Since the library uses structural typing (Protocol), any class that implements the required attributes (`status_code`, `message`, `to_examples()`) will work, including Pydantic models. Legacy `to_example()` is still accepted at runtime.
|
|
407
444
|
|
|
408
445
|
### Simple Pydantic Model as ErrorDTO
|
|
409
446
|
|
|
@@ -417,8 +454,8 @@ class PydanticErrorDTO(BaseModel):
|
|
|
417
454
|
status_code: int = Field(..., ge=400, le=599, description="HTTP status code")
|
|
418
455
|
message: str = Field(..., min_length=1, description="Error message")
|
|
419
456
|
|
|
420
|
-
def
|
|
421
|
-
"""Generate
|
|
457
|
+
def to_examples(self) -> Dict[str, Any]:
|
|
458
|
+
"""Generate examples for OpenAPI."""
|
|
422
459
|
return {
|
|
423
460
|
self.message: {
|
|
424
461
|
"value": {"detail": self.message},
|
|
@@ -461,8 +498,8 @@ class DetailedErrorDTO(BaseModel):
|
|
|
461
498
|
error_code: Optional[str] = Field(None, description="Internal error code")
|
|
462
499
|
timestamp: Optional[str] = Field(None, description="Error timestamp")
|
|
463
500
|
|
|
464
|
-
def
|
|
465
|
-
"""Generate
|
|
501
|
+
def to_examples(self) -> Dict[str, Any]:
|
|
502
|
+
"""Generate examples for OpenAPI."""
|
|
466
503
|
example = {"detail": self.message}
|
|
467
504
|
if self.error_code:
|
|
468
505
|
example["error_code"] = self.error_code
|
|
@@ -519,7 +556,7 @@ class DomainException(Exception):
|
|
|
519
556
|
status_code: int
|
|
520
557
|
message: str
|
|
521
558
|
|
|
522
|
-
def
|
|
559
|
+
def to_examples(self) -> Dict[str, Any]:
|
|
523
560
|
return {self.message: {"value": {"detail": self.message}}}
|
|
524
561
|
|
|
525
562
|
@classmethod
|
|
@@ -562,7 +599,7 @@ See [examples/domain_exceptions.py](examples/domain_exceptions.py) for complete
|
|
|
562
599
|
|
|
563
600
|
## Compatibility with Existing Projects
|
|
564
601
|
|
|
565
|
-
If your project already has error DTOs (like `ApiErrorDTO`), they can work with `fastapi-errors-plus` if they implement the `ErrorDTO` protocol:
|
|
602
|
+
If your project already has error DTOs (like `ApiErrorDTO`), they can work with `fastapi-errors-plus` if they implement the `ErrorDTO` protocol (or legacy `to_example()` during migration):
|
|
566
603
|
|
|
567
604
|
```python
|
|
568
605
|
# Your existing ApiErrorDTO
|
|
@@ -571,7 +608,7 @@ class ApiErrorDTO:
|
|
|
571
608
|
status_code: int
|
|
572
609
|
message: str
|
|
573
610
|
|
|
574
|
-
def
|
|
611
|
+
def to_examples(self) -> dict:
|
|
575
612
|
return {
|
|
576
613
|
self.message: {
|
|
577
614
|
"value": {"detail": self.message},
|
|
@@ -640,9 +677,13 @@ FastAPI automatically validates all parameters (Path, Query, Body), so 422 is re
|
|
|
640
677
|
```python
|
|
641
678
|
# Instances are Mapping keyed by HTTP status codes
|
|
642
679
|
error_responses = Errors(unauthorized_401=True, forbidden_403=True)
|
|
643
|
-
documented = error_responses[401] #
|
|
680
|
+
documented = error_responses[401] # deep copy — safe to read, won't mutate internal state
|
|
644
681
|
```
|
|
645
682
|
|
|
683
|
+
**Isolation (0.8+):** incoming response **dicts** are **deep-copied** on ingest; **`errors[status]`** returns a **deep copy** so callers cannot corrupt shared registry templates or internal merge state.
|
|
684
|
+
|
|
685
|
+
**Descriptions:** response blocks with only **`model`** (no `description`) get a default from **`HTTPStatus.phrase`** so OpenAPI generation does not fail.
|
|
686
|
+
|
|
646
687
|
### `ErrorDTO`
|
|
647
688
|
|
|
648
689
|
Protocol for error objects compatible with the library.
|
|
@@ -652,9 +693,32 @@ Protocol for error objects compatible with the library.
|
|
|
652
693
|
- `message: str` — Error message description
|
|
653
694
|
|
|
654
695
|
**Required methods:**
|
|
655
|
-
- `
|
|
696
|
+
- `to_examples() -> Dict[str, Any]` — OpenAPI `examples` map for `application/json`
|
|
697
|
+
|
|
698
|
+
During `Errors(...)` initialization, non-`dict` objects in `*errors` missing `status_code`, `message`, or a callable **`to_examples()`** / **`to_example()`** raise **`TypeError`** naming what was missing.
|
|
699
|
+
|
|
700
|
+
### `LegacyErrorDTO` / `ErrorDTOLike`
|
|
701
|
+
|
|
702
|
+
- **`LegacyErrorDTO`** — typing helper for classes that only implement deprecated **`to_example()`**.
|
|
703
|
+
- **`ErrorDTOLike`** — `Union[ErrorDTO, LegacyErrorDTO]` for transitional annotations.
|
|
704
|
+
|
|
705
|
+
### `ErrorDoc`
|
|
706
|
+
|
|
707
|
+
Declarative DTO for arbitrary example bodies and per-example **`summary`**.
|
|
708
|
+
|
|
709
|
+
**Constructor:**
|
|
710
|
+
```python
|
|
711
|
+
ErrorDoc(
|
|
712
|
+
status_code: int,
|
|
713
|
+
message: str,
|
|
714
|
+
examples: Optional[Dict[str, str | dict]] = None,
|
|
715
|
+
body: Optional[Dict[str, Any]] = None,
|
|
716
|
+
example_key: Optional[str] = None,
|
|
717
|
+
openapi_json_extras: Optional[Dict[str, Any]] = None,
|
|
718
|
+
)
|
|
719
|
+
```
|
|
656
720
|
|
|
657
|
-
|
|
721
|
+
When **`examples`** is omitted, a single example is built from **`body`** or `{"detail": message}`.
|
|
658
722
|
|
|
659
723
|
### `BaseErrorDTO`
|
|
660
724
|
|
|
@@ -793,7 +857,7 @@ class DomainException(Exception):
|
|
|
793
857
|
def __init__(self) -> None:
|
|
794
858
|
super().__init__(self.message)
|
|
795
859
|
|
|
796
|
-
def
|
|
860
|
+
def to_examples(self) -> Dict[str, Any]:
|
|
797
861
|
return {
|
|
798
862
|
self.message: {
|
|
799
863
|
"value": {"detail": self.message},
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
fastapi_errors_plus/__init__.py,sha256=sFQ2ykXQgnjAbTb6NsY8bkrAAbZTrrF_xUd8KXAXln4,484
|
|
2
|
+
fastapi_errors_plus/base.py,sha256=MB_IITnVFFSP8SazHo2AJDfEJrVwhnx3DqRl2Z6I2xE,3784
|
|
3
|
+
fastapi_errors_plus/error_doc.py,sha256=5DThH8WW_6R-DSIoOLq-zU4ccr8lDv0fEe0uuKS8ce8,2010
|
|
4
|
+
fastapi_errors_plus/errors.py,sha256=OmdZ_-8L2k0rLPZgNr68yxs3n2YQa28RWovk6P7Qf4k,25772
|
|
5
|
+
fastapi_errors_plus/example_utils.py,sha256=bHAj8dBhVxc3ozQPGuEHuXtH4--bSzJKFiqejnwCVPU,2161
|
|
6
|
+
fastapi_errors_plus/protocol.py,sha256=2P04uAnItLepU0pBznFF5XhTKVPugeK5W9KjoUAwmEA,1145
|
|
7
|
+
fastapi_errors_plus/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
fastapi_errors_plus-0.8.0.dist-info/licenses/LICENSE,sha256=PpYbS0f6IOOqH1JNR3K1UKZdEio6iO2ur9r8TwIZ7zs,1094
|
|
9
|
+
fastapi_errors_plus-0.8.0.dist-info/METADATA,sha256=19pI0EfTyi7HqHmbX0lwLKjQcPAjUxxz7K2ixfJkNYE,30659
|
|
10
|
+
fastapi_errors_plus-0.8.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
11
|
+
fastapi_errors_plus-0.8.0.dist-info/top_level.txt,sha256=vmHLF1-DQYB2quqrvdBA0g6rDtgOWQBrA1m-X_WyNk8,20
|
|
12
|
+
fastapi_errors_plus-0.8.0.dist-info/RECORD,,
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
fastapi_errors_plus/__init__.py,sha256=sL6F1BA6lB3cYkk76CKisAVAhVocChlCs_7yiHqWED8,454
|
|
2
|
-
fastapi_errors_plus/base.py,sha256=WNMypKQynTwe7GDxQdCCBwts5oHMaTgURmwMUmxeO0c,4021
|
|
3
|
-
fastapi_errors_plus/errors.py,sha256=Jt0lQ7HocZsT3sfAx4nvSvHGFJ3cc6o2_4VsUoBLyU0,23161
|
|
4
|
-
fastapi_errors_plus/protocol.py,sha256=u5nST4HdPUROXv0b5CYLXU9BAbF5Bh7ee_1MzEzYUxU,1644
|
|
5
|
-
fastapi_errors_plus/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
-
fastapi_errors_plus-0.7.0.dist-info/licenses/LICENSE,sha256=PpYbS0f6IOOqH1JNR3K1UKZdEio6iO2ur9r8TwIZ7zs,1094
|
|
7
|
-
fastapi_errors_plus-0.7.0.dist-info/METADATA,sha256=RD9nST74XBbunErEBsvWvbozidkT-Pa_B7gGhlLJ9uY,27669
|
|
8
|
-
fastapi_errors_plus-0.7.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
-
fastapi_errors_plus-0.7.0.dist-info/top_level.txt,sha256=vmHLF1-DQYB2quqrvdBA0g6rDtgOWQBrA1m-X_WyNk8,20
|
|
10
|
-
fastapi_errors_plus-0.7.0.dist-info/RECORD,,
|
{fastapi_errors_plus-0.7.0.dist-info → fastapi_errors_plus-0.8.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|