fastapi-errors-plus 0.4.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. {fastapi_errors_plus-0.4.0 → fastapi_errors_plus-0.6.0}/LICENSE +2 -0
  2. {fastapi_errors_plus-0.4.0 → fastapi_errors_plus-0.6.0}/PKG-INFO +84 -10
  3. {fastapi_errors_plus-0.4.0 → fastapi_errors_plus-0.6.0}/README.md +83 -9
  4. {fastapi_errors_plus-0.4.0 → fastapi_errors_plus-0.6.0}/fastapi_errors_plus/__init__.py +1 -1
  5. {fastapi_errors_plus-0.4.0 → fastapi_errors_plus-0.6.0}/fastapi_errors_plus/base.py +2 -0
  6. {fastapi_errors_plus-0.4.0 → fastapi_errors_plus-0.6.0}/fastapi_errors_plus/errors.py +57 -11
  7. {fastapi_errors_plus-0.4.0 → fastapi_errors_plus-0.6.0}/fastapi_errors_plus/protocol.py +2 -0
  8. fastapi_errors_plus-0.6.0/fastapi_errors_plus/py.typed +0 -0
  9. {fastapi_errors_plus-0.4.0 → fastapi_errors_plus-0.6.0}/fastapi_errors_plus.egg-info/PKG-INFO +84 -10
  10. {fastapi_errors_plus-0.4.0 → fastapi_errors_plus-0.6.0}/fastapi_errors_plus.egg-info/SOURCES.txt +1 -0
  11. {fastapi_errors_plus-0.4.0 → fastapi_errors_plus-0.6.0}/pyproject.toml +27 -1
  12. {fastapi_errors_plus-0.4.0 → fastapi_errors_plus-0.6.0}/tests/test_base.py +2 -0
  13. {fastapi_errors_plus-0.4.0 → fastapi_errors_plus-0.6.0}/tests/test_errors.py +123 -0
  14. {fastapi_errors_plus-0.4.0 → fastapi_errors_plus-0.6.0}/fastapi_errors_plus.egg-info/dependency_links.txt +0 -0
  15. {fastapi_errors_plus-0.4.0 → fastapi_errors_plus-0.6.0}/fastapi_errors_plus.egg-info/requires.txt +0 -0
  16. {fastapi_errors_plus-0.4.0 → fastapi_errors_plus-0.6.0}/fastapi_errors_plus.egg-info/top_level.txt +0 -0
  17. {fastapi_errors_plus-0.4.0 → fastapi_errors_plus-0.6.0}/setup.cfg +0 -0
  18. {fastapi_errors_plus-0.4.0 → fastapi_errors_plus-0.6.0}/tests/test_app.py +0 -0
  19. {fastapi_errors_plus-0.4.0 → fastapi_errors_plus-0.6.0}/tests/test_integration.py +0 -0
@@ -21,3 +21,5 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
22
 
23
23
 
24
+
25
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-errors-plus
3
- Version: 0.4.0
3
+ Version: 0.6.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
@@ -246,10 +246,10 @@ def delete_item(id: int):
246
246
  ```
247
247
 
248
248
  **Benefits:**
249
- - No need to write ErrorDTO classes from scratch
250
- - Correct implementation out of the box
251
- - Reusable across all endpoints
252
- - Supports inheritance for custom logic
249
+ - No need to write ErrorDTO classes from scratch
250
+ - Correct implementation out of the box
251
+ - Reusable across all endpoints
252
+ - Supports inheritance for custom logic
253
253
 
254
254
  ### 5. Mixed Usage
255
255
 
@@ -319,6 +319,8 @@ class ErrorDTO(Protocol):
319
319
 
320
320
  Any class implementing this protocol (through structural typing) can be used with `Errors()`.
321
321
 
322
+ **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.
323
+
322
324
  ### When to Use Protocol vs BaseErrorDTO
323
325
 
324
326
  **Use Protocol (structural typing)** when:
@@ -333,6 +335,71 @@ Any class implementing this protocol (through structural typing) can be used wit
333
335
 
334
336
  Both approaches work together — you can mix them in the same `Errors()` call!
335
337
 
338
+ ## Best Practice: Connecting Exceptions and ErrorDTO
339
+
340
+ ### Problem
341
+
342
+ It's not always clear which exception corresponds to which ErrorDTO:
343
+
344
+ ```python
345
+ # Not clear which exception this documents
346
+ responses=Errors(notification_not_found_error)
347
+ ```
348
+
349
+ ### Solution: Domain Exception as ErrorDTO
350
+
351
+ **Recommended approach** — make your exceptions implement ErrorDTO protocol:
352
+
353
+ ```python
354
+ # domain/exceptions.py
355
+ from typing import Dict, Any
356
+
357
+ class DomainException(Exception):
358
+ """Base exception implementing ErrorDTO protocol."""
359
+ status_code: int
360
+ message: str
361
+
362
+ def to_example(self) -> Dict[str, Any]:
363
+ return {self.message: {"value": {"detail": self.message}}}
364
+
365
+ @classmethod
366
+ def for_openapi(cls):
367
+ """Returns instance for OpenAPI documentation."""
368
+ return cls()
369
+
370
+ class NotificationNotFoundError(DomainException):
371
+ status_code = 404
372
+ message = "Notification not found"
373
+
374
+ def __init__(self, notification_id: str = ""):
375
+ self.notification_id = notification_id
376
+ super().__init__(self.message)
377
+
378
+ @classmethod
379
+ def for_openapi(cls):
380
+ return cls(notification_id="example_id")
381
+
382
+ # In endpoint
383
+ @router.delete(
384
+ "/{notificationId}",
385
+ responses=Errors(
386
+ NotificationNotFoundError.for_openapi(), # Clear connection!
387
+ ),
388
+ )
389
+ async def delete_notification(notification_id: str):
390
+ if not notification:
391
+ raise NotificationNotFoundError(notification_id) # Same exception!
392
+ ```
393
+
394
+ **Benefits:**
395
+ - Exception and ErrorDTO are one class
396
+ - Clear connection visible in endpoint
397
+ - No duplication
398
+ - Type-safe
399
+ - Works with any project architecture
400
+
401
+ See [examples/domain_exceptions.py](examples/domain_exceptions.py) for complete example.
402
+
336
403
  ## Compatibility with Existing Projects
337
404
 
338
405
  If your project already has error DTOs (like `ApiErrorDTO`), they can work with `fastapi-errors-plus` if they implement the `ErrorDTO` protocol:
@@ -375,11 +442,11 @@ Errors(
375
442
  *errors: Union[Dict[int, Dict[str, Any]], ErrorDTO],
376
443
  unauthorized: bool = False,
377
444
  forbidden: bool = False,
378
- validation_error: bool = True, # Defaults to True (FastAPI validates all parameters)
445
+ validation_error: Optional[bool] = None, # None (default) => True (FastAPI validates all parameters)
379
446
  internal_server_error: bool = False,
380
447
  unauthorized_401: bool = False,
381
448
  forbidden_403: bool = False,
382
- validation_error_422: bool = True, # Defaults to True (FastAPI validates all parameters)
449
+ validation_error_422: Optional[bool] = None, # None (default) => True (FastAPI validates all parameters)
383
450
  internal_server_error_500: bool = False,
384
451
  )
385
452
  ```
@@ -388,18 +455,25 @@ Errors(
388
455
  - `*errors`: Arbitrary errors as dict or ErrorDTO objects
389
456
  - `unauthorized_401`: Add 401 Unauthorized error (recommended, explicit). Defaults to `False`.
390
457
  - `forbidden_403`: Add 403 Forbidden error (recommended, explicit). Defaults to `False`.
391
- - `validation_error_422`: Add 422 Unprocessable Entity error (recommended, explicit). Defaults to `True` (FastAPI validates all parameters).
458
+ - `validation_error_422`: Add 422 Unprocessable Entity error (recommended, explicit).
459
+ - `None` (default): Add 422 (True by default, FastAPI validates all parameters)
460
+ - `False`: Explicitly disable 422
461
+ - `True`: Explicitly enable 422
392
462
  - `internal_server_error_500`: Add 500 Internal Server Error (recommended, explicit). Defaults to `False`.
393
463
  - `unauthorized`: Add 401 Unauthorized error (legacy, for backward compatibility). Defaults to `False`.
394
464
  - `forbidden`: Add 403 Forbidden error (legacy, for backward compatibility). Defaults to `False`.
395
- - `validation_error`: Add 422 Unprocessable Entity error (legacy, for backward compatibility). Defaults to `True` (FastAPI validates all parameters).
465
+ - `validation_error`: Add 422 Unprocessable Entity error (legacy, for backward compatibility).
466
+ - `None` (default): Add 422 (True by default, FastAPI validates all parameters)
467
+ - `False`: Explicitly disable 422
468
+ - `True`: Explicitly enable 422
396
469
  - `internal_server_error`: Add 500 Internal Server Error (legacy, for backward compatibility). Defaults to `False`.
397
470
 
398
471
  **Why `validation_error=True` by default?**
399
472
  FastAPI automatically validates all parameters (Path, Query, Body), so 422 is relevant in 95%+ of endpoints. For endpoints without parameters, explicitly set `validation_error=False` or `validation_error_422=False`.
400
473
 
401
474
  **Returns:**
402
- - Callable object that returns `Dict[int, Dict[str, Any]]` in FastAPI responses format
475
+ - Mapping object (dict-like) that implements `Dict[int, Dict[str, Any]]` for FastAPI responses
476
+ - Can be used directly in `responses` parameter without calling: `responses=Errors(...)`
403
477
 
404
478
  #### Usage
405
479
 
@@ -220,10 +220,10 @@ def delete_item(id: int):
220
220
  ```
221
221
 
222
222
  **Benefits:**
223
- - No need to write ErrorDTO classes from scratch
224
- - Correct implementation out of the box
225
- - Reusable across all endpoints
226
- - Supports inheritance for custom logic
223
+ - No need to write ErrorDTO classes from scratch
224
+ - Correct implementation out of the box
225
+ - Reusable across all endpoints
226
+ - Supports inheritance for custom logic
227
227
 
228
228
  ### 5. Mixed Usage
229
229
 
@@ -293,6 +293,8 @@ class ErrorDTO(Protocol):
293
293
 
294
294
  Any class implementing this protocol (through structural typing) can be used with `Errors()`.
295
295
 
296
+ **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.
297
+
296
298
  ### When to Use Protocol vs BaseErrorDTO
297
299
 
298
300
  **Use Protocol (structural typing)** when:
@@ -307,6 +309,71 @@ Any class implementing this protocol (through structural typing) can be used wit
307
309
 
308
310
  Both approaches work together — you can mix them in the same `Errors()` call!
309
311
 
312
+ ## Best Practice: Connecting Exceptions and ErrorDTO
313
+
314
+ ### Problem
315
+
316
+ It's not always clear which exception corresponds to which ErrorDTO:
317
+
318
+ ```python
319
+ # Not clear which exception this documents
320
+ responses=Errors(notification_not_found_error)
321
+ ```
322
+
323
+ ### Solution: Domain Exception as ErrorDTO
324
+
325
+ **Recommended approach** — make your exceptions implement ErrorDTO protocol:
326
+
327
+ ```python
328
+ # domain/exceptions.py
329
+ from typing import Dict, Any
330
+
331
+ class DomainException(Exception):
332
+ """Base exception implementing ErrorDTO protocol."""
333
+ status_code: int
334
+ message: str
335
+
336
+ def to_example(self) -> Dict[str, Any]:
337
+ return {self.message: {"value": {"detail": self.message}}}
338
+
339
+ @classmethod
340
+ def for_openapi(cls):
341
+ """Returns instance for OpenAPI documentation."""
342
+ return cls()
343
+
344
+ class NotificationNotFoundError(DomainException):
345
+ status_code = 404
346
+ message = "Notification not found"
347
+
348
+ def __init__(self, notification_id: str = ""):
349
+ self.notification_id = notification_id
350
+ super().__init__(self.message)
351
+
352
+ @classmethod
353
+ def for_openapi(cls):
354
+ return cls(notification_id="example_id")
355
+
356
+ # In endpoint
357
+ @router.delete(
358
+ "/{notificationId}",
359
+ responses=Errors(
360
+ NotificationNotFoundError.for_openapi(), # Clear connection!
361
+ ),
362
+ )
363
+ async def delete_notification(notification_id: str):
364
+ if not notification:
365
+ raise NotificationNotFoundError(notification_id) # Same exception!
366
+ ```
367
+
368
+ **Benefits:**
369
+ - Exception and ErrorDTO are one class
370
+ - Clear connection visible in endpoint
371
+ - No duplication
372
+ - Type-safe
373
+ - Works with any project architecture
374
+
375
+ See [examples/domain_exceptions.py](examples/domain_exceptions.py) for complete example.
376
+
310
377
  ## Compatibility with Existing Projects
311
378
 
312
379
  If your project already has error DTOs (like `ApiErrorDTO`), they can work with `fastapi-errors-plus` if they implement the `ErrorDTO` protocol:
@@ -349,11 +416,11 @@ Errors(
349
416
  *errors: Union[Dict[int, Dict[str, Any]], ErrorDTO],
350
417
  unauthorized: bool = False,
351
418
  forbidden: bool = False,
352
- validation_error: bool = True, # Defaults to True (FastAPI validates all parameters)
419
+ validation_error: Optional[bool] = None, # None (default) => True (FastAPI validates all parameters)
353
420
  internal_server_error: bool = False,
354
421
  unauthorized_401: bool = False,
355
422
  forbidden_403: bool = False,
356
- validation_error_422: bool = True, # Defaults to True (FastAPI validates all parameters)
423
+ validation_error_422: Optional[bool] = None, # None (default) => True (FastAPI validates all parameters)
357
424
  internal_server_error_500: bool = False,
358
425
  )
359
426
  ```
@@ -362,18 +429,25 @@ Errors(
362
429
  - `*errors`: Arbitrary errors as dict or ErrorDTO objects
363
430
  - `unauthorized_401`: Add 401 Unauthorized error (recommended, explicit). Defaults to `False`.
364
431
  - `forbidden_403`: Add 403 Forbidden error (recommended, explicit). Defaults to `False`.
365
- - `validation_error_422`: Add 422 Unprocessable Entity error (recommended, explicit). Defaults to `True` (FastAPI validates all parameters).
432
+ - `validation_error_422`: Add 422 Unprocessable Entity error (recommended, explicit).
433
+ - `None` (default): Add 422 (True by default, FastAPI validates all parameters)
434
+ - `False`: Explicitly disable 422
435
+ - `True`: Explicitly enable 422
366
436
  - `internal_server_error_500`: Add 500 Internal Server Error (recommended, explicit). Defaults to `False`.
367
437
  - `unauthorized`: Add 401 Unauthorized error (legacy, for backward compatibility). Defaults to `False`.
368
438
  - `forbidden`: Add 403 Forbidden error (legacy, for backward compatibility). Defaults to `False`.
369
- - `validation_error`: Add 422 Unprocessable Entity error (legacy, for backward compatibility). Defaults to `True` (FastAPI validates all parameters).
439
+ - `validation_error`: Add 422 Unprocessable Entity error (legacy, for backward compatibility).
440
+ - `None` (default): Add 422 (True by default, FastAPI validates all parameters)
441
+ - `False`: Explicitly disable 422
442
+ - `True`: Explicitly enable 422
370
443
  - `internal_server_error`: Add 500 Internal Server Error (legacy, for backward compatibility). Defaults to `False`.
371
444
 
372
445
  **Why `validation_error=True` by default?**
373
446
  FastAPI automatically validates all parameters (Path, Query, Body), so 422 is relevant in 95%+ of endpoints. For endpoints without parameters, explicitly set `validation_error=False` or `validation_error_422=False`.
374
447
 
375
448
  **Returns:**
376
- - Callable object that returns `Dict[int, Dict[str, Any]]` in FastAPI responses format
449
+ - Mapping object (dict-like) that implements `Dict[int, Dict[str, Any]]` for FastAPI responses
450
+ - Can be used directly in `responses` parameter without calling: `responses=Errors(...)`
377
451
 
378
452
  #### Usage
379
453
 
@@ -10,5 +10,5 @@ __all__ = [
10
10
  "BaseErrorDTO", # Base implementation (optional)
11
11
  "StandardErrorDTO", # Extended implementation (optional)
12
12
  ]
13
- __version__ = "0.4.0"
13
+ __version__ = "0.6.0"
14
14
 
@@ -120,3 +120,5 @@ class StandardErrorDTO(BaseErrorDTO):
120
120
  for key, message in self.examples.items()
121
121
  }
122
122
 
123
+
124
+
@@ -1,7 +1,7 @@
1
1
  """Main Errors class for documenting errors in FastAPI endpoints."""
2
2
 
3
3
  from collections.abc import Mapping
4
- from typing import Any, Dict, Iterator, Union
4
+ from typing import Any, Dict, Iterator, Optional, Union
5
5
 
6
6
  from fastapi import status
7
7
 
@@ -29,10 +29,7 @@ class Errors(Mapping):
29
29
  @router.delete(
30
30
  "/{id}",
31
31
  responses=Errors(
32
- unauthorized_401=True, # 401 (explicit)
33
- forbidden_403=True, # 403 (explicit)
34
- validation_error_422=True, # 422 (explicit)
35
- {404: { # 404 via dict
32
+ {404: { # 404 via dict (positional first)
36
33
  "description": "Not found",
37
34
  "content": {
38
35
  "application/json": {
@@ -40,6 +37,9 @@ class Errors(Mapping):
40
37
  },
41
38
  },
42
39
  }},
40
+ unauthorized_401=True, # 401 (explicit, named after positional)
41
+ forbidden_403=True, # 403 (explicit)
42
+ # validation_error_422=True - not needed, defaults to True
43
43
  ),
44
44
  )
45
45
  def delete_item(id: int):
@@ -55,12 +55,12 @@ class Errors(Mapping):
55
55
  # Old parameters (for backward compatibility)
56
56
  unauthorized: bool = False, # 401
57
57
  forbidden: bool = False, # 403
58
- validation_error: bool = None, # 422 (None = use default True, False = disable, True = enable)
58
+ validation_error: Optional[bool] = None, # 422 (None = use default True, False = disable, True = enable)
59
59
  internal_server_error: bool = False, # 500
60
60
  # New parameters with explicit status codes (recommended)
61
61
  unauthorized_401: bool = False, # 401 (explicit)
62
62
  forbidden_403: bool = False, # 403 (explicit)
63
- validation_error_422: bool = None, # 422 (explicit, None = use default True, False = disable, True = enable)
63
+ validation_error_422: Optional[bool] = None, # 422 (explicit, None = use default True, False = disable, True = enable)
64
64
  internal_server_error_500: bool = False, # 500 (explicit)
65
65
  ) -> None:
66
66
  """Initialize Errors instance.
@@ -73,7 +73,10 @@ class Errors(Mapping):
73
73
  Deprecated: Use `unauthorized_401` instead for explicit status code.
74
74
  forbidden: Add 403 Forbidden error. Defaults to False.
75
75
  Deprecated: Use `forbidden_403` instead for explicit status code.
76
- validation_error: Add 422 Unprocessable Entity error. Defaults to True (None means True).
76
+ validation_error: Add 422 Unprocessable Entity error.
77
+ - None (default): Add 422 (True by default, FastAPI validates all parameters)
78
+ - False: Explicitly disable 422
79
+ - True: Explicitly enable 422
77
80
  FastAPI automatically validates all parameters (Path, Query, Body), so 422 is relevant
78
81
  in 95%+ of endpoints. Set to False only for endpoints without parameters.
79
82
  Deprecated: Use `validation_error_422` instead for explicit status code.
@@ -81,7 +84,10 @@ class Errors(Mapping):
81
84
  Deprecated: Use `internal_server_error_500` instead for explicit status code.
82
85
  unauthorized_401: Add 401 Unauthorized error (explicit). Defaults to False.
83
86
  forbidden_403: Add 403 Forbidden error (explicit). Defaults to False.
84
- validation_error_422: Add 422 Unprocessable Entity error (explicit). Defaults to True (None means True).
87
+ validation_error_422: Add 422 Unprocessable Entity error (explicit).
88
+ - None (default): Add 422 (True by default, FastAPI validates all parameters)
89
+ - False: Explicitly disable 422
90
+ - True: Explicitly enable 422
85
91
  FastAPI automatically validates all parameters (Path, Query, Body), so 422 is relevant
86
92
  in 95%+ of endpoints. Set to False only for endpoints without parameters.
87
93
  internal_server_error_500: Add 500 Internal Server Error (explicit). Defaults to False.
@@ -205,6 +211,38 @@ class Errors(Mapping):
205
211
  f"Got {type(error).__name__}"
206
212
  )
207
213
 
214
+ def _unique_key(self, examples: Dict[str, Any], base: str) -> str:
215
+ """Generate unique key for examples dict.
216
+
217
+ Args:
218
+ examples: Existing examples dict
219
+ base: Base key name
220
+
221
+ Returns:
222
+ Unique key that doesn't exist in examples
223
+
224
+ Example:
225
+ >>> errors = Errors()
226
+ >>> errors._unique_key({"default": {}}, "default")
227
+ "default_2"
228
+ >>> errors._unique_key({"default": {}, "default_2": {}}, "default")
229
+ "default_3"
230
+ """
231
+ key = base
232
+ i = 2
233
+ while key in examples:
234
+ key = f"{base}_{i}"
235
+ i += 1
236
+ return key
237
+
238
+ # Standard descriptions for priority checking
239
+ STANDARD_DESCRIPTIONS = {
240
+ status.HTTP_401_UNAUTHORIZED: "Unauthorized",
241
+ status.HTTP_403_FORBIDDEN: "Forbidden",
242
+ status.HTTP_422_UNPROCESSABLE_ENTITY: "Validation Error",
243
+ status.HTTP_500_INTERNAL_SERVER_ERROR: "Internal Server Error",
244
+ }
245
+
208
246
  def _add_standard_error(
209
247
  self,
210
248
  status_code: int,
@@ -257,7 +295,9 @@ class Errors(Mapping):
257
295
  content["examples"] = {}
258
296
 
259
297
  # Add new example with unique key (don't overwrite existing!)
260
- content["examples"][example_key] = {"value": example}
298
+ # Check if example_key already exists in examples, use unique key if needed
299
+ unique_key = self._unique_key(content["examples"], example_key)
300
+ content["examples"][unique_key] = {"value": example}
261
301
 
262
302
  # Don't overwrite description if it already exists
263
303
  # Priority: dict > DTO > standard flags
@@ -334,7 +374,8 @@ class Errors(Mapping):
334
374
  existing_json["examples"]["default"] = {"value": response_json["example"]}
335
375
  else:
336
376
  # If default exists, add with a unique key
337
- existing_json["examples"]["CustomExample"] = {"value": response_json["example"]}
377
+ unique_key = self._unique_key(existing_json["examples"], "CustomExample")
378
+ existing_json["examples"][unique_key] = {"value": response_json["example"]}
338
379
 
339
380
  def _add_error_dto(self, error_dto: ErrorDTO) -> None:
340
381
  """Add error from ErrorDTO (via Protocol).
@@ -373,6 +414,11 @@ class Errors(Mapping):
373
414
  # Merge examples for the same status code
374
415
  # Safely access content/application/json with defaults
375
416
  existing = self._responses[status_code]
417
+
418
+ # If description is standard, replace it with DTO message (priority: DTO > flags)
419
+ if existing.get("description") == self.STANDARD_DESCRIPTIONS.get(status_code):
420
+ existing["description"] = error_dto.message
421
+
376
422
  existing_content = existing.setdefault("content", {})
377
423
  content_json = existing_content.setdefault("application/json", {})
378
424
  existing_examples = content_json.get("examples", {})
File without changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-errors-plus
3
- Version: 0.4.0
3
+ Version: 0.6.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
@@ -246,10 +246,10 @@ def delete_item(id: int):
246
246
  ```
247
247
 
248
248
  **Benefits:**
249
- - No need to write ErrorDTO classes from scratch
250
- - Correct implementation out of the box
251
- - Reusable across all endpoints
252
- - Supports inheritance for custom logic
249
+ - No need to write ErrorDTO classes from scratch
250
+ - Correct implementation out of the box
251
+ - Reusable across all endpoints
252
+ - Supports inheritance for custom logic
253
253
 
254
254
  ### 5. Mixed Usage
255
255
 
@@ -319,6 +319,8 @@ class ErrorDTO(Protocol):
319
319
 
320
320
  Any class implementing this protocol (through structural typing) can be used with `Errors()`.
321
321
 
322
+ **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.
323
+
322
324
  ### When to Use Protocol vs BaseErrorDTO
323
325
 
324
326
  **Use Protocol (structural typing)** when:
@@ -333,6 +335,71 @@ Any class implementing this protocol (through structural typing) can be used wit
333
335
 
334
336
  Both approaches work together — you can mix them in the same `Errors()` call!
335
337
 
338
+ ## Best Practice: Connecting Exceptions and ErrorDTO
339
+
340
+ ### Problem
341
+
342
+ It's not always clear which exception corresponds to which ErrorDTO:
343
+
344
+ ```python
345
+ # Not clear which exception this documents
346
+ responses=Errors(notification_not_found_error)
347
+ ```
348
+
349
+ ### Solution: Domain Exception as ErrorDTO
350
+
351
+ **Recommended approach** — make your exceptions implement ErrorDTO protocol:
352
+
353
+ ```python
354
+ # domain/exceptions.py
355
+ from typing import Dict, Any
356
+
357
+ class DomainException(Exception):
358
+ """Base exception implementing ErrorDTO protocol."""
359
+ status_code: int
360
+ message: str
361
+
362
+ def to_example(self) -> Dict[str, Any]:
363
+ return {self.message: {"value": {"detail": self.message}}}
364
+
365
+ @classmethod
366
+ def for_openapi(cls):
367
+ """Returns instance for OpenAPI documentation."""
368
+ return cls()
369
+
370
+ class NotificationNotFoundError(DomainException):
371
+ status_code = 404
372
+ message = "Notification not found"
373
+
374
+ def __init__(self, notification_id: str = ""):
375
+ self.notification_id = notification_id
376
+ super().__init__(self.message)
377
+
378
+ @classmethod
379
+ def for_openapi(cls):
380
+ return cls(notification_id="example_id")
381
+
382
+ # In endpoint
383
+ @router.delete(
384
+ "/{notificationId}",
385
+ responses=Errors(
386
+ NotificationNotFoundError.for_openapi(), # Clear connection!
387
+ ),
388
+ )
389
+ async def delete_notification(notification_id: str):
390
+ if not notification:
391
+ raise NotificationNotFoundError(notification_id) # Same exception!
392
+ ```
393
+
394
+ **Benefits:**
395
+ - Exception and ErrorDTO are one class
396
+ - Clear connection visible in endpoint
397
+ - No duplication
398
+ - Type-safe
399
+ - Works with any project architecture
400
+
401
+ See [examples/domain_exceptions.py](examples/domain_exceptions.py) for complete example.
402
+
336
403
  ## Compatibility with Existing Projects
337
404
 
338
405
  If your project already has error DTOs (like `ApiErrorDTO`), they can work with `fastapi-errors-plus` if they implement the `ErrorDTO` protocol:
@@ -375,11 +442,11 @@ Errors(
375
442
  *errors: Union[Dict[int, Dict[str, Any]], ErrorDTO],
376
443
  unauthorized: bool = False,
377
444
  forbidden: bool = False,
378
- validation_error: bool = True, # Defaults to True (FastAPI validates all parameters)
445
+ validation_error: Optional[bool] = None, # None (default) => True (FastAPI validates all parameters)
379
446
  internal_server_error: bool = False,
380
447
  unauthorized_401: bool = False,
381
448
  forbidden_403: bool = False,
382
- validation_error_422: bool = True, # Defaults to True (FastAPI validates all parameters)
449
+ validation_error_422: Optional[bool] = None, # None (default) => True (FastAPI validates all parameters)
383
450
  internal_server_error_500: bool = False,
384
451
  )
385
452
  ```
@@ -388,18 +455,25 @@ Errors(
388
455
  - `*errors`: Arbitrary errors as dict or ErrorDTO objects
389
456
  - `unauthorized_401`: Add 401 Unauthorized error (recommended, explicit). Defaults to `False`.
390
457
  - `forbidden_403`: Add 403 Forbidden error (recommended, explicit). Defaults to `False`.
391
- - `validation_error_422`: Add 422 Unprocessable Entity error (recommended, explicit). Defaults to `True` (FastAPI validates all parameters).
458
+ - `validation_error_422`: Add 422 Unprocessable Entity error (recommended, explicit).
459
+ - `None` (default): Add 422 (True by default, FastAPI validates all parameters)
460
+ - `False`: Explicitly disable 422
461
+ - `True`: Explicitly enable 422
392
462
  - `internal_server_error_500`: Add 500 Internal Server Error (recommended, explicit). Defaults to `False`.
393
463
  - `unauthorized`: Add 401 Unauthorized error (legacy, for backward compatibility). Defaults to `False`.
394
464
  - `forbidden`: Add 403 Forbidden error (legacy, for backward compatibility). Defaults to `False`.
395
- - `validation_error`: Add 422 Unprocessable Entity error (legacy, for backward compatibility). Defaults to `True` (FastAPI validates all parameters).
465
+ - `validation_error`: Add 422 Unprocessable Entity error (legacy, for backward compatibility).
466
+ - `None` (default): Add 422 (True by default, FastAPI validates all parameters)
467
+ - `False`: Explicitly disable 422
468
+ - `True`: Explicitly enable 422
396
469
  - `internal_server_error`: Add 500 Internal Server Error (legacy, for backward compatibility). Defaults to `False`.
397
470
 
398
471
  **Why `validation_error=True` by default?**
399
472
  FastAPI automatically validates all parameters (Path, Query, Body), so 422 is relevant in 95%+ of endpoints. For endpoints without parameters, explicitly set `validation_error=False` or `validation_error_422=False`.
400
473
 
401
474
  **Returns:**
402
- - Callable object that returns `Dict[int, Dict[str, Any]]` in FastAPI responses format
475
+ - Mapping object (dict-like) that implements `Dict[int, Dict[str, Any]]` for FastAPI responses
476
+ - Can be used directly in `responses` parameter without calling: `responses=Errors(...)`
403
477
 
404
478
  #### Usage
405
479
 
@@ -5,6 +5,7 @@ fastapi_errors_plus/__init__.py
5
5
  fastapi_errors_plus/base.py
6
6
  fastapi_errors_plus/errors.py
7
7
  fastapi_errors_plus/protocol.py
8
+ fastapi_errors_plus/py.typed
8
9
  fastapi_errors_plus.egg-info/PKG-INFO
9
10
  fastapi_errors_plus.egg-info/SOURCES.txt
10
11
  fastapi_errors_plus.egg-info/dependency_links.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fastapi-errors-plus"
7
- version = "0.4.0"
7
+ version = "0.6.0"
8
8
  description = "Universal library for documenting errors in FastAPI endpoints"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -46,3 +46,29 @@ python_files = "test_*.py"
46
46
  python_classes = "Test*"
47
47
  python_functions = "test_*"
48
48
 
49
+ [tool.coverage.run]
50
+ source = ["fastapi_errors_plus"]
51
+ omit = [
52
+ "*/tests/*",
53
+ "*/__pycache__/*",
54
+ "*/venv/*",
55
+ "*/.venv/*",
56
+ ]
57
+
58
+ [tool.coverage.report]
59
+ exclude_lines = [
60
+ "pragma: no cover",
61
+ "def __repr__",
62
+ "raise AssertionError",
63
+ "raise NotImplementedError",
64
+ "if __name__ == .__main__.:",
65
+ "if TYPE_CHECKING:",
66
+ "@abstractmethod",
67
+ ]
68
+ precision = 2
69
+ show_missing = true
70
+ skip_covered = false
71
+
72
+ [tool.coverage.html]
73
+ directory = "tests/htmlcov"
74
+
@@ -302,3 +302,5 @@ class TestBaseErrorDTOCompatibility:
302
302
  assert status.HTTP_404_NOT_FOUND in responses
303
303
  assert status.HTTP_500_INTERNAL_SERVER_ERROR in responses
304
304
 
305
+
306
+
@@ -446,6 +446,82 @@ class TestErrorsEdgeCases:
446
446
  assert len(errors) == 1
447
447
 
448
448
 
449
+ class TestUniqueKeys:
450
+ """Tests for unique key generation to prevent collisions."""
451
+
452
+ def test_unique_keys_for_standard_errors(self):
453
+ """Test that standard error keys are unique when merging."""
454
+ # First add flag (creates example), then add dict with examples containing same key
455
+ errors = Errors(
456
+ {401: { # Positional first - adds "StandardUnauthorized" to examples
457
+ "content": {
458
+ "application/json": {
459
+ "examples": {
460
+ "StandardUnauthorized": {"value": {"detail": "Custom"}},
461
+ },
462
+ },
463
+ },
464
+ }},
465
+ unauthorized_401=True, # Named after positional - should use unique key
466
+ )
467
+
468
+ # Should have both examples
469
+ examples = errors[401]["content"]["application/json"]["examples"]
470
+ # When dict is added first with "StandardUnauthorized", then flag is added
471
+ # Flag creates "example" which gets converted to "default" (not "StandardUnauthorized_2")
472
+ # This is because flag creates "example" first, then it's converted
473
+ assert "StandardUnauthorized" in examples # From dict
474
+ assert "default" in examples # From flag (converted from example)
475
+
476
+ # Both examples should be present
477
+ assert len(examples) == 2
478
+ assert examples["StandardUnauthorized"]["value"]["detail"] == "Custom"
479
+ assert examples["default"]["value"]["detail"] == "Unauthorized"
480
+
481
+ def test_unique_keys_for_dict_errors(self):
482
+ """Test that dict error keys are unique when merging."""
483
+ errors = Errors(
484
+ {401: {
485
+ "content": {
486
+ "application/json": {
487
+ "example": {"detail": "First"},
488
+ },
489
+ },
490
+ }},
491
+ {401: {
492
+ "content": {
493
+ "application/json": {
494
+ "example": {"detail": "Second"},
495
+ },
496
+ },
497
+ }},
498
+ )
499
+
500
+ examples = errors[401]["content"]["application/json"]["examples"]
501
+ assert "default" in examples
502
+ # Second example should have unique key
503
+ assert "CustomExample" in examples or "CustomExample_2" in examples
504
+ assert len(examples) == 2
505
+
506
+ def test_unique_keys_multiple_collisions(self):
507
+ """Test unique key generation with multiple collisions."""
508
+ errors = Errors()
509
+ examples = {
510
+ "CustomExample": {"value": {"detail": "First"}},
511
+ "CustomExample_2": {"value": {"detail": "Second"}},
512
+ }
513
+
514
+ # Add another CustomExample - should become CustomExample_3
515
+ unique_key = errors._unique_key(examples, "CustomExample")
516
+ assert unique_key == "CustomExample_3"
517
+
518
+ examples[unique_key] = {"value": {"detail": "Third"}}
519
+
520
+ # Add another - should become CustomExample_4
521
+ unique_key = errors._unique_key(examples, "CustomExample")
522
+ assert unique_key == "CustomExample_4"
523
+
524
+
449
525
  class TestErrorsValidation:
450
526
  """Tests for ErrorDTO validation."""
451
527
 
@@ -531,3 +607,50 @@ class TestErrorsValidation:
531
607
  assert "to_example" in error_msg
532
608
  assert "BadObject" in error_msg
533
609
 
610
+
611
+ class TestDTODescriptionPriority:
612
+ """Tests for DTO description priority over standard flags."""
613
+
614
+ def test_dto_overrides_standard_description(self):
615
+ """Test that ErrorDTO description overrides standard flag description."""
616
+ from fastapi_errors_plus import StandardErrorDTO
617
+
618
+ custom_401 = StandardErrorDTO(
619
+ status_code=401,
620
+ message="Custom Unauthorized",
621
+ examples={"Custom": "Custom auth error"},
622
+ )
623
+
624
+ errors = Errors(
625
+ custom_401, # Positional first
626
+ unauthorized_401=True, # Named after - adds "Unauthorized"
627
+ )
628
+
629
+ # DTO should override standard description
630
+ assert errors[401]["description"] == "Custom Unauthorized"
631
+
632
+ def test_dto_does_not_override_custom_description(self):
633
+ """Test that DTO does not override non-standard description."""
634
+ from fastapi_errors_plus import StandardErrorDTO
635
+
636
+ custom_401 = StandardErrorDTO(
637
+ status_code=401,
638
+ message="Another Custom",
639
+ examples={"Custom": "Custom auth error"},
640
+ )
641
+
642
+ errors = Errors(
643
+ {401: { # Positional first - custom description
644
+ "description": "My Custom Description",
645
+ "content": {
646
+ "application/json": {
647
+ "example": {"detail": "Custom"},
648
+ },
649
+ },
650
+ }},
651
+ custom_401, # DTO should not override dict description
652
+ )
653
+
654
+ # Dict description should be preserved (dict > DTO)
655
+ assert errors[401]["description"] == "My Custom Description"
656
+