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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.9.0}/PKG-INFO +152 -30
  2. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.9.0}/README.md +148 -29
  3. fastapi_errors_plus-0.9.0/fastapi_errors_plus/__init__.py +19 -0
  4. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus/base.py +49 -57
  5. fastapi_errors_plus-0.9.0/fastapi_errors_plus/error_doc.py +65 -0
  6. fastapi_errors_plus-0.9.0/fastapi_errors_plus/error_profile.py +31 -0
  7. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus/errors.py +293 -232
  8. fastapi_errors_plus-0.9.0/fastapi_errors_plus/example_utils.py +52 -0
  9. fastapi_errors_plus-0.9.0/fastapi_errors_plus/merge_utils.py +61 -0
  10. fastapi_errors_plus-0.9.0/fastapi_errors_plus/protocol.py +40 -0
  11. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus.egg-info/PKG-INFO +152 -30
  12. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus.egg-info/SOURCES.txt +8 -1
  13. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus.egg-info/requires.txt +3 -0
  14. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.9.0}/pyproject.toml +22 -1
  15. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.9.0}/tests/test_app.py +26 -18
  16. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.9.0}/tests/test_base.py +72 -61
  17. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.9.0}/tests/test_errors.py +248 -181
  18. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.9.0}/tests/test_integration.py +93 -92
  19. fastapi_errors_plus-0.9.0/tests/test_release_08.py +131 -0
  20. fastapi_errors_plus-0.9.0/tests/test_release_08_edge_cases.py +243 -0
  21. fastapi_errors_plus-0.9.0/tests/test_release_09.py +122 -0
  22. fastapi_errors_plus-0.7.0/fastapi_errors_plus/__init__.py +0 -14
  23. fastapi_errors_plus-0.7.0/fastapi_errors_plus/protocol.py +0 -60
  24. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.9.0}/LICENSE +0 -0
  25. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus/py.typed +0 -0
  26. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus.egg-info/dependency_links.txt +0 -0
  27. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.9.0}/fastapi_errors_plus.egg-info/top_level.txt +0 -0
  28. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.9.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-errors-plus
3
- Version: 0.7.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
@@ -22,6 +22,9 @@ Provides-Extra: dev
22
22
  Requires-Dist: pytest>=7.4.0; extra == "dev"
23
23
  Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
24
24
  Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
25
+ Requires-Dist: black>=23.0.0; extra == "dev"
26
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
27
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
25
28
  Dynamic: license-file
26
29
 
27
30
  # fastapi-errors-plus
@@ -30,7 +33,7 @@ Dynamic: license-file
30
33
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
31
34
  [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
32
35
  [![FastAPI](https://img.shields.io/badge/FastAPI-0.104%2B-009688.svg)](https://fastapi.tiangolo.com/)
33
- [![Tests](https://img.shields.io/badge/tests-101-success.svg)](https://github.com/seligoroff/fastapi-errors-plus)
36
+ [![Tests](https://img.shields.io/badge/tests-138-success.svg)](https://github.com/seligoroff/fastapi-errors-plus)
34
37
  [![Coverage](https://img.shields.io/badge/coverage-80%25%2B-green.svg)](https://github.com/seligoroff/fastapi-errors-plus)
35
38
 
36
39
  Universal library for documenting errors in FastAPI endpoints.
@@ -105,13 +108,13 @@ Use boolean flags for common HTTP status codes:
105
108
  - `validation_error_422=True` → 422 Unprocessable Entity (defaults to `True`)
106
109
  - `internal_server_error_500=True` → 500 Internal Server Error
107
110
 
108
- **Legacy (for backward compatibility):**
111
+ **Legacy (deprecated in 0.9, removal in 1.0):**
109
112
  - `unauthorized=True` → 401 Unauthorized
110
113
  - `forbidden=True` → 403 Forbidden
111
- - `validation_error=True` → 422 Unprocessable Entity (defaults to `True`)
114
+ - `validation_error=True` → 422 Unprocessable Entity
112
115
  - `internal_server_error=True` → 500 Internal Server Error
113
116
 
114
- **Note:** `validation_error` and `validation_error_422` default to `True` because FastAPI automatically validates all parameters (Path, Query, Body), making 422 relevant in 95%+ of endpoints. Set to `False` only for endpoints without parameters.
117
+ **Note on 422:** If you omit both `validation_error` and `validation_error_422`, the library still adds **422** today but emits a **`DeprecationWarning`** the default will become **`False` in 1.0**. For ADR-style APIs, set `validation_error_422=False` explicitly (or use **`ErrorProfile`** below).
115
118
 
116
119
  ```python
117
120
  @router.get(
@@ -164,7 +167,7 @@ class MyErrorDTO:
164
167
  status_code = 404
165
168
  message = "Not found"
166
169
 
167
- def to_example(self):
170
+ def to_examples(self):
168
171
  return {
169
172
  "Not found": {
170
173
  "value": {"detail": "Not found"},
@@ -209,7 +212,7 @@ def delete_item(id: int):
209
212
 
210
213
  #### OpenAPI extras (`schema`) next to examples
211
214
 
212
- ADR-style payloads (`code`, `detail`, optional `context`) need a **`schema`** in the spec besides **`examples`**. On **`BaseErrorDTO`** / **`StandardErrorDTO`** use **`openapi_json_extras`** (a dict merged under `content["application/json"]` — omit `example` / `examples` there; **`to_example()`** still defines examples):
215
+ ADR-style payloads (`code`, `detail`, optional `context`) need a **`schema`** in the spec besides **`examples`**. On **`BaseErrorDTO`** / **`StandardErrorDTO`** / **`ErrorDoc`** use **`openapi_json_extras`** (a dict merged under `content["application/json"]` — omit `example` / `examples` there; **`to_examples()`** still defines examples):
213
216
 
214
217
  ```python
215
218
  from fastapi import APIRouter, status
@@ -284,6 +287,75 @@ def delete_item(id: int):
284
287
  - Reusable across all endpoints
285
288
  - Supports inheritance for custom logic
286
289
 
290
+ `examples` values may be **strings** (shorthand for `{"detail": text}`) or full OpenAPI Example Objects with **`summary`** and **`value`**.
291
+
292
+ #### ErrorDoc
293
+
294
+ For arbitrary response bodies (ADR `code` / `detail` / `context`, not only `detail` strings), use **`ErrorDoc`**:
295
+
296
+ ```python
297
+ from fastapi_errors_plus import ErrorDoc, Errors
298
+
299
+ permission_denied = ErrorDoc(
300
+ status_code=403,
301
+ message="Insufficient permissions",
302
+ examples={
303
+ "MissingRole": {
304
+ "summary": "User lacks required role",
305
+ "value": {
306
+ "code": "FORBIDDEN",
307
+ "detail": "Role admin required",
308
+ },
309
+ },
310
+ },
311
+ openapi_json_extras={"schema": ADR_ERROR_BODY_SCHEMA},
312
+ )
313
+
314
+ @router.delete("/{id}", responses=Errors(permission_denied))
315
+ def delete_item(id: int):
316
+ ...
317
+ ```
318
+
319
+ A **plain dict** as an example value is treated as the full response **body** unless it looks like an OpenAPI Example Object (keys only among `value`, `summary`, `description`, `externalValue`).
320
+
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
+
287
359
  ### 5. Mixed Usage
288
360
 
289
361
  Combine flags, dict, and ErrorDTO:
@@ -363,7 +435,7 @@ Order matters only for overlaps: whichever **`dict`** is applied **later** in th
363
435
 
364
436
  ## ErrorDTO Protocol
365
437
 
366
- The `ErrorDTO` protocol defines the interface for error objects compatible with the library:
438
+ The canonical **`ErrorDTO`** protocol defines the interface for error objects compatible with the library:
367
439
 
368
440
  ```python
369
441
  from typing import Protocol, Dict, Any
@@ -372,27 +444,30 @@ class ErrorDTO(Protocol):
372
444
  status_code: int
373
445
  message: str
374
446
 
375
- def to_example(self) -> Dict[str, Any]:
376
- """Generate example for OpenAPI.
377
-
378
- Returns:
379
- Dict in format: {"key": {"value": {"detail": "message"}}}
380
- """
447
+ def to_examples(self) -> Dict[str, Any]:
448
+ """OpenAPI ``examples`` map: {"Key": {"value": {...}, "summary": "..."}}."""
381
449
  ...
382
450
  ```
383
451
 
384
452
  Any class implementing this protocol (through structural typing) can be used with `Errors()`.
385
453
 
454
+ **Legacy migration:** classes that only define **`to_example()`** still work at **runtime** (`DeprecationWarning` once per class per `Errors()` call). For static typing use **`LegacyErrorDTO`** or the union alias **`ErrorDTOLike`** (`ErrorDTO | LegacyErrorDTO`).
455
+
386
456
  **Best Practice:** For maximum clarity, consider making your domain exceptions implement the ErrorDTO protocol directly. See [Best Practice: Connecting Exceptions and ErrorDTO](#best-practice-connecting-exceptions-and-errordto) for details.
387
457
 
388
458
  ### When to Use Protocol vs BaseErrorDTO
389
459
 
460
+ Optional on custom DTOs (OpenAPI media-type extras without duplicating a status `dict`):
461
+
462
+ - attribute **`openapi_json_extras`**: fragment for **`content["application/json"]`** (often `{"schema": ...}` — not for `example` / `examples`);
463
+ - or method **`to_openapi_json_media_type_extras() -> Optional[dict]`** — when non-empty, overrides **`openapi_json_extras`**.
464
+
390
465
  **Use Protocol (structural typing)** when:
391
466
  - Your project already has error DTOs that implement the protocol
392
467
  - You need maximum flexibility and custom implementations
393
468
  - You want to keep your existing error infrastructure
394
469
 
395
- **Use BaseErrorDTO/StandardErrorDTO** when:
470
+ **Use BaseErrorDTO/StandardErrorDTO/ErrorDoc** when:
396
471
  - Starting a new project or adding error documentation
397
472
  - You want a ready-to-use implementation without boilerplate
398
473
  - You need multiple examples for standard HTTP errors (401, 403, etc.)
@@ -403,7 +478,7 @@ Both approaches work together — you can mix them in the same `Errors()` call!
403
478
 
404
479
  **Note:** Pydantic is **not required** to use this library. This section is for projects that already use Pydantic and want to integrate it with the ErrorDTO protocol.
405
480
 
406
- Since the library uses structural typing (Protocol), any class that implements the required attributes (`status_code`, `message`, `to_example()`) will work, including Pydantic models.
481
+ Since the library uses structural typing (Protocol), any class that implements the required attributes (`status_code`, `message`, `to_examples()`) will work, including Pydantic models. Legacy `to_example()` is still accepted at runtime.
407
482
 
408
483
  ### Simple Pydantic Model as ErrorDTO
409
484
 
@@ -417,8 +492,8 @@ class PydanticErrorDTO(BaseModel):
417
492
  status_code: int = Field(..., ge=400, le=599, description="HTTP status code")
418
493
  message: str = Field(..., min_length=1, description="Error message")
419
494
 
420
- def to_example(self) -> Dict[str, Any]:
421
- """Generate example for OpenAPI."""
495
+ def to_examples(self) -> Dict[str, Any]:
496
+ """Generate examples for OpenAPI."""
422
497
  return {
423
498
  self.message: {
424
499
  "value": {"detail": self.message},
@@ -461,8 +536,8 @@ class DetailedErrorDTO(BaseModel):
461
536
  error_code: Optional[str] = Field(None, description="Internal error code")
462
537
  timestamp: Optional[str] = Field(None, description="Error timestamp")
463
538
 
464
- def to_example(self) -> Dict[str, Any]:
465
- """Generate example for OpenAPI."""
539
+ def to_examples(self) -> Dict[str, Any]:
540
+ """Generate examples for OpenAPI."""
466
541
  example = {"detail": self.message}
467
542
  if self.error_code:
468
543
  example["error_code"] = self.error_code
@@ -519,7 +594,7 @@ class DomainException(Exception):
519
594
  status_code: int
520
595
  message: str
521
596
 
522
- def to_example(self) -> Dict[str, Any]:
597
+ def to_examples(self) -> Dict[str, Any]:
523
598
  return {self.message: {"value": {"detail": self.message}}}
524
599
 
525
600
  @classmethod
@@ -562,7 +637,7 @@ See [examples/domain_exceptions.py](examples/domain_exceptions.py) for complete
562
637
 
563
638
  ## Compatibility with Existing Projects
564
639
 
565
- If your project already has error DTOs (like `ApiErrorDTO`), they can work with `fastapi-errors-plus` if they implement the `ErrorDTO` protocol:
640
+ If your project already has error DTOs (like `ApiErrorDTO`), they can work with `fastapi-errors-plus` if they implement the `ErrorDTO` protocol (or legacy `to_example()` during migration):
566
641
 
567
642
  ```python
568
643
  # Your existing ApiErrorDTO
@@ -571,7 +646,7 @@ class ApiErrorDTO:
571
646
  status_code: int
572
647
  message: str
573
648
 
574
- def to_example(self) -> dict:
649
+ def to_examples(self) -> dict:
575
650
  return {
576
651
  self.message: {
577
652
  "value": {"detail": self.message},
@@ -608,6 +683,7 @@ Errors(
608
683
  forbidden_403: bool = False,
609
684
  validation_error_422: Optional[bool] = None, # None (default) => True (FastAPI validates all parameters)
610
685
  internal_server_error_500: bool = False,
686
+ profile: Optional[ErrorProfile] = None,
611
687
  )
612
688
  ```
613
689
 
@@ -626,10 +702,12 @@ Errors(
626
702
  - `None` (default): Add 422 (True by default, FastAPI validates all parameters)
627
703
  - `False`: Explicitly disable 422
628
704
  - `True`: Explicitly enable 422
629
- - `internal_server_error`: Add 500 Internal Server Error (legacy, for backward compatibility). Defaults to `False`.
705
+ - `internal_server_error`: Add 500 Internal Server Error (legacy, **deprecated** in 0.9). Defaults to `False`.
706
+ - `profile`: Optional **`ErrorProfile`** — project defaults; explicit kwargs override profile.
707
+
708
+ **Deprecation (0.9+):** legacy kwargs above emit `DeprecationWarning` (removal in **1.0**). Omitting both `validation_error` and `validation_error_422` still adds 422 but warns; default becomes **`False` in 1.0**.
630
709
 
631
- **Why `validation_error=True` by default?**
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`.
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.
633
711
 
634
712
  **Returns:**
635
713
  - A dict-like `Mapping[int, …]` suitable for FastAPI’s `responses` / OpenAPI
@@ -640,9 +718,13 @@ FastAPI automatically validates all parameters (Path, Query, Body), so 422 is re
640
718
  ```python
641
719
  # Instances are Mapping keyed by HTTP status codes
642
720
  error_responses = Errors(unauthorized_401=True, forbidden_403=True)
643
- documented = error_responses[401] # response block picked up by OpenAPI
721
+ documented = error_responses[401] # deep copy safe to read, won't mutate internal state
644
722
  ```
645
723
 
724
+ **Isolation (0.8+):** incoming response **dicts** are **deep-copied** on ingest; **`errors[status]`** returns a **deep copy** so callers cannot corrupt shared registry templates or internal merge state.
725
+
726
+ **Descriptions:** response blocks with only **`model`** (no `description`) get a default from **`HTTPStatus.phrase`** so OpenAPI generation does not fail.
727
+
646
728
  ### `ErrorDTO`
647
729
 
648
730
  Protocol for error objects compatible with the library.
@@ -652,9 +734,47 @@ Protocol for error objects compatible with the library.
652
734
  - `message: str` — Error message description
653
735
 
654
736
  **Required methods:**
655
- - `to_example() -> Dict[str, Any]` — Generate example for OpenAPI
737
+ - `to_examples() -> Dict[str, Any]` — OpenAPI `examples` map for `application/json`
738
+
739
+ During `Errors(...)` initialization, non-`dict` objects in `*errors` missing `status_code`, `message`, or a callable **`to_examples()`** / **`to_example()`** raise **`TypeError`** naming what was missing.
740
+
741
+ ### `LegacyErrorDTO` / `ErrorDTOLike`
656
742
 
657
- During `Errors(...)` initialization, non-`dict` objects in `*errors` missing `status_code`, `message`, or a callable `to_example` raise **`TypeError`** naming what was missing.
743
+ - **`LegacyErrorDTO`** typing helper for classes that only implement deprecated **`to_example()`**.
744
+ - **`ErrorDTOLike`** — `Union[ErrorDTO, LegacyErrorDTO]` for transitional annotations.
745
+
746
+ ### `ErrorDoc`
747
+
748
+ Declarative DTO for arbitrary example bodies and per-example **`summary`**.
749
+
750
+ **Constructor:**
751
+ ```python
752
+ ErrorDoc(
753
+ status_code: int,
754
+ message: str,
755
+ examples: Optional[Dict[str, str | dict]] = None,
756
+ body: Optional[Dict[str, Any]] = None,
757
+ example_key: Optional[str] = None,
758
+ model: Any = None,
759
+ schema: Optional[Dict[str, Any]] = None,
760
+ openapi_json_extras: Optional[Dict[str, Any]] = None,
761
+ )
762
+ ```
763
+
764
+ When **`examples`** is omitted, a single example is built from **`body`** or `{"detail": message}`.
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
+ ```
658
778
 
659
779
  ### `BaseErrorDTO`
660
780
 
@@ -665,6 +785,8 @@ Base implementation of ErrorDTO Protocol for convenience.
665
785
  BaseErrorDTO(
666
786
  status_code: int,
667
787
  message: str,
788
+ model: Any = None,
789
+ schema: Optional[Dict[str, Any]] = None,
668
790
  openapi_json_extras: Optional[Dict[str, Any]] = None,
669
791
  )
670
792
  ```
@@ -793,7 +915,7 @@ class DomainException(Exception):
793
915
  def __init__(self) -> None:
794
916
  super().__init__(self.message)
795
917
 
796
- def to_example(self) -> Dict[str, Any]:
918
+ def to_examples(self) -> Dict[str, Any]:
797
919
  return {
798
920
  self.message: {
799
921
  "value": {"detail": self.message},
@@ -4,7 +4,7 @@
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
5
  [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
6
6
  [![FastAPI](https://img.shields.io/badge/FastAPI-0.104%2B-009688.svg)](https://fastapi.tiangolo.com/)
7
- [![Tests](https://img.shields.io/badge/tests-101-success.svg)](https://github.com/seligoroff/fastapi-errors-plus)
7
+ [![Tests](https://img.shields.io/badge/tests-138-success.svg)](https://github.com/seligoroff/fastapi-errors-plus)
8
8
  [![Coverage](https://img.shields.io/badge/coverage-80%25%2B-green.svg)](https://github.com/seligoroff/fastapi-errors-plus)
9
9
 
10
10
  Universal library for documenting errors in FastAPI endpoints.
@@ -79,13 +79,13 @@ Use boolean flags for common HTTP status codes:
79
79
  - `validation_error_422=True` → 422 Unprocessable Entity (defaults to `True`)
80
80
  - `internal_server_error_500=True` → 500 Internal Server Error
81
81
 
82
- **Legacy (for backward compatibility):**
82
+ **Legacy (deprecated in 0.9, removal in 1.0):**
83
83
  - `unauthorized=True` → 401 Unauthorized
84
84
  - `forbidden=True` → 403 Forbidden
85
- - `validation_error=True` → 422 Unprocessable Entity (defaults to `True`)
85
+ - `validation_error=True` → 422 Unprocessable Entity
86
86
  - `internal_server_error=True` → 500 Internal Server Error
87
87
 
88
- **Note:** `validation_error` and `validation_error_422` default to `True` because FastAPI automatically validates all parameters (Path, Query, Body), making 422 relevant in 95%+ of endpoints. Set to `False` only for endpoints without parameters.
88
+ **Note on 422:** If you omit both `validation_error` and `validation_error_422`, the library still adds **422** today but emits a **`DeprecationWarning`** the default will become **`False` in 1.0**. For ADR-style APIs, set `validation_error_422=False` explicitly (or use **`ErrorProfile`** below).
89
89
 
90
90
  ```python
91
91
  @router.get(
@@ -138,7 +138,7 @@ class MyErrorDTO:
138
138
  status_code = 404
139
139
  message = "Not found"
140
140
 
141
- def to_example(self):
141
+ def to_examples(self):
142
142
  return {
143
143
  "Not found": {
144
144
  "value": {"detail": "Not found"},
@@ -183,7 +183,7 @@ def delete_item(id: int):
183
183
 
184
184
  #### OpenAPI extras (`schema`) next to examples
185
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):
186
+ ADR-style payloads (`code`, `detail`, optional `context`) need a **`schema`** in the spec besides **`examples`**. On **`BaseErrorDTO`** / **`StandardErrorDTO`** / **`ErrorDoc`** use **`openapi_json_extras`** (a dict merged under `content["application/json"]` — omit `example` / `examples` there; **`to_examples()`** still defines examples):
187
187
 
188
188
  ```python
189
189
  from fastapi import APIRouter, status
@@ -258,6 +258,75 @@ def delete_item(id: int):
258
258
  - Reusable across all endpoints
259
259
  - Supports inheritance for custom logic
260
260
 
261
+ `examples` values may be **strings** (shorthand for `{"detail": text}`) or full OpenAPI Example Objects with **`summary`** and **`value`**.
262
+
263
+ #### ErrorDoc
264
+
265
+ For arbitrary response bodies (ADR `code` / `detail` / `context`, not only `detail` strings), use **`ErrorDoc`**:
266
+
267
+ ```python
268
+ from fastapi_errors_plus import ErrorDoc, Errors
269
+
270
+ permission_denied = ErrorDoc(
271
+ status_code=403,
272
+ message="Insufficient permissions",
273
+ examples={
274
+ "MissingRole": {
275
+ "summary": "User lacks required role",
276
+ "value": {
277
+ "code": "FORBIDDEN",
278
+ "detail": "Role admin required",
279
+ },
280
+ },
281
+ },
282
+ openapi_json_extras={"schema": ADR_ERROR_BODY_SCHEMA},
283
+ )
284
+
285
+ @router.delete("/{id}", responses=Errors(permission_denied))
286
+ def delete_item(id: int):
287
+ ...
288
+ ```
289
+
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
+
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
+
261
330
  ### 5. Mixed Usage
262
331
 
263
332
  Combine flags, dict, and ErrorDTO:
@@ -337,7 +406,7 @@ Order matters only for overlaps: whichever **`dict`** is applied **later** in th
337
406
 
338
407
  ## ErrorDTO Protocol
339
408
 
340
- The `ErrorDTO` protocol defines the interface for error objects compatible with the library:
409
+ The canonical **`ErrorDTO`** protocol defines the interface for error objects compatible with the library:
341
410
 
342
411
  ```python
343
412
  from typing import Protocol, Dict, Any
@@ -346,27 +415,30 @@ class ErrorDTO(Protocol):
346
415
  status_code: int
347
416
  message: str
348
417
 
349
- def to_example(self) -> Dict[str, Any]:
350
- """Generate example for OpenAPI.
351
-
352
- Returns:
353
- Dict in format: {"key": {"value": {"detail": "message"}}}
354
- """
418
+ def to_examples(self) -> Dict[str, Any]:
419
+ """OpenAPI ``examples`` map: {"Key": {"value": {...}, "summary": "..."}}."""
355
420
  ...
356
421
  ```
357
422
 
358
423
  Any class implementing this protocol (through structural typing) can be used with `Errors()`.
359
424
 
425
+ **Legacy migration:** classes that only define **`to_example()`** still work at **runtime** (`DeprecationWarning` once per class per `Errors()` call). For static typing use **`LegacyErrorDTO`** or the union alias **`ErrorDTOLike`** (`ErrorDTO | LegacyErrorDTO`).
426
+
360
427
  **Best Practice:** For maximum clarity, consider making your domain exceptions implement the ErrorDTO protocol directly. See [Best Practice: Connecting Exceptions and ErrorDTO](#best-practice-connecting-exceptions-and-errordto) for details.
361
428
 
362
429
  ### When to Use Protocol vs BaseErrorDTO
363
430
 
431
+ Optional on custom DTOs (OpenAPI media-type extras without duplicating a status `dict`):
432
+
433
+ - attribute **`openapi_json_extras`**: fragment for **`content["application/json"]`** (often `{"schema": ...}` — not for `example` / `examples`);
434
+ - or method **`to_openapi_json_media_type_extras() -> Optional[dict]`** — when non-empty, overrides **`openapi_json_extras`**.
435
+
364
436
  **Use Protocol (structural typing)** when:
365
437
  - Your project already has error DTOs that implement the protocol
366
438
  - You need maximum flexibility and custom implementations
367
439
  - You want to keep your existing error infrastructure
368
440
 
369
- **Use BaseErrorDTO/StandardErrorDTO** when:
441
+ **Use BaseErrorDTO/StandardErrorDTO/ErrorDoc** when:
370
442
  - Starting a new project or adding error documentation
371
443
  - You want a ready-to-use implementation without boilerplate
372
444
  - You need multiple examples for standard HTTP errors (401, 403, etc.)
@@ -377,7 +449,7 @@ Both approaches work together — you can mix them in the same `Errors()` call!
377
449
 
378
450
  **Note:** Pydantic is **not required** to use this library. This section is for projects that already use Pydantic and want to integrate it with the ErrorDTO protocol.
379
451
 
380
- Since the library uses structural typing (Protocol), any class that implements the required attributes (`status_code`, `message`, `to_example()`) will work, including Pydantic models.
452
+ Since the library uses structural typing (Protocol), any class that implements the required attributes (`status_code`, `message`, `to_examples()`) will work, including Pydantic models. Legacy `to_example()` is still accepted at runtime.
381
453
 
382
454
  ### Simple Pydantic Model as ErrorDTO
383
455
 
@@ -391,8 +463,8 @@ class PydanticErrorDTO(BaseModel):
391
463
  status_code: int = Field(..., ge=400, le=599, description="HTTP status code")
392
464
  message: str = Field(..., min_length=1, description="Error message")
393
465
 
394
- def to_example(self) -> Dict[str, Any]:
395
- """Generate example for OpenAPI."""
466
+ def to_examples(self) -> Dict[str, Any]:
467
+ """Generate examples for OpenAPI."""
396
468
  return {
397
469
  self.message: {
398
470
  "value": {"detail": self.message},
@@ -435,8 +507,8 @@ class DetailedErrorDTO(BaseModel):
435
507
  error_code: Optional[str] = Field(None, description="Internal error code")
436
508
  timestamp: Optional[str] = Field(None, description="Error timestamp")
437
509
 
438
- def to_example(self) -> Dict[str, Any]:
439
- """Generate example for OpenAPI."""
510
+ def to_examples(self) -> Dict[str, Any]:
511
+ """Generate examples for OpenAPI."""
440
512
  example = {"detail": self.message}
441
513
  if self.error_code:
442
514
  example["error_code"] = self.error_code
@@ -493,7 +565,7 @@ class DomainException(Exception):
493
565
  status_code: int
494
566
  message: str
495
567
 
496
- def to_example(self) -> Dict[str, Any]:
568
+ def to_examples(self) -> Dict[str, Any]:
497
569
  return {self.message: {"value": {"detail": self.message}}}
498
570
 
499
571
  @classmethod
@@ -536,7 +608,7 @@ See [examples/domain_exceptions.py](examples/domain_exceptions.py) for complete
536
608
 
537
609
  ## Compatibility with Existing Projects
538
610
 
539
- If your project already has error DTOs (like `ApiErrorDTO`), they can work with `fastapi-errors-plus` if they implement the `ErrorDTO` protocol:
611
+ If your project already has error DTOs (like `ApiErrorDTO`), they can work with `fastapi-errors-plus` if they implement the `ErrorDTO` protocol (or legacy `to_example()` during migration):
540
612
 
541
613
  ```python
542
614
  # Your existing ApiErrorDTO
@@ -545,7 +617,7 @@ class ApiErrorDTO:
545
617
  status_code: int
546
618
  message: str
547
619
 
548
- def to_example(self) -> dict:
620
+ def to_examples(self) -> dict:
549
621
  return {
550
622
  self.message: {
551
623
  "value": {"detail": self.message},
@@ -582,6 +654,7 @@ Errors(
582
654
  forbidden_403: bool = False,
583
655
  validation_error_422: Optional[bool] = None, # None (default) => True (FastAPI validates all parameters)
584
656
  internal_server_error_500: bool = False,
657
+ profile: Optional[ErrorProfile] = None,
585
658
  )
586
659
  ```
587
660
 
@@ -600,10 +673,12 @@ Errors(
600
673
  - `None` (default): Add 422 (True by default, FastAPI validates all parameters)
601
674
  - `False`: Explicitly disable 422
602
675
  - `True`: Explicitly enable 422
603
- - `internal_server_error`: Add 500 Internal Server Error (legacy, for backward compatibility). Defaults to `False`.
676
+ - `internal_server_error`: Add 500 Internal Server Error (legacy, **deprecated** in 0.9). Defaults to `False`.
677
+ - `profile`: Optional **`ErrorProfile`** — project defaults; explicit kwargs override profile.
678
+
679
+ **Deprecation (0.9+):** legacy kwargs above emit `DeprecationWarning` (removal in **1.0**). Omitting both `validation_error` and `validation_error_422` still adds 422 but warns; default becomes **`False` in 1.0**.
604
680
 
605
- **Why `validation_error=True` by default?**
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`.
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.
607
682
 
608
683
  **Returns:**
609
684
  - A dict-like `Mapping[int, …]` suitable for FastAPI’s `responses` / OpenAPI
@@ -614,9 +689,13 @@ FastAPI automatically validates all parameters (Path, Query, Body), so 422 is re
614
689
  ```python
615
690
  # Instances are Mapping keyed by HTTP status codes
616
691
  error_responses = Errors(unauthorized_401=True, forbidden_403=True)
617
- documented = error_responses[401] # response block picked up by OpenAPI
692
+ documented = error_responses[401] # deep copy safe to read, won't mutate internal state
618
693
  ```
619
694
 
695
+ **Isolation (0.8+):** incoming response **dicts** are **deep-copied** on ingest; **`errors[status]`** returns a **deep copy** so callers cannot corrupt shared registry templates or internal merge state.
696
+
697
+ **Descriptions:** response blocks with only **`model`** (no `description`) get a default from **`HTTPStatus.phrase`** so OpenAPI generation does not fail.
698
+
620
699
  ### `ErrorDTO`
621
700
 
622
701
  Protocol for error objects compatible with the library.
@@ -626,9 +705,47 @@ Protocol for error objects compatible with the library.
626
705
  - `message: str` — Error message description
627
706
 
628
707
  **Required methods:**
629
- - `to_example() -> Dict[str, Any]` — Generate example for OpenAPI
708
+ - `to_examples() -> Dict[str, Any]` — OpenAPI `examples` map for `application/json`
709
+
710
+ During `Errors(...)` initialization, non-`dict` objects in `*errors` missing `status_code`, `message`, or a callable **`to_examples()`** / **`to_example()`** raise **`TypeError`** naming what was missing.
711
+
712
+ ### `LegacyErrorDTO` / `ErrorDTOLike`
630
713
 
631
- During `Errors(...)` initialization, non-`dict` objects in `*errors` missing `status_code`, `message`, or a callable `to_example` raise **`TypeError`** naming what was missing.
714
+ - **`LegacyErrorDTO`** typing helper for classes that only implement deprecated **`to_example()`**.
715
+ - **`ErrorDTOLike`** — `Union[ErrorDTO, LegacyErrorDTO]` for transitional annotations.
716
+
717
+ ### `ErrorDoc`
718
+
719
+ Declarative DTO for arbitrary example bodies and per-example **`summary`**.
720
+
721
+ **Constructor:**
722
+ ```python
723
+ ErrorDoc(
724
+ status_code: int,
725
+ message: str,
726
+ examples: Optional[Dict[str, str | dict]] = None,
727
+ body: Optional[Dict[str, Any]] = None,
728
+ example_key: Optional[str] = None,
729
+ model: Any = None,
730
+ schema: Optional[Dict[str, Any]] = None,
731
+ openapi_json_extras: Optional[Dict[str, Any]] = None,
732
+ )
733
+ ```
734
+
735
+ When **`examples`** is omitted, a single example is built from **`body`** or `{"detail": message}`.
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
+ ```
632
749
 
633
750
  ### `BaseErrorDTO`
634
751
 
@@ -639,6 +756,8 @@ Base implementation of ErrorDTO Protocol for convenience.
639
756
  BaseErrorDTO(
640
757
  status_code: int,
641
758
  message: str,
759
+ model: Any = None,
760
+ schema: Optional[Dict[str, Any]] = None,
642
761
  openapi_json_extras: Optional[Dict[str, Any]] = None,
643
762
  )
644
763
  ```
@@ -767,7 +886,7 @@ class DomainException(Exception):
767
886
  def __init__(self) -> None:
768
887
  super().__init__(self.message)
769
888
 
770
- def to_example(self) -> Dict[str, Any]:
889
+ def to_examples(self) -> Dict[str, Any]:
771
890
  return {
772
891
  self.message: {
773
892
  "value": {"detail": self.message},