fastapi-errors-plus 0.8.0__tar.gz → 0.9.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 (26) hide show
  1. {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/PKG-INFO +66 -8
  2. {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/README.md +65 -7
  3. {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus/__init__.py +3 -1
  4. {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus/base.py +6 -0
  5. {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus/error_doc.py +6 -0
  6. fastapi_errors_plus-0.9.0/fastapi_errors_plus/error_profile.py +31 -0
  7. {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus/errors.py +130 -160
  8. fastapi_errors_plus-0.9.0/fastapi_errors_plus/merge_utils.py +61 -0
  9. {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus.egg-info/PKG-INFO +66 -8
  10. {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus.egg-info/SOURCES.txt +4 -1
  11. {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/pyproject.toml +4 -1
  12. {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/tests/test_errors.py +3 -8
  13. fastapi_errors_plus-0.9.0/tests/test_release_09.py +122 -0
  14. {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/LICENSE +0 -0
  15. {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus/example_utils.py +0 -0
  16. {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus/protocol.py +0 -0
  17. {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus/py.typed +0 -0
  18. {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus.egg-info/dependency_links.txt +0 -0
  19. {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus.egg-info/requires.txt +0 -0
  20. {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus.egg-info/top_level.txt +0 -0
  21. {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/setup.cfg +0 -0
  22. {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/tests/test_app.py +0 -0
  23. {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/tests/test_base.py +0 -0
  24. {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/tests/test_integration.py +0 -0
  25. {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/tests/test_release_08.py +0 -0
  26. {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/tests/test_release_08_edge_cases.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-errors-plus
3
- Version: 0.8.0
3
+ Version: 0.9.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
@@ -33,7 +33,7 @@ Dynamic: license-file
33
33
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
34
34
  [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
35
35
  [![FastAPI](https://img.shields.io/badge/FastAPI-0.104%2B-009688.svg)](https://fastapi.tiangolo.com/)
36
- [![Tests](https://img.shields.io/badge/tests-117-success.svg)](https://github.com/seligoroff/fastapi-errors-plus)
36
+ [![Tests](https://img.shields.io/badge/tests-138-success.svg)](https://github.com/seligoroff/fastapi-errors-plus)
37
37
  [![Coverage](https://img.shields.io/badge/coverage-80%25%2B-green.svg)](https://github.com/seligoroff/fastapi-errors-plus)
38
38
 
39
39
  Universal library for documenting errors in FastAPI endpoints.
@@ -108,13 +108,13 @@ Use boolean flags for common HTTP status codes:
108
108
  - `validation_error_422=True` → 422 Unprocessable Entity (defaults to `True`)
109
109
  - `internal_server_error_500=True` → 500 Internal Server Error
110
110
 
111
- **Legacy (for backward compatibility):**
111
+ **Legacy (deprecated in 0.9, removal in 1.0):**
112
112
  - `unauthorized=True` → 401 Unauthorized
113
113
  - `forbidden=True` → 403 Forbidden
114
- - `validation_error=True` → 422 Unprocessable Entity (defaults to `True`)
114
+ - `validation_error=True` → 422 Unprocessable Entity
115
115
  - `internal_server_error=True` → 500 Internal Server Error
116
116
 
117
- **Note:** `validation_error` and `validation_error_422` default to `True` because FastAPI automatically validates all parameters (Path, Query, Body), making 422 relevant in 95%+ of endpoints. Set to `False` only for endpoints without parameters.
117
+ **Note on 422:** If you omit both `validation_error` and `validation_error_422`, the library still adds **422** today but emits a **`DeprecationWarning`** the default will become **`False` in 1.0**. For ADR-style APIs, set `validation_error_422=False` explicitly (or use **`ErrorProfile`** below).
118
118
 
119
119
  ```python
120
120
  @router.get(
@@ -318,6 +318,44 @@ def delete_item(id: int):
318
318
 
319
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
320
 
321
+ Optional **`model=`** (Pydantic model on the outer response object for FastAPI `$ref` registration) and **`schema=`** (JSON Schema under `application/json`) avoid a separate status `dict` for typed error bodies:
322
+
323
+ ```python
324
+ from fastapi_errors_plus import ErrorDoc, Errors
325
+
326
+ conflict = ErrorDoc(
327
+ status_code=409,
328
+ message="BusinessRule",
329
+ model=ApplicationJsonError, # optional Pydantic model
330
+ schema=ADR_ERROR_BODY_SCHEMA, # or raw JSON Schema
331
+ body={"code": "RULE_VIOLATION", "detail": "Item exists"},
332
+ )
333
+
334
+ @router.post("/items", responses=Errors(conflict, validation_error_422=False))
335
+ def create_item():
336
+ ...
337
+ ```
338
+
339
+ #### ErrorProfile
340
+
341
+ Project-wide defaults (frozen — not mutated by endpoint calls):
342
+
343
+ ```python
344
+ from fastapi_errors_plus import ErrorDoc, ErrorProfile, Errors
345
+
346
+ ADR = ErrorProfile(
347
+ validation_error_422=False,
348
+ unauthorized_401=True,
349
+ internal_server_error_500=True,
350
+ )
351
+
352
+ @router.post("/items", responses=Errors(business_conflict, profile=ADR))
353
+ def create_item():
354
+ ...
355
+ ```
356
+
357
+ Explicit `Errors` keyword flags override profile values. Positional dict/DTO errors are merged after profile-driven standard statuses.
358
+
321
359
  ### 5. Mixed Usage
322
360
 
323
361
  Combine flags, dict, and ErrorDTO:
@@ -645,6 +683,7 @@ Errors(
645
683
  forbidden_403: bool = False,
646
684
  validation_error_422: Optional[bool] = None, # None (default) => True (FastAPI validates all parameters)
647
685
  internal_server_error_500: bool = False,
686
+ profile: Optional[ErrorProfile] = None,
648
687
  )
649
688
  ```
650
689
 
@@ -663,10 +702,12 @@ Errors(
663
702
  - `None` (default): Add 422 (True by default, FastAPI validates all parameters)
664
703
  - `False`: Explicitly disable 422
665
704
  - `True`: Explicitly enable 422
666
- - `internal_server_error`: Add 500 Internal Server Error (legacy, for backward compatibility). Defaults to `False`.
705
+ - `internal_server_error`: Add 500 Internal Server Error (legacy, **deprecated** in 0.9). Defaults to `False`.
706
+ - `profile`: Optional **`ErrorProfile`** — project defaults; explicit kwargs override profile.
707
+
708
+ **Deprecation (0.9+):** legacy kwargs above emit `DeprecationWarning` (removal in **1.0**). Omitting both `validation_error` and `validation_error_422` still adds 422 but warns; default becomes **`False` in 1.0**.
667
709
 
668
- **Why `validation_error=True` by default?**
669
- 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`.
710
+ **422 behaviour:** pass `validation_error_422=False` for endpoints without request-body validation or ADR-style error bodies. Use **`ErrorProfile`** to set this once per project.
670
711
 
671
712
  **Returns:**
672
713
  - A dict-like `Mapping[int, …]` suitable for FastAPI’s `responses` / OpenAPI
@@ -714,12 +755,27 @@ ErrorDoc(
714
755
  examples: Optional[Dict[str, str | dict]] = None,
715
756
  body: Optional[Dict[str, Any]] = None,
716
757
  example_key: Optional[str] = None,
758
+ model: Any = None,
759
+ schema: Optional[Dict[str, Any]] = None,
717
760
  openapi_json_extras: Optional[Dict[str, Any]] = None,
718
761
  )
719
762
  ```
720
763
 
721
764
  When **`examples`** is omitted, a single example is built from **`body`** or `{"detail": message}`.
722
765
 
766
+ ### `ErrorProfile`
767
+
768
+ Frozen project-wide defaults for standard HTTP flags.
769
+
770
+ ```python
771
+ ErrorProfile(
772
+ unauthorized_401: bool = False,
773
+ forbidden_403: bool = False,
774
+ validation_error_422: Optional[bool] = None,
775
+ internal_server_error_500: bool = False,
776
+ )
777
+ ```
778
+
723
779
  ### `BaseErrorDTO`
724
780
 
725
781
  Base implementation of ErrorDTO Protocol for convenience.
@@ -729,6 +785,8 @@ Base implementation of ErrorDTO Protocol for convenience.
729
785
  BaseErrorDTO(
730
786
  status_code: int,
731
787
  message: str,
788
+ model: Any = None,
789
+ schema: Optional[Dict[str, Any]] = None,
732
790
  openapi_json_extras: Optional[Dict[str, Any]] = None,
733
791
  )
734
792
  ```
@@ -4,7 +4,7 @@
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
5
  [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
6
6
  [![FastAPI](https://img.shields.io/badge/FastAPI-0.104%2B-009688.svg)](https://fastapi.tiangolo.com/)
7
- [![Tests](https://img.shields.io/badge/tests-117-success.svg)](https://github.com/seligoroff/fastapi-errors-plus)
7
+ [![Tests](https://img.shields.io/badge/tests-138-success.svg)](https://github.com/seligoroff/fastapi-errors-plus)
8
8
  [![Coverage](https://img.shields.io/badge/coverage-80%25%2B-green.svg)](https://github.com/seligoroff/fastapi-errors-plus)
9
9
 
10
10
  Universal library for documenting errors in FastAPI endpoints.
@@ -79,13 +79,13 @@ Use boolean flags for common HTTP status codes:
79
79
  - `validation_error_422=True` → 422 Unprocessable Entity (defaults to `True`)
80
80
  - `internal_server_error_500=True` → 500 Internal Server Error
81
81
 
82
- **Legacy (for backward compatibility):**
82
+ **Legacy (deprecated in 0.9, removal in 1.0):**
83
83
  - `unauthorized=True` → 401 Unauthorized
84
84
  - `forbidden=True` → 403 Forbidden
85
- - `validation_error=True` → 422 Unprocessable Entity (defaults to `True`)
85
+ - `validation_error=True` → 422 Unprocessable Entity
86
86
  - `internal_server_error=True` → 500 Internal Server Error
87
87
 
88
- **Note:** `validation_error` and `validation_error_422` default to `True` because FastAPI automatically validates all parameters (Path, Query, Body), making 422 relevant in 95%+ of endpoints. Set to `False` only for endpoints without parameters.
88
+ **Note on 422:** If you omit both `validation_error` and `validation_error_422`, the library still adds **422** today but emits a **`DeprecationWarning`** the default will become **`False` in 1.0**. For ADR-style APIs, set `validation_error_422=False` explicitly (or use **`ErrorProfile`** below).
89
89
 
90
90
  ```python
91
91
  @router.get(
@@ -289,6 +289,44 @@ def delete_item(id: int):
289
289
 
290
290
  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`).
291
291
 
292
+ Optional **`model=`** (Pydantic model on the outer response object for FastAPI `$ref` registration) and **`schema=`** (JSON Schema under `application/json`) avoid a separate status `dict` for typed error bodies:
293
+
294
+ ```python
295
+ from fastapi_errors_plus import ErrorDoc, Errors
296
+
297
+ conflict = ErrorDoc(
298
+ status_code=409,
299
+ message="BusinessRule",
300
+ model=ApplicationJsonError, # optional Pydantic model
301
+ schema=ADR_ERROR_BODY_SCHEMA, # or raw JSON Schema
302
+ body={"code": "RULE_VIOLATION", "detail": "Item exists"},
303
+ )
304
+
305
+ @router.post("/items", responses=Errors(conflict, validation_error_422=False))
306
+ def create_item():
307
+ ...
308
+ ```
309
+
310
+ #### ErrorProfile
311
+
312
+ Project-wide defaults (frozen — not mutated by endpoint calls):
313
+
314
+ ```python
315
+ from fastapi_errors_plus import ErrorDoc, ErrorProfile, Errors
316
+
317
+ ADR = ErrorProfile(
318
+ validation_error_422=False,
319
+ unauthorized_401=True,
320
+ internal_server_error_500=True,
321
+ )
322
+
323
+ @router.post("/items", responses=Errors(business_conflict, profile=ADR))
324
+ def create_item():
325
+ ...
326
+ ```
327
+
328
+ Explicit `Errors` keyword flags override profile values. Positional dict/DTO errors are merged after profile-driven standard statuses.
329
+
292
330
  ### 5. Mixed Usage
293
331
 
294
332
  Combine flags, dict, and ErrorDTO:
@@ -616,6 +654,7 @@ Errors(
616
654
  forbidden_403: bool = False,
617
655
  validation_error_422: Optional[bool] = None, # None (default) => True (FastAPI validates all parameters)
618
656
  internal_server_error_500: bool = False,
657
+ profile: Optional[ErrorProfile] = None,
619
658
  )
620
659
  ```
621
660
 
@@ -634,10 +673,12 @@ Errors(
634
673
  - `None` (default): Add 422 (True by default, FastAPI validates all parameters)
635
674
  - `False`: Explicitly disable 422
636
675
  - `True`: Explicitly enable 422
637
- - `internal_server_error`: Add 500 Internal Server Error (legacy, for backward compatibility). Defaults to `False`.
676
+ - `internal_server_error`: Add 500 Internal Server Error (legacy, **deprecated** in 0.9). Defaults to `False`.
677
+ - `profile`: Optional **`ErrorProfile`** — project defaults; explicit kwargs override profile.
678
+
679
+ **Deprecation (0.9+):** legacy kwargs above emit `DeprecationWarning` (removal in **1.0**). Omitting both `validation_error` and `validation_error_422` still adds 422 but warns; default becomes **`False` in 1.0**.
638
680
 
639
- **Why `validation_error=True` by default?**
640
- 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`.
681
+ **422 behaviour:** pass `validation_error_422=False` for endpoints without request-body validation or ADR-style error bodies. Use **`ErrorProfile`** to set this once per project.
641
682
 
642
683
  **Returns:**
643
684
  - A dict-like `Mapping[int, …]` suitable for FastAPI’s `responses` / OpenAPI
@@ -685,12 +726,27 @@ ErrorDoc(
685
726
  examples: Optional[Dict[str, str | dict]] = None,
686
727
  body: Optional[Dict[str, Any]] = None,
687
728
  example_key: Optional[str] = None,
729
+ model: Any = None,
730
+ schema: Optional[Dict[str, Any]] = None,
688
731
  openapi_json_extras: Optional[Dict[str, Any]] = None,
689
732
  )
690
733
  ```
691
734
 
692
735
  When **`examples`** is omitted, a single example is built from **`body`** or `{"detail": message}`.
693
736
 
737
+ ### `ErrorProfile`
738
+
739
+ Frozen project-wide defaults for standard HTTP flags.
740
+
741
+ ```python
742
+ ErrorProfile(
743
+ unauthorized_401: bool = False,
744
+ forbidden_403: bool = False,
745
+ validation_error_422: Optional[bool] = None,
746
+ internal_server_error_500: bool = False,
747
+ )
748
+ ```
749
+
694
750
  ### `BaseErrorDTO`
695
751
 
696
752
  Base implementation of ErrorDTO Protocol for convenience.
@@ -700,6 +756,8 @@ Base implementation of ErrorDTO Protocol for convenience.
700
756
  BaseErrorDTO(
701
757
  status_code: int,
702
758
  message: str,
759
+ model: Any = None,
760
+ schema: Optional[Dict[str, Any]] = None,
703
761
  openapi_json_extras: Optional[Dict[str, Any]] = None,
704
762
  )
705
763
  ```
@@ -2,6 +2,7 @@
2
2
 
3
3
  from fastapi_errors_plus.base import BaseErrorDTO, StandardErrorDTO
4
4
  from fastapi_errors_plus.error_doc import ErrorDoc
5
+ from fastapi_errors_plus.error_profile import ErrorProfile
5
6
  from fastapi_errors_plus.errors import Errors
6
7
  from fastapi_errors_plus.protocol import ErrorDTO, ErrorDTOLike, LegacyErrorDTO
7
8
 
@@ -11,7 +12,8 @@ __all__ = [
11
12
  "LegacyErrorDTO",
12
13
  "ErrorDTOLike",
13
14
  "ErrorDoc",
15
+ "ErrorProfile",
14
16
  "BaseErrorDTO",
15
17
  "StandardErrorDTO",
16
18
  ]
17
- __version__ = "0.8.0"
19
+ __version__ = "0.9.0"
@@ -41,6 +41,12 @@ class BaseErrorDTO:
41
41
  message: str
42
42
  """Error message description."""
43
43
 
44
+ model: Any = field(default=None, repr=False)
45
+ """Optional Pydantic model for FastAPI ``responses`` (outer ``model=`` key)."""
46
+
47
+ schema: Optional[Dict[str, Any]] = field(default=None, repr=False)
48
+ """Optional JSON Schema under ``content['application/json']['schema']``."""
49
+
44
50
  openapi_json_extras: Optional[Dict[str, Any]] = field(default=None)
45
51
  """Optional OpenAPI fragment under ``content['application/json']`` besides examples,
46
52
  typically ``{\"schema\": ...}`` or ``encoding``. Do **not** put ``example`` / ``examples``
@@ -39,6 +39,12 @@ class ErrorDoc:
39
39
  examples: Optional[Dict[str, ExampleSpec]] = None
40
40
  body: Optional[Dict[str, Any]] = None
41
41
  example_key: Optional[str] = None
42
+ model: Any = field(default=None, repr=False)
43
+ """Optional Pydantic model for FastAPI ``responses`` (outer ``model=`` key)."""
44
+
45
+ schema: Optional[Dict[str, Any]] = field(default=None, repr=False)
46
+ """Optional JSON Schema under ``content['application/json']['schema']``."""
47
+
42
48
  openapi_json_extras: Optional[Dict[str, Any]] = field(default=None)
43
49
 
44
50
  def to_examples(self) -> Dict[str, Any]:
@@ -0,0 +1,31 @@
1
+ """Project-wide defaults for :class:`Errors` (release 0.9)."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class ErrorProfile:
9
+ """Immutable project defaults applied before per-endpoint ``Errors(...)`` args.
10
+
11
+ Explicit ``Errors`` keyword flags override profile values. Positional dict/DTO
12
+ errors are merged after profile-driven standard statuses.
13
+
14
+ Example:
15
+ ```python
16
+ ADR = ErrorProfile(
17
+ validation_error_422=False,
18
+ unauthorized_401=True,
19
+ internal_server_error_500=True,
20
+ )
21
+
22
+ @router.post("/items", responses=Errors(business_conflict, profile=ADR))
23
+ def create_item():
24
+ ...
25
+ ```
26
+ """
27
+
28
+ unauthorized_401: bool = False
29
+ forbidden_403: bool = False
30
+ validation_error_422: Optional[bool] = None
31
+ internal_server_error_500: bool = False
@@ -8,7 +8,14 @@ from typing import Any, Dict, Iterator, Optional, Union
8
8
 
9
9
  from fastapi import status
10
10
 
11
- from fastapi_errors_plus.protocol import ErrorDTO
11
+ from fastapi_errors_plus.error_profile import ErrorProfile
12
+ from fastapi_errors_plus.merge_utils import (
13
+ ensure_examples_dict,
14
+ merge_examples_map,
15
+ merge_singular_example,
16
+ standard_flag_example_key,
17
+ )
18
+ from fastapi_errors_plus.protocol import ErrorDTO, LegacyErrorDTO
12
19
 
13
20
  # Starlette (via FastAPI) prefers HTTP_422_UNPROCESSABLE_CONTENT over ENTITY.
14
21
  # Older pins may lack CONTENT (import crash); avoid nested getattr(, default=)
@@ -38,17 +45,26 @@ def _pick_error_dto_application_json_extra(error_dto: Any) -> Optional[Dict[str,
38
45
  """Optional OpenAPI ``application/json`` keys from DTO (e.g. ``schema``, ``encoding``).
39
46
 
40
47
  Prefer ``to_openapi_json_media_type_extras()`` when present and returns a truthy mapping;
41
- otherwise ``openapi_json_extras`` attribute. Must not rely on ``example`` / ``examples`` here —
42
- ``to_example()`` covers those."""
48
+ otherwise ``schema`` field, then ``openapi_json_extras``. Must not rely on ``example`` /
49
+ ``examples`` here — ``to_examples()`` covers those."""
50
+ extras: Dict[str, Any] = {}
51
+ schema = getattr(error_dto, "schema", None)
52
+ if isinstance(schema, dict) and schema:
53
+ extras["schema"] = schema
54
+ attr_extra = getattr(error_dto, "openapi_json_extras", None)
55
+ if isinstance(attr_extra, dict) and attr_extra:
56
+ extras.update(attr_extra)
43
57
  getter = getattr(error_dto, "to_openapi_json_media_type_extras", None)
44
58
  if callable(getter):
45
59
  out = getter()
46
60
  if isinstance(out, dict) and out:
47
- return dict(out)
48
- extra = getattr(error_dto, "openapi_json_extras", None)
49
- if isinstance(extra, dict) and extra:
50
- return dict(extra)
51
- return None
61
+ extras.update(out)
62
+ return extras or None
63
+
64
+
65
+ def _pick_error_dto_model(error_dto: Any) -> Any:
66
+ """Optional FastAPI ``model`` on the outer response object."""
67
+ return getattr(error_dto, "model", None)
52
68
 
53
69
 
54
70
  def _example_defining_class(cls: type, method: str) -> Optional[type]:
@@ -174,6 +190,7 @@ class Errors(Mapping):
174
190
  bool
175
191
  ] = None, # 422 (explicit, None = use default True, False = disable, True = enable)
176
192
  internal_server_error_500: bool = False, # 500 (explicit)
193
+ profile: Optional[ErrorProfile] = None,
177
194
  ) -> None:
178
195
  """Initialize Errors instance.
179
196
 
@@ -224,31 +241,76 @@ class Errors(Mapping):
224
241
  ```
225
242
  """
226
243
  self._responses: Dict[int, Dict[str, Any]] = {}
244
+ self._flag_example_keys: Dict[int, str] = {}
245
+
246
+ if unauthorized:
247
+ warnings.warn(
248
+ "unauthorized is deprecated; use unauthorized_401 instead.",
249
+ DeprecationWarning,
250
+ stacklevel=2,
251
+ )
252
+ if forbidden:
253
+ warnings.warn(
254
+ "forbidden is deprecated; use forbidden_403 instead.",
255
+ DeprecationWarning,
256
+ stacklevel=2,
257
+ )
258
+ if validation_error is not None:
259
+ warnings.warn(
260
+ "validation_error is deprecated; use validation_error_422 instead.",
261
+ DeprecationWarning,
262
+ stacklevel=2,
263
+ )
264
+ if internal_server_error:
265
+ warnings.warn(
266
+ "internal_server_error is deprecated; use internal_server_error_500 instead.",
267
+ DeprecationWarning,
268
+ stacklevel=2,
269
+ )
270
+
271
+ use_unauthorized = unauthorized_401 or unauthorized
272
+ use_forbidden = forbidden_403 or forbidden
273
+ use_internal = internal_server_error_500 or internal_server_error
274
+ val_422 = (
275
+ validation_error_422
276
+ if validation_error_422 is not None
277
+ else validation_error
278
+ )
279
+
280
+ if profile is not None:
281
+ if not use_unauthorized:
282
+ use_unauthorized = profile.unauthorized_401
283
+ if not use_forbidden:
284
+ use_forbidden = profile.forbidden_403
285
+ if not use_internal:
286
+ use_internal = profile.internal_server_error_500
287
+ if val_422 is None:
288
+ val_422 = profile.validation_error_422
227
289
 
228
290
  # Add standard errors
229
- # New parameters with explicit codes have priority, but old ones still work
230
- if unauthorized_401 or unauthorized:
291
+ if use_unauthorized:
231
292
  self._add_standard_error(
232
293
  status.HTTP_401_UNAUTHORIZED,
233
294
  "Unauthorized",
234
295
  {"detail": "Unauthorized"},
235
296
  )
236
- if forbidden_403 or forbidden:
297
+ if use_forbidden:
237
298
  self._add_standard_error(
238
299
  status.HTTP_403_FORBIDDEN,
239
300
  "Forbidden",
240
301
  {"detail": "Forbidden"},
241
302
  )
242
- # For validation_error: True by default (FastAPI validates all parameters)
243
- # Add 422 unless at least one parameter is explicitly False
244
- # If user sets validation_error=False, they want to disable 422 (even if validation_error_422 defaults to True)
245
- # If user sets validation_error_422=False, they want to disable 422 (even if validation_error defaults to True)
246
- # Only if both are explicitly False, we definitely don't add
247
- # If at least one is True (or None which defaults to True) and neither is explicitly False, add
248
- add_422 = True # Default is True
249
- if validation_error is False or validation_error_422 is False:
250
- # At least one is explicitly False - don't add
303
+ add_422 = True
304
+ if val_422 is False:
251
305
  add_422 = False
306
+ elif val_422 is None:
307
+ add_422 = True
308
+ warnings.warn(
309
+ "Implicit validation_error_422=True is deprecated and will default to "
310
+ "False in 1.0. Pass validation_error_422=False explicitly to silence.",
311
+ DeprecationWarning,
312
+ stacklevel=2,
313
+ )
252
314
 
253
315
  if add_422:
254
316
  self._add_standard_error(
@@ -256,7 +318,7 @@ class Errors(Mapping):
256
318
  "Validation Error",
257
319
  {"detail": "Validation error"},
258
320
  )
259
- if internal_server_error_500 or internal_server_error:
321
+ if use_internal:
260
322
  self._add_standard_error(
261
323
  status.HTTP_500_INTERNAL_SERVER_ERROR,
262
324
  "Internal Server Error",
@@ -277,37 +339,16 @@ class Errors(Mapping):
277
339
  _ensure_response_descriptions(self._responses)
278
340
 
279
341
  def _validate_error_dto(self, error: Any) -> None:
280
- """Validate that error object implements ErrorDTO protocol.
281
-
282
- Args:
283
- error: Object to validate
284
-
285
- Raises:
286
- TypeError: If object doesn't implement ErrorDTO protocol
287
-
288
- Example:
289
- ```python
290
- # Valid object
291
- class MyError:
292
- status_code = 404
293
- message = "Not found"
294
- def to_example(self): ...
295
-
296
- errors = Errors()
297
- errors._validate_error_dto(MyError()) # OK
298
-
299
- # Invalid object
300
- class BadError:
301
- status_code = 404
302
- # Missing message and to_example
303
-
304
- errors._validate_error_dto(BadError()) # Raises TypeError
305
- ```
306
- """
307
- required_attrs = ("status_code", "message")
342
+ """Validate that error object implements ErrorDTO or LegacyErrorDTO protocol."""
308
343
  has_to_examples = callable(getattr(error, "to_examples", None))
309
344
  has_to_example = callable(getattr(error, "to_example", None))
310
345
 
346
+ if isinstance(error, ErrorDTO) and has_to_examples:
347
+ return
348
+ if isinstance(error, LegacyErrorDTO) and has_to_example:
349
+ return
350
+
351
+ required_attrs = ("status_code", "message")
311
352
  missing_attrs = [attr for attr in required_attrs if not hasattr(error, attr)]
312
353
  if not has_to_examples and not has_to_example:
313
354
  missing_methods = ["to_examples or to_example"]
@@ -355,6 +396,10 @@ class Errors(Mapping):
355
396
  status.HTTP_500_INTERNAL_SERVER_ERROR: "Internal Server Error",
356
397
  }
357
398
 
399
+ def _prior_singular_example_key(self, status_code: int) -> Optional[str]:
400
+ """Example-map key for a singular ``example`` from a standard flag, if any."""
401
+ return self._flag_example_keys.get(status_code)
402
+
358
403
  def _add_standard_error(
359
404
  self,
360
405
  status_code: int,
@@ -365,22 +410,11 @@ class Errors(Mapping):
365
410
 
366
411
  If the status code already exists, merges the example with existing examples
367
412
  using a unique key instead of overwriting.
368
-
369
- Args:
370
- status_code: HTTP status code (e.g., 401, 403, 422, 500)
371
- description: Error description for OpenAPI
372
- example: Example response body (e.g., {"detail": "Unauthorized"})
373
413
  """
374
- # Generate unique key for standard example
375
- standard_keys = {
376
- status.HTTP_401_UNAUTHORIZED: "StandardUnauthorized",
377
- status.HTTP_403_FORBIDDEN: "StandardForbidden",
378
- _HTTP_422: "StandardValidationError",
379
- status.HTTP_500_INTERNAL_SERVER_ERROR: "StandardInternalServerError",
380
- }
381
- example_key = standard_keys.get(status_code, f"Standard{status_code}")
414
+ example_key = standard_flag_example_key(status_code)
382
415
 
383
416
  if status_code not in self._responses:
417
+ self._flag_example_keys[status_code] = example_key
384
418
  self._responses[status_code] = {
385
419
  "description": description,
386
420
  "content": {
@@ -390,29 +424,13 @@ class Errors(Mapping):
390
424
  },
391
425
  }
392
426
  else:
393
- # If already exists, add to examples without overwriting
394
- # Safely access content/application/json with defaults
395
427
  existing = self._responses[status_code]
396
428
  existing_content = existing.setdefault("content", {})
397
429
  content = existing_content.setdefault("application/json", {})
398
-
399
- # Convert example to examples if needed
400
- if "examples" not in content:
401
- if "example" in content:
402
- existing_example = content.pop("example")
403
- content["examples"] = {
404
- "default": {"value": existing_example},
405
- }
406
- else:
407
- content["examples"] = {}
408
-
409
- # Add new example with unique key (don't overwrite existing!)
410
- # Check if example_key already exists in examples, use unique key if needed
411
- unique_key = self._unique_key(content["examples"], example_key)
412
- content["examples"][unique_key] = {"value": example}
413
-
414
- # Don't overwrite description if it already exists
415
- # Priority: dict > DTO > standard flags
430
+ prior_key = self._prior_singular_example_key(status_code)
431
+ examples = ensure_examples_dict(content, prior_singular_key=prior_key)
432
+ unique_key = self._unique_key(examples, example_key)
433
+ examples[unique_key] = {"value": example}
416
434
 
417
435
  def _add_dict_error(self, error_dict: Dict[int, Dict[str, Any]]) -> None:
418
436
  """Add error from dict in FastAPI responses format.
@@ -457,58 +475,24 @@ class Errors(Mapping):
457
475
 
458
476
  # Handle example/examples
459
477
  if "examples" in response_json:
460
- # Response has examples
461
- if "examples" not in existing_json:
462
- # Convert existing example to examples if needed
463
- if "example" in existing_json:
464
- existing_example = existing_json.pop("example")
465
- existing_json["examples"] = {
466
- "default": {"value": existing_example},
467
- }
468
- else:
469
- existing_json["examples"] = {}
470
-
471
- # Merge examples
472
- existing_json["examples"].update(
473
- copy.deepcopy(response_json["examples"])
478
+ prior_key = self._prior_singular_example_key(status_code)
479
+ incoming_examples = response_json["examples"]
480
+ if prior_key and prior_key in incoming_examples:
481
+ prior_key = "default"
482
+ merge_examples_map(
483
+ existing_json,
484
+ copy.deepcopy(incoming_examples),
485
+ prior_singular_key=prior_key,
474
486
  )
475
487
  elif "example" in response_json:
476
- # Response has example - convert and merge
477
- if "examples" not in existing_json:
478
- # Convert existing example to examples if needed
479
- if "example" in existing_json:
480
- existing_example = existing_json.pop("example")
481
- # Check if existing example is from a standard flag
482
- # Standard flags use specific messages
483
- standard_messages = {
484
- "Unauthorized": "StandardUnauthorized",
485
- "Forbidden": "StandardForbidden",
486
- "Validation error": "StandardValidationError",
487
- "Internal Server Error": "StandardInternalServerError",
488
- }
489
- existing_detail = existing_example.get("detail", "")
490
- example_key = standard_messages.get(
491
- existing_detail, "default"
492
- )
493
- existing_json["examples"] = {
494
- example_key: {"value": existing_example},
495
- }
496
- else:
497
- existing_json["examples"] = {}
498
-
499
- # Add new example - use "default" if available, otherwise unique key
500
- if "default" not in existing_json["examples"]:
501
- existing_json["examples"]["default"] = {
502
- "value": response_json["example"]
503
- }
504
- else:
505
- # If default exists, add with a unique key
506
- unique_key = self._unique_key(
507
- existing_json["examples"], "CustomExample"
508
- )
509
- existing_json["examples"][unique_key] = {
510
- "value": response_json["example"]
511
- }
488
+ merge_singular_example(
489
+ existing_json,
490
+ response_json["example"],
491
+ prior_singular_key=self._prior_singular_example_key(
492
+ status_code
493
+ ),
494
+ unique_key_fn=self._unique_key,
495
+ )
512
496
 
513
497
  def _add_error_dto(self, error_dto: ErrorDTO) -> None:
514
498
  """Add error from ErrorDTO (via Protocol).
@@ -536,6 +520,7 @@ class Errors(Mapping):
536
520
  dto_extras = _pick_error_dto_application_json_extra(error_dto)
537
521
  if dto_extras is not None:
538
522
  dto_extras = copy.deepcopy(dto_extras)
523
+ dto_model = _pick_error_dto_model(error_dto)
539
524
 
540
525
  if status_code not in self._responses:
541
526
  application_json: Dict[str, Any] = {"examples": examples}
@@ -543,46 +528,31 @@ class Errors(Mapping):
543
528
  _merge_openapi_application_json_non_example(
544
529
  application_json, dto_extras
545
530
  )
546
- self._responses[status_code] = {
531
+ response_block: Dict[str, Any] = {
547
532
  "description": error_dto.message,
548
533
  "content": {
549
534
  "application/json": application_json,
550
535
  },
551
536
  }
537
+ if dto_model is not None:
538
+ response_block["model"] = dto_model
539
+ self._responses[status_code] = response_block
552
540
  else:
553
- # Merge examples for the same status code
554
- # Safely access content/application/json with defaults
555
541
  existing = self._responses[status_code]
556
542
 
557
543
  _apply_dto_description(existing, error_dto, self.STANDARD_DESCRIPTIONS)
558
544
 
545
+ if dto_model is not None:
546
+ existing["model"] = dto_model
547
+
559
548
  existing_content = existing.setdefault("content", {})
560
549
  content_json = existing_content.setdefault("application/json", {})
561
- existing_examples = content_json.get("examples", {})
562
-
563
- if "example" in content_json:
564
- # Convert example to examples with correct key for standard flags
565
- existing_example = content_json.pop("example")
566
- existing_detail = existing_example.get("detail", "")
567
-
568
- # Check if existing example is from a standard flag
569
- standard_messages = {
570
- "Unauthorized": "StandardUnauthorized",
571
- "Forbidden": "StandardForbidden",
572
- "Validation error": "StandardValidationError",
573
- "Internal Server Error": "StandardInternalServerError",
574
- }
575
- example_key = standard_messages.get(existing_detail, "default")
576
-
577
- existing_examples = {
578
- example_key: {
579
- "value": existing_example,
580
- },
581
- }
582
-
583
- # Merge new examples from ErrorDTO
584
- existing_examples.update(copy.deepcopy(examples))
585
- content_json["examples"] = existing_examples
550
+ prior_key = self._prior_singular_example_key(status_code)
551
+ merge_examples_map(
552
+ content_json,
553
+ copy.deepcopy(examples),
554
+ prior_singular_key=prior_key,
555
+ )
586
556
 
587
557
  if dto_extras is not None:
588
558
  _merge_openapi_application_json_non_example(content_json, dto_extras)
@@ -0,0 +1,61 @@
1
+ """Shared OpenAPI example merge helpers (internal)."""
2
+
3
+ from typing import Any, Callable, Dict, Optional
4
+
5
+ # Example keys produced by standard HTTP status flags in :class:`Errors`.
6
+ STANDARD_FLAG_EXAMPLE_KEYS: Dict[int, str] = {
7
+ 401: "StandardUnauthorized",
8
+ 403: "StandardForbidden",
9
+ 422: "StandardValidationError",
10
+ 500: "StandardInternalServerError",
11
+ }
12
+
13
+
14
+ def standard_flag_example_key(status_code: int) -> str:
15
+ """Return the examples-map key for a standard flag on *status_code*."""
16
+ return STANDARD_FLAG_EXAMPLE_KEYS.get(status_code, f"Standard{status_code}")
17
+
18
+
19
+ def ensure_examples_dict(
20
+ media_json: Dict[str, Any],
21
+ *,
22
+ prior_singular_key: Optional[str] = None,
23
+ ) -> Dict[str, Any]:
24
+ """Ensure ``media_json`` has an ``examples`` dict; promote singular ``example`` if needed."""
25
+ if "examples" in media_json:
26
+ return media_json["examples"]
27
+ examples: Dict[str, Any] = {}
28
+ if "example" in media_json:
29
+ key = prior_singular_key or "default"
30
+ examples[key] = {"value": media_json.pop("example")}
31
+ media_json["examples"] = examples
32
+ return examples
33
+
34
+
35
+ def merge_singular_example(
36
+ media_json: Dict[str, Any],
37
+ example_body: Dict[str, Any],
38
+ *,
39
+ prior_singular_key: Optional[str] = None,
40
+ incoming_key: Optional[str] = None,
41
+ unique_key_fn: Callable[[Dict[str, Any], str], str],
42
+ ) -> None:
43
+ """Merge one OpenAPI ``example`` body into ``media_json`` as part of ``examples``."""
44
+ examples = ensure_examples_dict(media_json, prior_singular_key=prior_singular_key)
45
+ target_key = incoming_key or "default"
46
+ if target_key not in examples:
47
+ examples[target_key] = {"value": example_body}
48
+ else:
49
+ unique = unique_key_fn(examples, "CustomExample")
50
+ examples[unique] = {"value": example_body}
51
+
52
+
53
+ def merge_examples_map(
54
+ media_json: Dict[str, Any],
55
+ incoming_examples: Dict[str, Any],
56
+ *,
57
+ prior_singular_key: Optional[str] = None,
58
+ ) -> None:
59
+ """Merge an ``examples`` map into ``media_json``."""
60
+ examples = ensure_examples_dict(media_json, prior_singular_key=prior_singular_key)
61
+ examples.update(incoming_examples)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-errors-plus
3
- Version: 0.8.0
3
+ Version: 0.9.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
@@ -33,7 +33,7 @@ Dynamic: license-file
33
33
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
34
34
  [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
35
35
  [![FastAPI](https://img.shields.io/badge/FastAPI-0.104%2B-009688.svg)](https://fastapi.tiangolo.com/)
36
- [![Tests](https://img.shields.io/badge/tests-117-success.svg)](https://github.com/seligoroff/fastapi-errors-plus)
36
+ [![Tests](https://img.shields.io/badge/tests-138-success.svg)](https://github.com/seligoroff/fastapi-errors-plus)
37
37
  [![Coverage](https://img.shields.io/badge/coverage-80%25%2B-green.svg)](https://github.com/seligoroff/fastapi-errors-plus)
38
38
 
39
39
  Universal library for documenting errors in FastAPI endpoints.
@@ -108,13 +108,13 @@ Use boolean flags for common HTTP status codes:
108
108
  - `validation_error_422=True` → 422 Unprocessable Entity (defaults to `True`)
109
109
  - `internal_server_error_500=True` → 500 Internal Server Error
110
110
 
111
- **Legacy (for backward compatibility):**
111
+ **Legacy (deprecated in 0.9, removal in 1.0):**
112
112
  - `unauthorized=True` → 401 Unauthorized
113
113
  - `forbidden=True` → 403 Forbidden
114
- - `validation_error=True` → 422 Unprocessable Entity (defaults to `True`)
114
+ - `validation_error=True` → 422 Unprocessable Entity
115
115
  - `internal_server_error=True` → 500 Internal Server Error
116
116
 
117
- **Note:** `validation_error` and `validation_error_422` default to `True` because FastAPI automatically validates all parameters (Path, Query, Body), making 422 relevant in 95%+ of endpoints. Set to `False` only for endpoints without parameters.
117
+ **Note on 422:** If you omit both `validation_error` and `validation_error_422`, the library still adds **422** today but emits a **`DeprecationWarning`** the default will become **`False` in 1.0**. For ADR-style APIs, set `validation_error_422=False` explicitly (or use **`ErrorProfile`** below).
118
118
 
119
119
  ```python
120
120
  @router.get(
@@ -318,6 +318,44 @@ def delete_item(id: int):
318
318
 
319
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
320
 
321
+ Optional **`model=`** (Pydantic model on the outer response object for FastAPI `$ref` registration) and **`schema=`** (JSON Schema under `application/json`) avoid a separate status `dict` for typed error bodies:
322
+
323
+ ```python
324
+ from fastapi_errors_plus import ErrorDoc, Errors
325
+
326
+ conflict = ErrorDoc(
327
+ status_code=409,
328
+ message="BusinessRule",
329
+ model=ApplicationJsonError, # optional Pydantic model
330
+ schema=ADR_ERROR_BODY_SCHEMA, # or raw JSON Schema
331
+ body={"code": "RULE_VIOLATION", "detail": "Item exists"},
332
+ )
333
+
334
+ @router.post("/items", responses=Errors(conflict, validation_error_422=False))
335
+ def create_item():
336
+ ...
337
+ ```
338
+
339
+ #### ErrorProfile
340
+
341
+ Project-wide defaults (frozen — not mutated by endpoint calls):
342
+
343
+ ```python
344
+ from fastapi_errors_plus import ErrorDoc, ErrorProfile, Errors
345
+
346
+ ADR = ErrorProfile(
347
+ validation_error_422=False,
348
+ unauthorized_401=True,
349
+ internal_server_error_500=True,
350
+ )
351
+
352
+ @router.post("/items", responses=Errors(business_conflict, profile=ADR))
353
+ def create_item():
354
+ ...
355
+ ```
356
+
357
+ Explicit `Errors` keyword flags override profile values. Positional dict/DTO errors are merged after profile-driven standard statuses.
358
+
321
359
  ### 5. Mixed Usage
322
360
 
323
361
  Combine flags, dict, and ErrorDTO:
@@ -645,6 +683,7 @@ Errors(
645
683
  forbidden_403: bool = False,
646
684
  validation_error_422: Optional[bool] = None, # None (default) => True (FastAPI validates all parameters)
647
685
  internal_server_error_500: bool = False,
686
+ profile: Optional[ErrorProfile] = None,
648
687
  )
649
688
  ```
650
689
 
@@ -663,10 +702,12 @@ Errors(
663
702
  - `None` (default): Add 422 (True by default, FastAPI validates all parameters)
664
703
  - `False`: Explicitly disable 422
665
704
  - `True`: Explicitly enable 422
666
- - `internal_server_error`: Add 500 Internal Server Error (legacy, for backward compatibility). Defaults to `False`.
705
+ - `internal_server_error`: Add 500 Internal Server Error (legacy, **deprecated** in 0.9). Defaults to `False`.
706
+ - `profile`: Optional **`ErrorProfile`** — project defaults; explicit kwargs override profile.
707
+
708
+ **Deprecation (0.9+):** legacy kwargs above emit `DeprecationWarning` (removal in **1.0**). Omitting both `validation_error` and `validation_error_422` still adds 422 but warns; default becomes **`False` in 1.0**.
667
709
 
668
- **Why `validation_error=True` by default?**
669
- 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`.
710
+ **422 behaviour:** pass `validation_error_422=False` for endpoints without request-body validation or ADR-style error bodies. Use **`ErrorProfile`** to set this once per project.
670
711
 
671
712
  **Returns:**
672
713
  - A dict-like `Mapping[int, …]` suitable for FastAPI’s `responses` / OpenAPI
@@ -714,12 +755,27 @@ ErrorDoc(
714
755
  examples: Optional[Dict[str, str | dict]] = None,
715
756
  body: Optional[Dict[str, Any]] = None,
716
757
  example_key: Optional[str] = None,
758
+ model: Any = None,
759
+ schema: Optional[Dict[str, Any]] = None,
717
760
  openapi_json_extras: Optional[Dict[str, Any]] = None,
718
761
  )
719
762
  ```
720
763
 
721
764
  When **`examples`** is omitted, a single example is built from **`body`** or `{"detail": message}`.
722
765
 
766
+ ### `ErrorProfile`
767
+
768
+ Frozen project-wide defaults for standard HTTP flags.
769
+
770
+ ```python
771
+ ErrorProfile(
772
+ unauthorized_401: bool = False,
773
+ forbidden_403: bool = False,
774
+ validation_error_422: Optional[bool] = None,
775
+ internal_server_error_500: bool = False,
776
+ )
777
+ ```
778
+
723
779
  ### `BaseErrorDTO`
724
780
 
725
781
  Base implementation of ErrorDTO Protocol for convenience.
@@ -729,6 +785,8 @@ Base implementation of ErrorDTO Protocol for convenience.
729
785
  BaseErrorDTO(
730
786
  status_code: int,
731
787
  message: str,
788
+ model: Any = None,
789
+ schema: Optional[Dict[str, Any]] = None,
732
790
  openapi_json_extras: Optional[Dict[str, Any]] = None,
733
791
  )
734
792
  ```
@@ -4,8 +4,10 @@ pyproject.toml
4
4
  fastapi_errors_plus/__init__.py
5
5
  fastapi_errors_plus/base.py
6
6
  fastapi_errors_plus/error_doc.py
7
+ fastapi_errors_plus/error_profile.py
7
8
  fastapi_errors_plus/errors.py
8
9
  fastapi_errors_plus/example_utils.py
10
+ fastapi_errors_plus/merge_utils.py
9
11
  fastapi_errors_plus/protocol.py
10
12
  fastapi_errors_plus/py.typed
11
13
  fastapi_errors_plus.egg-info/PKG-INFO
@@ -18,4 +20,5 @@ tests/test_base.py
18
20
  tests/test_errors.py
19
21
  tests/test_integration.py
20
22
  tests/test_release_08.py
21
- tests/test_release_08_edge_cases.py
23
+ tests/test_release_08_edge_cases.py
24
+ tests/test_release_09.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fastapi-errors-plus"
7
- version = "0.8.0"
7
+ version = "0.9.0"
8
8
  description = "Universal library for documenting errors in FastAPI endpoints"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -61,6 +61,9 @@ addopts = [
61
61
  "--cov-fail-under=80",
62
62
  "-v",
63
63
  ]
64
+ filterwarnings = [
65
+ "ignore:Implicit validation_error_422=True is deprecated:DeprecationWarning",
66
+ ]
64
67
 
65
68
  [tool.coverage.run]
66
69
  source = ["fastapi_errors_plus"]
@@ -676,15 +676,10 @@ class TestUniqueKeys:
676
676
  unauthorized_401=True, # Named after positional - should use unique key
677
677
  )
678
678
 
679
- # Should have both examples
679
+ # Flag runs before positional dict: flag example becomes "default", dict keeps StandardUnauthorized
680
680
  examples = errors[401]["content"]["application/json"]["examples"]
681
- # When dict is added first with "StandardUnauthorized", then flag is added
682
- # Flag creates "example" which gets converted to "default" (not "StandardUnauthorized_2")
683
- # This is because flag creates "example" first, then it's converted
684
- assert "StandardUnauthorized" in examples # From dict
685
- assert "default" in examples # From flag (converted from example)
686
-
687
- # Both examples should be present
681
+ assert "StandardUnauthorized" in examples
682
+ assert "default" in examples
688
683
  assert len(examples) == 2
689
684
  assert examples["StandardUnauthorized"]["value"]["detail"] == "Custom"
690
685
  assert examples["default"]["value"]["detail"] == "Unauthorized"
@@ -0,0 +1,122 @@
1
+ """Tests for release 0.9.0 (profile, model/schema on DTO, merge refactor, deprecations)."""
2
+
3
+ import warnings
4
+
5
+ import pytest
6
+ from fastapi import status
7
+
8
+ from fastapi_errors_plus import BaseErrorDTO, ErrorDoc, ErrorProfile, Errors
9
+
10
+
11
+ @pytest.mark.unit
12
+ class TestErrorProfile:
13
+ def test_profile_applies_standard_flags(self):
14
+ profile = ErrorProfile(
15
+ unauthorized_401=True,
16
+ validation_error_422=False,
17
+ internal_server_error_500=True,
18
+ )
19
+ errors = Errors(profile=profile)
20
+ assert 401 in errors
21
+ assert 500 in errors
22
+ assert status.HTTP_422_UNPROCESSABLE_CONTENT not in errors
23
+
24
+ def test_explicit_kwargs_override_profile(self):
25
+ profile = ErrorProfile(unauthorized_401=True, validation_error_422=False)
26
+ errors = Errors(forbidden_403=True, profile=profile, validation_error_422=False)
27
+ assert 401 in errors
28
+ assert 403 in errors
29
+
30
+ def test_profile_does_not_mutate_on_merge(self):
31
+ profile = ErrorProfile(validation_error_422=False)
32
+ Errors({409: {"description": "x"}}, profile=profile)
33
+ errors2 = Errors(profile=profile)
34
+ assert 409 not in errors2
35
+
36
+
37
+ @pytest.mark.unit
38
+ class TestDtoModelAndSchema:
39
+ def test_error_doc_model_on_response(self):
40
+ class M:
41
+ pass
42
+
43
+ doc = ErrorDoc(
44
+ status_code=401,
45
+ message="Unauthorized",
46
+ model=M,
47
+ body={"code": "UNAUTHORIZED", "detail": "bad token"},
48
+ )
49
+ errors = Errors(doc, validation_error_422=False)
50
+ assert errors[401]["model"] is M
51
+
52
+ def test_error_doc_schema_without_extra_dict(self):
53
+ schema = {
54
+ "type": "object",
55
+ "properties": {"code": {"type": "string"}, "detail": {"type": "string"}},
56
+ }
57
+ doc = ErrorDoc(
58
+ status_code=409,
59
+ message="Conflict",
60
+ schema=schema,
61
+ body={"code": "CONFLICT", "detail": "exists"},
62
+ )
63
+ errors = Errors(doc, validation_error_422=False)
64
+ aj = errors[409]["content"]["application/json"]
65
+ assert aj["schema"] == schema
66
+ assert "Conflict" in aj["examples"]
67
+
68
+ def test_base_error_dto_model_merges_with_flag(self):
69
+ class M:
70
+ pass
71
+
72
+ dto = BaseErrorDTO(
73
+ status_code=status.HTTP_401_UNAUTHORIZED,
74
+ message="Token expired",
75
+ model=M,
76
+ )
77
+ errors = Errors(dto, unauthorized_401=True, validation_error_422=False)
78
+ assert errors[401]["model"] is M
79
+
80
+
81
+ @pytest.mark.unit
82
+ class TestFlagExampleKeyRegistry:
83
+ def test_flag_then_dto_uses_standard_key_not_detail_guess(self):
84
+ dto = BaseErrorDTO(
85
+ status_code=status.HTTP_401_UNAUTHORIZED,
86
+ message="Custom auth message",
87
+ )
88
+ errors = Errors(dto, unauthorized_401=True, validation_error_422=False)
89
+ examples = errors[401]["content"]["application/json"]["examples"]
90
+ assert "StandardUnauthorized" in examples
91
+ assert "Custom auth message" in examples
92
+
93
+
94
+ @pytest.mark.unit
95
+ class TestLegacyKwargsDeprecation:
96
+ def test_legacy_unauthorized_warns(self):
97
+ with pytest.warns(DeprecationWarning, match="unauthorized_401"):
98
+ Errors(unauthorized=True, validation_error_422=False)
99
+
100
+ def test_implicit_422_default_warns(self):
101
+ with warnings.catch_warnings(record=True) as caught:
102
+ warnings.simplefilter("always", DeprecationWarning)
103
+ Errors(unauthorized_401=True)
104
+ dep = [w for w in caught if "validation_error_422" in str(w.message)]
105
+ assert len(dep) == 1
106
+
107
+ def test_explicit_422_false_silences_implicit_warning(self):
108
+ with warnings.catch_warnings():
109
+ warnings.simplefilter("error", DeprecationWarning)
110
+ Errors(unauthorized_401=True, validation_error_422=False)
111
+
112
+
113
+ @pytest.mark.unit
114
+ class TestProtocolValidation:
115
+ def test_non_callable_to_example_raises_before_collect(self):
116
+ class Bad:
117
+ status_code = 404
118
+ message = "x"
119
+ to_example = "nope"
120
+
121
+ with pytest.raises(TypeError, match="to_examples or to_example"):
122
+ Errors(Bad(), validation_error_422=False)