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.
@@ -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", # Protocol (for structural typing)
10
- "BaseErrorDTO", # Base implementation (optional)
11
- "StandardErrorDTO", # Extended implementation (optional)
10
+ "ErrorDTO",
11
+ "LegacyErrorDTO",
12
+ "ErrorDTOLike",
13
+ "ErrorDoc",
14
+ "BaseErrorDTO",
15
+ "StandardErrorDTO",
12
16
  ]
13
- __version__ = "0.7.0"
14
-
17
+ __version__ = "0.8.0"
@@ -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 to_example(self) -> Dict[str, Any]:
47
- """Generate example for OpenAPI.
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
- examples: Optional[Dict[str, str]] = field(default=None)
97
- """Dictionary of examples: {"key": "message"}."""
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()
@@ -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 = getattr(status, "HTTP_422_UNPROCESSABLE_CONTENT", None)
14
- if _HTTP_422 is None:
15
- _HTTP_422 = getattr(status, "HTTP_422_UNPROCESSABLE_ENTITY", 422)
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, # 401
94
- forbidden: bool = False, # 403
95
- validation_error: Optional[bool] = None, # 422 (None = use default True, False = disable, True = enable)
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, # 401 (explicit)
99
- forbidden_403: bool = False, # 403 (explicit)
100
- validation_error_422: Optional[bool] = None, # 422 (explicit, None = use default True, False = disable, True = enable)
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
- required_methods = ("to_example",)
232
-
233
- missing_attrs = [
234
- attr for attr in required_attrs
235
- if not hasattr(error, attr)
236
- ]
237
- missing_methods = [
238
- method for method in required_methods
239
- if not hasattr(error, method) or not callable(getattr(error, method, None))
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 {', '.join(required_methods)}() method. "
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 - just add it
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("application/json", {})
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(existing_json, response_json)
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(response_json["examples"])
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(existing_detail, "default")
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"] = {"value": response_json["example"]}
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(existing_json["examples"], "CustomExample")
421
- existing_json["examples"][unique_key] = {"value": response_json["example"]}
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: Error object implementing ErrorDTO protocol.
430
- Must have `status_code`, `message`, and `to_example()` method.
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.to_example()
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(application_json, dto_extras)
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
- # If description is standard, replace it with DTO message (priority: DTO > flags)
464
- if existing.get("description") == self.STANDARD_DESCRIPTIONS.get(status_code):
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
@@ -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
- """Protocol for error objects compatible with the library.
8
-
9
- Any class implementing this protocol can be used with Errors().
10
-
11
- Optionally, objects may expose **OpenAPI extras** besides examples:
12
-
13
- - ``openapi_json_extras``: ``dict`` merged into ``content["application/json"]``
14
- (e.g. ``{"schema": ...}`` do **not** use for ``example`` / ``examples``).
15
- - or ``to_openapi_json_media_type_extras() -> Optional[dict]``: if present and returns a
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
- """Error message description."""
39
-
40
- def to_example(self) -> Dict[str, Any]:
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.7.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
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
31
34
  [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
32
35
  [![FastAPI](https://img.shields.io/badge/FastAPI-0.104%2B-009688.svg)](https://fastapi.tiangolo.com/)
33
- [![Tests](https://img.shields.io/badge/tests-101-success.svg)](https://github.com/seligoroff/fastapi-errors-plus)
36
+ [![Tests](https://img.shields.io/badge/tests-117-success.svg)](https://github.com/seligoroff/fastapi-errors-plus)
34
37
  [![Coverage](https://img.shields.io/badge/coverage-80%25%2B-green.svg)](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 to_example(self):
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; **`to_example()`** still defines examples):
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 `ErrorDTO` protocol defines the interface for error objects compatible with the library:
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 to_example(self) -> Dict[str, Any]:
376
- """Generate example for OpenAPI.
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`, `to_example()`) will work, including Pydantic models.
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 to_example(self) -> Dict[str, Any]:
421
- """Generate example for OpenAPI."""
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 to_example(self) -> Dict[str, Any]:
465
- """Generate example for OpenAPI."""
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 to_example(self) -> Dict[str, Any]:
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 to_example(self) -> dict:
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] # response block picked up by OpenAPI
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
- - `to_example() -> Dict[str, Any]` — Generate example for OpenAPI
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
- During `Errors(...)` initialization, non-`dict` objects in `*errors` missing `status_code`, `message`, or a callable `to_example` raise **`TypeError`** naming what was missing.
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 to_example(self) -> Dict[str, Any]:
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,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (83.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -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,,