fastapi-errors-plus 0.6.2__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.7.0}/PKG-INFO +94 -33
  2. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.7.0}/README.md +93 -32
  3. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.7.0}/fastapi_errors_plus/__init__.py +1 -1
  4. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.7.0}/fastapi_errors_plus/base.py +6 -0
  5. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.7.0}/fastapi_errors_plus/errors.py +56 -8
  6. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.7.0}/fastapi_errors_plus/protocol.py +7 -0
  7. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.7.0}/fastapi_errors_plus.egg-info/PKG-INFO +94 -33
  8. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.7.0}/pyproject.toml +1 -1
  9. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.7.0}/tests/test_base.py +34 -0
  10. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.7.0}/tests/test_errors.py +153 -0
  11. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.7.0}/LICENSE +0 -0
  12. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.7.0}/fastapi_errors_plus/py.typed +0 -0
  13. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.7.0}/fastapi_errors_plus.egg-info/SOURCES.txt +0 -0
  14. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.7.0}/fastapi_errors_plus.egg-info/dependency_links.txt +0 -0
  15. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.7.0}/fastapi_errors_plus.egg-info/requires.txt +0 -0
  16. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.7.0}/fastapi_errors_plus.egg-info/top_level.txt +0 -0
  17. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.7.0}/setup.cfg +0 -0
  18. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.7.0}/tests/test_app.py +0 -0
  19. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.7.0}/tests/test_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-errors-plus
3
- Version: 0.6.2
3
+ Version: 0.7.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
@@ -30,12 +30,12 @@ Dynamic: license-file
30
30
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
31
31
  [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
32
32
  [![FastAPI](https://img.shields.io/badge/FastAPI-0.104%2B-009688.svg)](https://fastapi.tiangolo.com/)
33
- [![Tests](https://img.shields.io/badge/tests-72%20passed-success.svg)](https://github.com/seligoroff/fastapi-errors-plus)
34
- [![Coverage](https://img.shields.io/badge/coverage-83%25-green.svg)](https://github.com/seligoroff/fastapi-errors-plus)
33
+ [![Tests](https://img.shields.io/badge/tests-101-success.svg)](https://github.com/seligoroff/fastapi-errors-plus)
34
+ [![Coverage](https://img.shields.io/badge/coverage-80%25%2B-green.svg)](https://github.com/seligoroff/fastapi-errors-plus)
35
35
 
36
36
  Universal library for documenting errors in FastAPI endpoints.
37
37
 
38
- > [Русская версия README](README.ru.md)
38
+ > [Русская версия README](https://github.com/seligoroff/fastapi-errors-plus/blob/main/README.ru.md)
39
39
 
40
40
  ## Philosophy
41
41
 
@@ -207,6 +207,39 @@ def delete_item(id: int):
207
207
  pass
208
208
  ```
209
209
 
210
+ #### OpenAPI extras (`schema`) next to examples
211
+
212
+ ADR-style payloads (`code`, `detail`, optional `context`) need a **`schema`** in the spec besides **`examples`**. On **`BaseErrorDTO`** / **`StandardErrorDTO`** use **`openapi_json_extras`** (a dict merged under `content["application/json"]` — omit `example` / `examples` there; **`to_example()`** still defines examples):
213
+
214
+ ```python
215
+ from fastapi import APIRouter, status
216
+ from fastapi_errors_plus import BaseErrorDTO, Errors
217
+
218
+ ADR_ERROR_BODY_SCHEMA = {
219
+ "type": "object",
220
+ "required": ["code", "detail"],
221
+ "properties": {
222
+ "code": {"type": "string"},
223
+ "detail": {"type": "string"},
224
+ "context": {"type": "object"},
225
+ },
226
+ }
227
+
228
+ business_conflict = BaseErrorDTO(
229
+ status_code=status.HTTP_409_CONFLICT,
230
+ message="BusinessRuleViolation",
231
+ openapi_json_extras={"schema": ADR_ERROR_BODY_SCHEMA},
232
+ )
233
+
234
+ router = APIRouter()
235
+
236
+ @router.post("/items", responses=Errors(business_conflict, validation_error=False))
237
+ def create_item():
238
+ ...
239
+ ```
240
+
241
+ Custom **`ErrorDTO`** classes may instead define **`to_openapi_json_media_type_extras()`** returning a **`dict`**; if present and non-empty, it overrides **`openapi_json_extras`**. Any later **`dict`** in **`Errors`** for that status still overrides the same **`application/json`** keys (same precedence as merging two dicts).
242
+
210
243
  #### StandardErrorDTO
211
244
 
212
245
  Extended implementation for errors with multiple examples (useful for standard HTTP errors):
@@ -297,6 +330,37 @@ def update_item(id: int):
297
330
 
298
331
  The OpenAPI spec will contain both examples under the 404 status code.
299
332
 
333
+ When merging the same status code: a **dict** wins for **`description`** over the bundled standard-flag wording; an **ErrorDTO**’s `message` can replace the description only while it still matches the library’s default label for that code (custom descriptions coming from dicts are not overwritten by DTOs).
334
+
335
+ Under `content["application/json"]`, `example` / `examples` are merged as before; any **other** OpenAPI Media Type fields from a later **`dict`** (for example **`schema`**, **`encoding`**) are **copied in** as well—the later dict wins on conflict (**same rule as `description`**).
336
+
337
+ You can combine one **`ErrorDTO`** (examples) with a **`dict`** for the **same numeric status code** listing only **`schema`** (or other non-example keys) without repeating the boilerplate examples block:
338
+
339
+ ```python
340
+ Errors(
341
+ conflict_error_doc, # implements ErrorDTO, e.g. .for_openapi() for ADR-shaped examples
342
+ {
343
+ status.HTTP_409_CONFLICT: {
344
+ "description": "Business rule violation",
345
+ "content": {
346
+ "application/json": {
347
+ "schema": {
348
+ "type": "object",
349
+ "properties": {
350
+ "code": {"type": "string"},
351
+ "detail": {"type": "string"},
352
+ "context": {"type": "object"},
353
+ },
354
+ },
355
+ },
356
+ },
357
+ },
358
+ },
359
+ )
360
+ ```
361
+
362
+ Order matters only for overlaps: whichever **`dict`** is applied **later** in the **`Errors`** argument list overwrites **`schema`** / **`encoding`** (and **`model`** on the outer response dict, if provided) when the same keys appear again.
363
+
300
364
  ## ErrorDTO Protocol
301
365
 
302
366
  The `ErrorDTO` protocol defines the interface for error objects compatible with the library:
@@ -568,14 +632,15 @@ Errors(
568
632
  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`.
569
633
 
570
634
  **Returns:**
571
- - Mapping object (dict-like) that implements `Dict[int, Dict[str, Any]]` for FastAPI responses
572
- - Can be used directly in `responses` parameter without calling: `responses=Errors(...)`
635
+ - A dict-like `Mapping[int, ]` suitable for FastAPI’s `responses` / OpenAPI
636
+ - Pass the instance as-is **do not** call it like a function: `responses=Errors(...)`
573
637
 
574
638
  #### Usage
575
639
 
576
640
  ```python
577
- # Call the instance to get responses dict
578
- responses = Errors(unauthorized=True)
641
+ # Instances are Mapping keyed by HTTP status codes
642
+ error_responses = Errors(unauthorized_401=True, forbidden_403=True)
643
+ documented = error_responses[401] # response block picked up by OpenAPI
579
644
  ```
580
645
 
581
646
  ### `ErrorDTO`
@@ -589,6 +654,8 @@ Protocol for error objects compatible with the library.
589
654
  **Required methods:**
590
655
  - `to_example() -> Dict[str, Any]` — Generate example for OpenAPI
591
656
 
657
+ During `Errors(...)` initialization, non-`dict` objects in `*errors` missing `status_code`, `message`, or a callable `to_example` raise **`TypeError`** naming what was missing.
658
+
592
659
  ### `BaseErrorDTO`
593
660
 
594
661
  Base implementation of ErrorDTO Protocol for convenience.
@@ -598,6 +665,7 @@ Base implementation of ErrorDTO Protocol for convenience.
598
665
  BaseErrorDTO(
599
666
  status_code: int,
600
667
  message: str,
668
+ openapi_json_extras: Optional[Dict[str, Any]] = None,
601
669
  )
602
670
  ```
603
671
 
@@ -615,6 +683,7 @@ Extended implementation for errors with multiple examples.
615
683
  StandardErrorDTO(
616
684
  status_code: int,
617
685
  message: str,
686
+ openapi_json_extras: Optional[Dict[str, Any]] = None,
618
687
  examples: Optional[Dict[str, str]] = None,
619
688
  )
620
689
  ```
@@ -666,7 +735,7 @@ def delete_item(id: int):
666
735
  ```python
667
736
  from fastapi import APIRouter
668
737
  from fastapi_errors_plus import Errors
669
- from api.exceptions.dto import errors # Your project's error DTOs
738
+ from api.exceptions.dto import notification_not_found_error # ErrorDTO-compatible instance
670
739
 
671
740
  router = APIRouter()
672
741
 
@@ -675,7 +744,7 @@ router = APIRouter()
675
744
  responses=Errors(
676
745
  unauthorized=True,
677
746
  forbidden=True,
678
- errors.notification_not_found_error, # Your ErrorDTO
747
+ notification_not_found_error,
679
748
  ),
680
749
  )
681
750
  async def delete_notification(notification_id: int):
@@ -714,40 +783,32 @@ This example shows how to use `fastapi-errors-plus` in a FastAPI project with Cl
714
783
 
715
784
  **Domain Layer** (`domain/errors.py`):
716
785
  ```python
717
- from typing import Protocol, Dict, Any
786
+ from typing import Dict, Any
718
787
 
719
- class DomainError(Protocol):
720
- """Domain error protocol compatible with ErrorDTO."""
788
+ class DomainException(Exception):
789
+ """Domain exception usable as runtime error and shaped like ErrorDTO for OpenAPI."""
721
790
  status_code: int
722
791
  message: str
723
-
724
- def to_example(self) -> Dict[str, Any]:
725
- """Generate example for OpenAPI."""
726
- ...
727
792
 
728
- class ItemNotFoundError:
729
- """Domain error for item not found."""
730
- status_code = 404
731
- message = "Item not found"
732
-
793
+ def __init__(self) -> None:
794
+ super().__init__(self.message)
795
+
733
796
  def to_example(self) -> Dict[str, Any]:
734
797
  return {
735
- "ItemNotFound": {
736
- "value": {"detail": "Item not found"},
798
+ self.message: {
799
+ "value": {"detail": self.message},
737
800
  },
738
801
  }
739
802
 
740
- class ItemAlreadyExistsError:
741
- """Domain error for item already exists."""
803
+
804
+ class ItemNotFoundError(DomainException):
805
+ status_code = 404
806
+ message = "Item not found"
807
+
808
+
809
+ class ItemAlreadyExistsError(DomainException):
742
810
  status_code = 409
743
811
  message = "Item already exists"
744
-
745
- def to_example(self) -> Dict[str, Any]:
746
- return {
747
- "ItemAlreadyExists": {
748
- "value": {"detail": "Item already exists"},
749
- },
750
- }
751
812
  ```
752
813
 
753
814
  **Application Layer** (`application/use_cases.py`):
@@ -4,12 +4,12 @@
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-72%20passed-success.svg)](https://github.com/seligoroff/fastapi-errors-plus)
8
- [![Coverage](https://img.shields.io/badge/coverage-83%25-green.svg)](https://github.com/seligoroff/fastapi-errors-plus)
7
+ [![Tests](https://img.shields.io/badge/tests-101-success.svg)](https://github.com/seligoroff/fastapi-errors-plus)
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.
11
11
 
12
- > [Русская версия README](README.ru.md)
12
+ > [Русская версия README](https://github.com/seligoroff/fastapi-errors-plus/blob/main/README.ru.md)
13
13
 
14
14
  ## Philosophy
15
15
 
@@ -181,6 +181,39 @@ def delete_item(id: int):
181
181
  pass
182
182
  ```
183
183
 
184
+ #### OpenAPI extras (`schema`) next to examples
185
+
186
+ ADR-style payloads (`code`, `detail`, optional `context`) need a **`schema`** in the spec besides **`examples`**. On **`BaseErrorDTO`** / **`StandardErrorDTO`** use **`openapi_json_extras`** (a dict merged under `content["application/json"]` — omit `example` / `examples` there; **`to_example()`** still defines examples):
187
+
188
+ ```python
189
+ from fastapi import APIRouter, status
190
+ from fastapi_errors_plus import BaseErrorDTO, Errors
191
+
192
+ ADR_ERROR_BODY_SCHEMA = {
193
+ "type": "object",
194
+ "required": ["code", "detail"],
195
+ "properties": {
196
+ "code": {"type": "string"},
197
+ "detail": {"type": "string"},
198
+ "context": {"type": "object"},
199
+ },
200
+ }
201
+
202
+ business_conflict = BaseErrorDTO(
203
+ status_code=status.HTTP_409_CONFLICT,
204
+ message="BusinessRuleViolation",
205
+ openapi_json_extras={"schema": ADR_ERROR_BODY_SCHEMA},
206
+ )
207
+
208
+ router = APIRouter()
209
+
210
+ @router.post("/items", responses=Errors(business_conflict, validation_error=False))
211
+ def create_item():
212
+ ...
213
+ ```
214
+
215
+ Custom **`ErrorDTO`** classes may instead define **`to_openapi_json_media_type_extras()`** returning a **`dict`**; if present and non-empty, it overrides **`openapi_json_extras`**. Any later **`dict`** in **`Errors`** for that status still overrides the same **`application/json`** keys (same precedence as merging two dicts).
216
+
184
217
  #### StandardErrorDTO
185
218
 
186
219
  Extended implementation for errors with multiple examples (useful for standard HTTP errors):
@@ -271,6 +304,37 @@ def update_item(id: int):
271
304
 
272
305
  The OpenAPI spec will contain both examples under the 404 status code.
273
306
 
307
+ When merging the same status code: a **dict** wins for **`description`** over the bundled standard-flag wording; an **ErrorDTO**’s `message` can replace the description only while it still matches the library’s default label for that code (custom descriptions coming from dicts are not overwritten by DTOs).
308
+
309
+ Under `content["application/json"]`, `example` / `examples` are merged as before; any **other** OpenAPI Media Type fields from a later **`dict`** (for example **`schema`**, **`encoding`**) are **copied in** as well—the later dict wins on conflict (**same rule as `description`**).
310
+
311
+ You can combine one **`ErrorDTO`** (examples) with a **`dict`** for the **same numeric status code** listing only **`schema`** (or other non-example keys) without repeating the boilerplate examples block:
312
+
313
+ ```python
314
+ Errors(
315
+ conflict_error_doc, # implements ErrorDTO, e.g. .for_openapi() for ADR-shaped examples
316
+ {
317
+ status.HTTP_409_CONFLICT: {
318
+ "description": "Business rule violation",
319
+ "content": {
320
+ "application/json": {
321
+ "schema": {
322
+ "type": "object",
323
+ "properties": {
324
+ "code": {"type": "string"},
325
+ "detail": {"type": "string"},
326
+ "context": {"type": "object"},
327
+ },
328
+ },
329
+ },
330
+ },
331
+ },
332
+ },
333
+ )
334
+ ```
335
+
336
+ Order matters only for overlaps: whichever **`dict`** is applied **later** in the **`Errors`** argument list overwrites **`schema`** / **`encoding`** (and **`model`** on the outer response dict, if provided) when the same keys appear again.
337
+
274
338
  ## ErrorDTO Protocol
275
339
 
276
340
  The `ErrorDTO` protocol defines the interface for error objects compatible with the library:
@@ -542,14 +606,15 @@ Errors(
542
606
  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`.
543
607
 
544
608
  **Returns:**
545
- - Mapping object (dict-like) that implements `Dict[int, Dict[str, Any]]` for FastAPI responses
546
- - Can be used directly in `responses` parameter without calling: `responses=Errors(...)`
609
+ - A dict-like `Mapping[int, ]` suitable for FastAPI’s `responses` / OpenAPI
610
+ - Pass the instance as-is **do not** call it like a function: `responses=Errors(...)`
547
611
 
548
612
  #### Usage
549
613
 
550
614
  ```python
551
- # Call the instance to get responses dict
552
- responses = Errors(unauthorized=True)
615
+ # Instances are Mapping keyed by HTTP status codes
616
+ error_responses = Errors(unauthorized_401=True, forbidden_403=True)
617
+ documented = error_responses[401] # response block picked up by OpenAPI
553
618
  ```
554
619
 
555
620
  ### `ErrorDTO`
@@ -563,6 +628,8 @@ Protocol for error objects compatible with the library.
563
628
  **Required methods:**
564
629
  - `to_example() -> Dict[str, Any]` — Generate example for OpenAPI
565
630
 
631
+ During `Errors(...)` initialization, non-`dict` objects in `*errors` missing `status_code`, `message`, or a callable `to_example` raise **`TypeError`** naming what was missing.
632
+
566
633
  ### `BaseErrorDTO`
567
634
 
568
635
  Base implementation of ErrorDTO Protocol for convenience.
@@ -572,6 +639,7 @@ Base implementation of ErrorDTO Protocol for convenience.
572
639
  BaseErrorDTO(
573
640
  status_code: int,
574
641
  message: str,
642
+ openapi_json_extras: Optional[Dict[str, Any]] = None,
575
643
  )
576
644
  ```
577
645
 
@@ -589,6 +657,7 @@ Extended implementation for errors with multiple examples.
589
657
  StandardErrorDTO(
590
658
  status_code: int,
591
659
  message: str,
660
+ openapi_json_extras: Optional[Dict[str, Any]] = None,
592
661
  examples: Optional[Dict[str, str]] = None,
593
662
  )
594
663
  ```
@@ -640,7 +709,7 @@ def delete_item(id: int):
640
709
  ```python
641
710
  from fastapi import APIRouter
642
711
  from fastapi_errors_plus import Errors
643
- from api.exceptions.dto import errors # Your project's error DTOs
712
+ from api.exceptions.dto import notification_not_found_error # ErrorDTO-compatible instance
644
713
 
645
714
  router = APIRouter()
646
715
 
@@ -649,7 +718,7 @@ router = APIRouter()
649
718
  responses=Errors(
650
719
  unauthorized=True,
651
720
  forbidden=True,
652
- errors.notification_not_found_error, # Your ErrorDTO
721
+ notification_not_found_error,
653
722
  ),
654
723
  )
655
724
  async def delete_notification(notification_id: int):
@@ -688,40 +757,32 @@ This example shows how to use `fastapi-errors-plus` in a FastAPI project with Cl
688
757
 
689
758
  **Domain Layer** (`domain/errors.py`):
690
759
  ```python
691
- from typing import Protocol, Dict, Any
760
+ from typing import Dict, Any
692
761
 
693
- class DomainError(Protocol):
694
- """Domain error protocol compatible with ErrorDTO."""
762
+ class DomainException(Exception):
763
+ """Domain exception usable as runtime error and shaped like ErrorDTO for OpenAPI."""
695
764
  status_code: int
696
765
  message: str
697
-
698
- def to_example(self) -> Dict[str, Any]:
699
- """Generate example for OpenAPI."""
700
- ...
701
766
 
702
- class ItemNotFoundError:
703
- """Domain error for item not found."""
704
- status_code = 404
705
- message = "Item not found"
706
-
767
+ def __init__(self) -> None:
768
+ super().__init__(self.message)
769
+
707
770
  def to_example(self) -> Dict[str, Any]:
708
771
  return {
709
- "ItemNotFound": {
710
- "value": {"detail": "Item not found"},
772
+ self.message: {
773
+ "value": {"detail": self.message},
711
774
  },
712
775
  }
713
776
 
714
- class ItemAlreadyExistsError:
715
- """Domain error for item already exists."""
777
+
778
+ class ItemNotFoundError(DomainException):
779
+ status_code = 404
780
+ message = "Item not found"
781
+
782
+
783
+ class ItemAlreadyExistsError(DomainException):
716
784
  status_code = 409
717
785
  message = "Item already exists"
718
-
719
- def to_example(self) -> Dict[str, Any]:
720
- return {
721
- "ItemAlreadyExists": {
722
- "value": {"detail": "Item already exists"},
723
- },
724
- }
725
786
  ```
726
787
 
727
788
  **Application Layer** (`application/use_cases.py`):
@@ -10,5 +10,5 @@ __all__ = [
10
10
  "BaseErrorDTO", # Base implementation (optional)
11
11
  "StandardErrorDTO", # Extended implementation (optional)
12
12
  ]
13
- __version__ = "0.6.2"
13
+ __version__ = "0.7.0"
14
14
 
@@ -37,6 +37,12 @@ class BaseErrorDTO:
37
37
  message: str
38
38
  """Error message description."""
39
39
 
40
+ openapi_json_extras: Optional[Dict[str, Any]] = field(default=None)
41
+ """Optional OpenAPI fragment under ``content['application/json']`` besides examples,
42
+ typically ``{\"schema\": ...}`` or ``encoding``. Do **not** put ``example`` / ``examples``
43
+ here — use :meth:`to_example`. Arbitrary implementations may instead implement
44
+ :meth:`to_openapi_json_media_type_extras`; that return value wins over this attribute."""
45
+
40
46
  def to_example(self) -> Dict[str, Any]:
41
47
  """Generate example for OpenAPI.
42
48
 
@@ -7,6 +7,43 @@ from fastapi import status
7
7
 
8
8
  from fastapi_errors_plus.protocol import ErrorDTO
9
9
 
10
+ # Starlette (via FastAPI) prefers HTTP_422_UNPROCESSABLE_CONTENT over ENTITY.
11
+ # Older pins may lack CONTENT (import crash); avoid nested getattr(, default=)
12
+ # touching ENTITY when CONTENT exists (DeprecationWarning).
13
+ _HTTP_422 = getattr(status, "HTTP_422_UNPROCESSABLE_CONTENT", None)
14
+ if _HTTP_422 is None:
15
+ _HTTP_422 = getattr(status, "HTTP_422_UNPROCESSABLE_ENTITY", 422)
16
+
17
+ # OpenAPI Media Type: merge example/examples separately; other keys (schema, encoding, …) from an extra dict win.
18
+ _OPENAPI_MEDIA_TYPE_EXAMPLE_KEYS = frozenset({"example", "examples"})
19
+
20
+
21
+ def _merge_openapi_application_json_non_example(
22
+ existing_json: Dict[str, Any],
23
+ response_json: Dict[str, Any],
24
+ ) -> None:
25
+ """Apply schema/encoding/extra fields from incoming media type; incoming overwrites on conflict."""
26
+ for key, value in response_json.items():
27
+ if key not in _OPENAPI_MEDIA_TYPE_EXAMPLE_KEYS:
28
+ existing_json[key] = value
29
+
30
+
31
+ def _pick_error_dto_application_json_extra(error_dto: Any) -> Optional[Dict[str, Any]]:
32
+ """Optional OpenAPI ``application/json`` keys from DTO (e.g. ``schema``, ``encoding``).
33
+
34
+ Prefer ``to_openapi_json_media_type_extras()`` when present and returns a truthy mapping;
35
+ otherwise ``openapi_json_extras`` attribute. Must not rely on ``example`` / ``examples`` here —
36
+ ``to_example()`` covers those."""
37
+ getter = getattr(error_dto, "to_openapi_json_media_type_extras", None)
38
+ if callable(getter):
39
+ out = getter()
40
+ if isinstance(out, dict) and out:
41
+ return dict(out)
42
+ extra = getattr(error_dto, "openapi_json_extras", None)
43
+ if isinstance(extra, dict) and extra:
44
+ return dict(extra)
45
+ return None
46
+
10
47
 
11
48
  class Errors(Mapping):
12
49
  """Universal class for documenting errors in FastAPI endpoints.
@@ -140,7 +177,7 @@ class Errors(Mapping):
140
177
 
141
178
  if add_422:
142
179
  self._add_standard_error(
143
- status.HTTP_422_UNPROCESSABLE_CONTENT,
180
+ _HTTP_422,
144
181
  "Validation Error",
145
182
  {"detail": "Validation error"},
146
183
  )
@@ -239,7 +276,7 @@ class Errors(Mapping):
239
276
  STANDARD_DESCRIPTIONS = {
240
277
  status.HTTP_401_UNAUTHORIZED: "Unauthorized",
241
278
  status.HTTP_403_FORBIDDEN: "Forbidden",
242
- status.HTTP_422_UNPROCESSABLE_CONTENT: "Validation Error",
279
+ _HTTP_422: "Validation Error",
243
280
  status.HTTP_500_INTERNAL_SERVER_ERROR: "Internal Server Error",
244
281
  }
245
282
 
@@ -263,7 +300,7 @@ class Errors(Mapping):
263
300
  standard_keys = {
264
301
  status.HTTP_401_UNAUTHORIZED: "StandardUnauthorized",
265
302
  status.HTTP_403_FORBIDDEN: "StandardForbidden",
266
- status.HTTP_422_UNPROCESSABLE_CONTENT: "StandardValidationError",
303
+ _HTTP_422: "StandardValidationError",
267
304
  status.HTTP_500_INTERNAL_SERVER_ERROR: "StandardInternalServerError",
268
305
  }
269
306
  example_key = standard_keys.get(status_code, f"Standard{status_code}")
@@ -322,6 +359,10 @@ class Errors(Mapping):
322
359
  if "description" in response_data:
323
360
  existing["description"] = response_data["description"]
324
361
 
362
+ # FastAPI-style shorthand on the response object (incoming dict wins)
363
+ if "model" in response_data:
364
+ existing["model"] = response_data["model"]
365
+
325
366
  # Merge content
326
367
  if "content" in response_data:
327
368
  existing_content = existing.setdefault("content", {})
@@ -332,6 +373,8 @@ class Errors(Mapping):
332
373
  existing_json = existing_content.setdefault("application/json", {})
333
374
  response_json = response_content["application/json"]
334
375
 
376
+ _merge_openapi_application_json_non_example(existing_json, response_json)
377
+
335
378
  # Handle example/examples
336
379
  if "examples" in response_json:
337
380
  # Response has examples
@@ -400,14 +443,16 @@ class Errors(Mapping):
400
443
  """
401
444
  status_code = error_dto.status_code
402
445
  examples = error_dto.to_example()
403
-
446
+ dto_extras = _pick_error_dto_application_json_extra(error_dto)
447
+
404
448
  if status_code not in self._responses:
449
+ application_json: Dict[str, Any] = {"examples": examples}
450
+ if dto_extras is not None:
451
+ _merge_openapi_application_json_non_example(application_json, dto_extras)
405
452
  self._responses[status_code] = {
406
453
  "description": error_dto.message,
407
454
  "content": {
408
- "application/json": {
409
- "examples": examples,
410
- },
455
+ "application/json": application_json,
411
456
  },
412
457
  }
413
458
  else:
@@ -446,7 +491,10 @@ class Errors(Mapping):
446
491
  # Merge new examples from ErrorDTO
447
492
  existing_examples.update(examples)
448
493
  content_json["examples"] = existing_examples
449
-
494
+
495
+ if dto_extras is not None:
496
+ _merge_openapi_application_json_non_example(content_json, dto_extras)
497
+
450
498
  # Mapping protocol implementation
451
499
  def __getitem__(self, key: int) -> Dict[str, Any]:
452
500
  """Get response for status code.
@@ -8,6 +8,13 @@ class ErrorDTO(Protocol):
8
8
 
9
9
  Any class implementing this protocol can be used with Errors().
10
10
 
11
+ Optionally, objects may expose **OpenAPI extras** besides examples:
12
+
13
+ - ``openapi_json_extras``: ``dict`` merged into ``content["application/json"]``
14
+ (e.g. ``{"schema": ...}`` — do **not** use for ``example`` / ``examples``).
15
+ - or ``to_openapi_json_media_type_extras() -> Optional[dict]``: if present and returns a
16
+ non-empty ``dict``, it **takes precedence** over ``openapi_json_extras``.
17
+
11
18
  Example:
12
19
  ```python
13
20
  class MyError:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-errors-plus
3
- Version: 0.6.2
3
+ Version: 0.7.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
@@ -30,12 +30,12 @@ Dynamic: license-file
30
30
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
31
31
  [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
32
32
  [![FastAPI](https://img.shields.io/badge/FastAPI-0.104%2B-009688.svg)](https://fastapi.tiangolo.com/)
33
- [![Tests](https://img.shields.io/badge/tests-72%20passed-success.svg)](https://github.com/seligoroff/fastapi-errors-plus)
34
- [![Coverage](https://img.shields.io/badge/coverage-83%25-green.svg)](https://github.com/seligoroff/fastapi-errors-plus)
33
+ [![Tests](https://img.shields.io/badge/tests-101-success.svg)](https://github.com/seligoroff/fastapi-errors-plus)
34
+ [![Coverage](https://img.shields.io/badge/coverage-80%25%2B-green.svg)](https://github.com/seligoroff/fastapi-errors-plus)
35
35
 
36
36
  Universal library for documenting errors in FastAPI endpoints.
37
37
 
38
- > [Русская версия README](README.ru.md)
38
+ > [Русская версия README](https://github.com/seligoroff/fastapi-errors-plus/blob/main/README.ru.md)
39
39
 
40
40
  ## Philosophy
41
41
 
@@ -207,6 +207,39 @@ def delete_item(id: int):
207
207
  pass
208
208
  ```
209
209
 
210
+ #### OpenAPI extras (`schema`) next to examples
211
+
212
+ ADR-style payloads (`code`, `detail`, optional `context`) need a **`schema`** in the spec besides **`examples`**. On **`BaseErrorDTO`** / **`StandardErrorDTO`** use **`openapi_json_extras`** (a dict merged under `content["application/json"]` — omit `example` / `examples` there; **`to_example()`** still defines examples):
213
+
214
+ ```python
215
+ from fastapi import APIRouter, status
216
+ from fastapi_errors_plus import BaseErrorDTO, Errors
217
+
218
+ ADR_ERROR_BODY_SCHEMA = {
219
+ "type": "object",
220
+ "required": ["code", "detail"],
221
+ "properties": {
222
+ "code": {"type": "string"},
223
+ "detail": {"type": "string"},
224
+ "context": {"type": "object"},
225
+ },
226
+ }
227
+
228
+ business_conflict = BaseErrorDTO(
229
+ status_code=status.HTTP_409_CONFLICT,
230
+ message="BusinessRuleViolation",
231
+ openapi_json_extras={"schema": ADR_ERROR_BODY_SCHEMA},
232
+ )
233
+
234
+ router = APIRouter()
235
+
236
+ @router.post("/items", responses=Errors(business_conflict, validation_error=False))
237
+ def create_item():
238
+ ...
239
+ ```
240
+
241
+ Custom **`ErrorDTO`** classes may instead define **`to_openapi_json_media_type_extras()`** returning a **`dict`**; if present and non-empty, it overrides **`openapi_json_extras`**. Any later **`dict`** in **`Errors`** for that status still overrides the same **`application/json`** keys (same precedence as merging two dicts).
242
+
210
243
  #### StandardErrorDTO
211
244
 
212
245
  Extended implementation for errors with multiple examples (useful for standard HTTP errors):
@@ -297,6 +330,37 @@ def update_item(id: int):
297
330
 
298
331
  The OpenAPI spec will contain both examples under the 404 status code.
299
332
 
333
+ When merging the same status code: a **dict** wins for **`description`** over the bundled standard-flag wording; an **ErrorDTO**’s `message` can replace the description only while it still matches the library’s default label for that code (custom descriptions coming from dicts are not overwritten by DTOs).
334
+
335
+ Under `content["application/json"]`, `example` / `examples` are merged as before; any **other** OpenAPI Media Type fields from a later **`dict`** (for example **`schema`**, **`encoding`**) are **copied in** as well—the later dict wins on conflict (**same rule as `description`**).
336
+
337
+ You can combine one **`ErrorDTO`** (examples) with a **`dict`** for the **same numeric status code** listing only **`schema`** (or other non-example keys) without repeating the boilerplate examples block:
338
+
339
+ ```python
340
+ Errors(
341
+ conflict_error_doc, # implements ErrorDTO, e.g. .for_openapi() for ADR-shaped examples
342
+ {
343
+ status.HTTP_409_CONFLICT: {
344
+ "description": "Business rule violation",
345
+ "content": {
346
+ "application/json": {
347
+ "schema": {
348
+ "type": "object",
349
+ "properties": {
350
+ "code": {"type": "string"},
351
+ "detail": {"type": "string"},
352
+ "context": {"type": "object"},
353
+ },
354
+ },
355
+ },
356
+ },
357
+ },
358
+ },
359
+ )
360
+ ```
361
+
362
+ Order matters only for overlaps: whichever **`dict`** is applied **later** in the **`Errors`** argument list overwrites **`schema`** / **`encoding`** (and **`model`** on the outer response dict, if provided) when the same keys appear again.
363
+
300
364
  ## ErrorDTO Protocol
301
365
 
302
366
  The `ErrorDTO` protocol defines the interface for error objects compatible with the library:
@@ -568,14 +632,15 @@ Errors(
568
632
  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`.
569
633
 
570
634
  **Returns:**
571
- - Mapping object (dict-like) that implements `Dict[int, Dict[str, Any]]` for FastAPI responses
572
- - Can be used directly in `responses` parameter without calling: `responses=Errors(...)`
635
+ - A dict-like `Mapping[int, ]` suitable for FastAPI’s `responses` / OpenAPI
636
+ - Pass the instance as-is **do not** call it like a function: `responses=Errors(...)`
573
637
 
574
638
  #### Usage
575
639
 
576
640
  ```python
577
- # Call the instance to get responses dict
578
- responses = Errors(unauthorized=True)
641
+ # Instances are Mapping keyed by HTTP status codes
642
+ error_responses = Errors(unauthorized_401=True, forbidden_403=True)
643
+ documented = error_responses[401] # response block picked up by OpenAPI
579
644
  ```
580
645
 
581
646
  ### `ErrorDTO`
@@ -589,6 +654,8 @@ Protocol for error objects compatible with the library.
589
654
  **Required methods:**
590
655
  - `to_example() -> Dict[str, Any]` — Generate example for OpenAPI
591
656
 
657
+ During `Errors(...)` initialization, non-`dict` objects in `*errors` missing `status_code`, `message`, or a callable `to_example` raise **`TypeError`** naming what was missing.
658
+
592
659
  ### `BaseErrorDTO`
593
660
 
594
661
  Base implementation of ErrorDTO Protocol for convenience.
@@ -598,6 +665,7 @@ Base implementation of ErrorDTO Protocol for convenience.
598
665
  BaseErrorDTO(
599
666
  status_code: int,
600
667
  message: str,
668
+ openapi_json_extras: Optional[Dict[str, Any]] = None,
601
669
  )
602
670
  ```
603
671
 
@@ -615,6 +683,7 @@ Extended implementation for errors with multiple examples.
615
683
  StandardErrorDTO(
616
684
  status_code: int,
617
685
  message: str,
686
+ openapi_json_extras: Optional[Dict[str, Any]] = None,
618
687
  examples: Optional[Dict[str, str]] = None,
619
688
  )
620
689
  ```
@@ -666,7 +735,7 @@ def delete_item(id: int):
666
735
  ```python
667
736
  from fastapi import APIRouter
668
737
  from fastapi_errors_plus import Errors
669
- from api.exceptions.dto import errors # Your project's error DTOs
738
+ from api.exceptions.dto import notification_not_found_error # ErrorDTO-compatible instance
670
739
 
671
740
  router = APIRouter()
672
741
 
@@ -675,7 +744,7 @@ router = APIRouter()
675
744
  responses=Errors(
676
745
  unauthorized=True,
677
746
  forbidden=True,
678
- errors.notification_not_found_error, # Your ErrorDTO
747
+ notification_not_found_error,
679
748
  ),
680
749
  )
681
750
  async def delete_notification(notification_id: int):
@@ -714,40 +783,32 @@ This example shows how to use `fastapi-errors-plus` in a FastAPI project with Cl
714
783
 
715
784
  **Domain Layer** (`domain/errors.py`):
716
785
  ```python
717
- from typing import Protocol, Dict, Any
786
+ from typing import Dict, Any
718
787
 
719
- class DomainError(Protocol):
720
- """Domain error protocol compatible with ErrorDTO."""
788
+ class DomainException(Exception):
789
+ """Domain exception usable as runtime error and shaped like ErrorDTO for OpenAPI."""
721
790
  status_code: int
722
791
  message: str
723
-
724
- def to_example(self) -> Dict[str, Any]:
725
- """Generate example for OpenAPI."""
726
- ...
727
792
 
728
- class ItemNotFoundError:
729
- """Domain error for item not found."""
730
- status_code = 404
731
- message = "Item not found"
732
-
793
+ def __init__(self) -> None:
794
+ super().__init__(self.message)
795
+
733
796
  def to_example(self) -> Dict[str, Any]:
734
797
  return {
735
- "ItemNotFound": {
736
- "value": {"detail": "Item not found"},
798
+ self.message: {
799
+ "value": {"detail": self.message},
737
800
  },
738
801
  }
739
802
 
740
- class ItemAlreadyExistsError:
741
- """Domain error for item already exists."""
803
+
804
+ class ItemNotFoundError(DomainException):
805
+ status_code = 404
806
+ message = "Item not found"
807
+
808
+
809
+ class ItemAlreadyExistsError(DomainException):
742
810
  status_code = 409
743
811
  message = "Item already exists"
744
-
745
- def to_example(self) -> Dict[str, Any]:
746
- return {
747
- "ItemAlreadyExists": {
748
- "value": {"detail": "Item already exists"},
749
- },
750
- }
751
812
  ```
752
813
 
753
814
  **Application Layer** (`application/use_cases.py`):
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fastapi-errors-plus"
7
- version = "0.6.2"
7
+ version = "0.7.0"
8
8
  description = "Universal library for documenting errors in FastAPI endpoints"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -69,6 +69,40 @@ class TestBaseErrorDTO:
69
69
  assert "Item not found" in examples
70
70
  assert examples["Item not found"]["value"] == {"detail": "Item not found"}
71
71
 
72
+ def test_base_error_dto_openapi_json_extras_schema(self):
73
+ """Schema beside examples via openapi_json_extras."""
74
+ adr_schema = {
75
+ "type": "object",
76
+ "required": ["code", "detail"],
77
+ "properties": {
78
+ "code": {"type": "string"},
79
+ "detail": {"type": "string"},
80
+ "context": {"type": "object"},
81
+ },
82
+ }
83
+ error = BaseErrorDTO(
84
+ status_code=409,
85
+ message="Conflict",
86
+ openapi_json_extras={"schema": adr_schema},
87
+ )
88
+ merged = Errors(error, validation_error=False)
89
+ aj = merged[409]["content"]["application/json"]
90
+ assert aj["schema"] == adr_schema
91
+ assert "Conflict" in aj["examples"]
92
+
93
+ def test_standard_error_dto_openapi_json_extras(self):
94
+ adr_schema = {"$ref": "#/components/schemas/AppError"}
95
+ error = StandardErrorDTO(
96
+ status_code=422,
97
+ message="Bad",
98
+ examples={"One": "msg"},
99
+ openapi_json_extras={"schema": adr_schema},
100
+ )
101
+ merged = Errors(error, validation_error=False)
102
+ aj = merged[422]["content"]["application/json"]
103
+ assert aj["schema"] == adr_schema
104
+ assert "One" in aj["examples"]
105
+
72
106
 
73
107
  @pytest.mark.unit
74
108
  class TestStandardErrorDTO:
@@ -300,6 +300,159 @@ class TestErrorsMergeExamples:
300
300
  assert "examples" in content or "example" in content
301
301
 
302
302
 
303
+ @pytest.mark.unit
304
+ class TestErrorsOpenApiSchemaMerge:
305
+ """Merging schema / non-example Media Type keys with ErrorDTO or other dict fragments."""
306
+
307
+ def test_merge_error_dto_then_dict_adds_schema(self, simple_error_dto):
308
+ """Examples from ErrorDTO plus OpenAPI schema from a second fragment (same status)."""
309
+ error_dto = simple_error_dto(
310
+ status_code=status.HTTP_404_NOT_FOUND,
311
+ message="NotFound",
312
+ detail="nothing",
313
+ )
314
+ schema_frag = {"$ref": "#/components/schemas/AppError"}
315
+ errors = Errors(
316
+ error_dto,
317
+ {
318
+ status.HTTP_404_NOT_FOUND: {
319
+ "content": {
320
+ "application/json": {
321
+ "schema": schema_frag,
322
+ },
323
+ },
324
+ },
325
+ },
326
+ validation_error=False,
327
+ )
328
+ aj = errors[status.HTTP_404_NOT_FOUND]["content"]["application/json"]
329
+ assert aj["schema"] == schema_frag
330
+ assert len(aj["examples"]) >= 1
331
+ assert "NotFound" in aj["examples"]
332
+
333
+ def test_merge_dict_schema_then_error_dto_keeps_schema(self, simple_error_dto):
334
+ """Schema-only fragment first; DTO merges examples without dropping schema."""
335
+ schema_frag = {
336
+ "type": "object",
337
+ "properties": {
338
+ "code": {"type": "string"},
339
+ "detail": {"type": "string"},
340
+ },
341
+ }
342
+ error_dto = simple_error_dto(
343
+ status_code=status.HTTP_404_NOT_FOUND,
344
+ message="NotFound",
345
+ detail="nothing",
346
+ )
347
+ errors = Errors(
348
+ {
349
+ status.HTTP_404_NOT_FOUND: {
350
+ "content": {"application/json": {"schema": schema_frag}},
351
+ },
352
+ },
353
+ error_dto,
354
+ validation_error=False,
355
+ )
356
+ aj = errors[status.HTTP_404_NOT_FOUND]["content"]["application/json"]
357
+ assert aj["schema"] == schema_frag
358
+ assert "NotFound" in aj["examples"]
359
+
360
+ def test_later_dict_schema_overwrites_earlier(self):
361
+ """Second dict for the same status overwrites schema (dict wins, same as description)."""
362
+ errors = Errors(
363
+ {
364
+ status.HTTP_404_NOT_FOUND: {
365
+ "content": {"application/json": {"schema": {"type": "string"}}},
366
+ },
367
+ },
368
+ {
369
+ status.HTTP_404_NOT_FOUND: {
370
+ "content": {"application/json": {"schema": {"type": "object"}}},
371
+ },
372
+ },
373
+ validation_error=False,
374
+ )
375
+ assert (
376
+ errors[status.HTTP_404_NOT_FOUND]["content"]["application/json"]["schema"]["type"]
377
+ == "object"
378
+ )
379
+
380
+ def test_merge_non_example_encoding_key_from_dict(self):
381
+ errors = Errors(
382
+ {
383
+ status.HTTP_404_NOT_FOUND: {
384
+ "content": {"application/json": {"encoding": "utf-8"}},
385
+ },
386
+ },
387
+ validation_error=False,
388
+ )
389
+ assert (
390
+ errors[status.HTTP_404_NOT_FOUND]["content"]["application/json"]["encoding"]
391
+ == "utf-8"
392
+ )
393
+
394
+ def test_openapi_json_extras_without_second_fragment_dict(self):
395
+ """ADR-style payload: schema on BaseErrorDTO, no duplicate status dict required."""
396
+ from fastapi_errors_plus import BaseErrorDTO
397
+
398
+ adr_schema = {
399
+ "type": "object",
400
+ "properties": {
401
+ "code": {"type": "string"},
402
+ "detail": {"type": "string"},
403
+ "context": {"type": "object"},
404
+ },
405
+ }
406
+ err = BaseErrorDTO(
407
+ status_code=status.HTTP_409_CONFLICT,
408
+ message="BusinessRule",
409
+ openapi_json_extras={
410
+ "schema": adr_schema,
411
+ },
412
+ )
413
+ errors = Errors(err, validation_error=False)
414
+ aj = errors[status.HTTP_409_CONFLICT]["content"]["application/json"]
415
+ assert aj["schema"] is adr_schema
416
+ assert aj["examples"]["BusinessRule"]["value"]["detail"] == "BusinessRule"
417
+
418
+ def test_later_dict_overwrites_schema_from_dto_openapi_json_extras(self):
419
+ from fastapi_errors_plus import BaseErrorDTO
420
+
421
+ dto = BaseErrorDTO(
422
+ status_code=status.HTTP_404_NOT_FOUND,
423
+ message="Missing",
424
+ openapi_json_extras={"schema": {"type": "string"}},
425
+ )
426
+ errors = Errors(
427
+ dto,
428
+ {
429
+ status.HTTP_404_NOT_FOUND: {
430
+ "content": {"application/json": {"schema": {"type": "object"}}},
431
+ },
432
+ },
433
+ validation_error=False,
434
+ )
435
+ schema = errors[status.HTTP_404_NOT_FOUND]["content"]["application/json"]["schema"]
436
+ assert schema["type"] == "object"
437
+
438
+ def test_to_openapi_json_media_type_extras_overrides_attribute(self):
439
+ class CustomDTO:
440
+ status_code = status.HTTP_403_FORBIDDEN
441
+ message = "Denied"
442
+ openapi_json_extras = {"schema": {"type": "string"}}
443
+
444
+ def to_example(self): # type: ignore[no-untyped-def]
445
+ return {
446
+ self.message: {"value": {"detail": "no"}},
447
+ }
448
+
449
+ def to_openapi_json_media_type_extras(self): # type: ignore[no-untyped-def]
450
+ return {"schema": {"description": "from method"}}
451
+
452
+ errors = Errors(CustomDTO(), validation_error=False)
453
+ assert errors[403]["content"]["application/json"]["schema"]["description"] == "from method"
454
+
455
+
303
456
  @pytest.mark.unit
304
457
  class TestErrorsMixed:
305
458
  """Tests for mixed usage (flags + dict + ErrorDTO)."""