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.
- {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/PKG-INFO +66 -8
- {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/README.md +65 -7
- {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus/__init__.py +3 -1
- {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus/base.py +6 -0
- {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus/error_doc.py +6 -0
- fastapi_errors_plus-0.9.0/fastapi_errors_plus/error_profile.py +31 -0
- {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus/errors.py +130 -160
- fastapi_errors_plus-0.9.0/fastapi_errors_plus/merge_utils.py +61 -0
- {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus.egg-info/PKG-INFO +66 -8
- {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus.egg-info/SOURCES.txt +4 -1
- {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/pyproject.toml +4 -1
- {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/tests/test_errors.py +3 -8
- fastapi_errors_plus-0.9.0/tests/test_release_09.py +122 -0
- {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/LICENSE +0 -0
- {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus/example_utils.py +0 -0
- {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus/protocol.py +0 -0
- {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus/py.typed +0 -0
- {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus.egg-info/dependency_links.txt +0 -0
- {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus.egg-info/requires.txt +0 -0
- {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus.egg-info/top_level.txt +0 -0
- {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/setup.cfg +0 -0
- {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/tests/test_app.py +0 -0
- {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/tests/test_base.py +0 -0
- {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/tests/test_integration.py +0 -0
- {fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/tests/test_release_08.py +0 -0
- {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.
|
|
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
|
[](https://opensource.org/licenses/MIT)
|
|
34
34
|
[](https://www.python.org/downloads/)
|
|
35
35
|
[](https://fastapi.tiangolo.com/)
|
|
36
|
-
[](https://github.com/seligoroff/fastapi-errors-plus)
|
|
37
37
|
[](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 (
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
**
|
|
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
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
[](https://www.python.org/downloads/)
|
|
6
6
|
[](https://fastapi.tiangolo.com/)
|
|
7
|
-
[](https://github.com/seligoroff/fastapi-errors-plus)
|
|
8
8
|
[](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 (
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
**
|
|
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.
|
|
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.
|
|
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 ``
|
|
42
|
-
``
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
243
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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)
|
{fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-errors-plus
|
|
3
|
-
Version: 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
|
[](https://opensource.org/licenses/MIT)
|
|
34
34
|
[](https://www.python.org/downloads/)
|
|
35
35
|
[](https://fastapi.tiangolo.com/)
|
|
36
|
-
[](https://github.com/seligoroff/fastapi-errors-plus)
|
|
37
37
|
[](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 (
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
**
|
|
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
|
```
|
{fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus.egg-info/SOURCES.txt
RENAMED
|
@@ -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.
|
|
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
|
-
#
|
|
679
|
+
# Flag runs before positional dict: flag example becomes "default", dict keeps StandardUnauthorized
|
|
680
680
|
examples = errors[401]["content"]["application/json"]["examples"]
|
|
681
|
-
|
|
682
|
-
|
|
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)
|
|
File without changes
|
{fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus/example_utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus.egg-info/requires.txt
RENAMED
|
File without changes
|
{fastapi_errors_plus-0.8.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|