fastapi-errors-plus 0.7.0__tar.gz → 0.8.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 (25) hide show
  1. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.8.0}/PKG-INFO +88 -24
  2. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.8.0}/README.md +84 -23
  3. fastapi_errors_plus-0.8.0/fastapi_errors_plus/__init__.py +17 -0
  4. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.8.0}/fastapi_errors_plus/base.py +43 -57
  5. fastapi_errors_plus-0.8.0/fastapi_errors_plus/error_doc.py +59 -0
  6. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.8.0}/fastapi_errors_plus/errors.py +196 -105
  7. fastapi_errors_plus-0.8.0/fastapi_errors_plus/example_utils.py +52 -0
  8. fastapi_errors_plus-0.8.0/fastapi_errors_plus/protocol.py +40 -0
  9. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.8.0}/fastapi_errors_plus.egg-info/PKG-INFO +88 -24
  10. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.8.0}/fastapi_errors_plus.egg-info/SOURCES.txt +5 -1
  11. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.8.0}/fastapi_errors_plus.egg-info/requires.txt +3 -0
  12. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.8.0}/pyproject.toml +19 -1
  13. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.8.0}/tests/test_app.py +26 -18
  14. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.8.0}/tests/test_base.py +72 -61
  15. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.8.0}/tests/test_errors.py +246 -174
  16. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.8.0}/tests/test_integration.py +93 -92
  17. fastapi_errors_plus-0.8.0/tests/test_release_08.py +131 -0
  18. fastapi_errors_plus-0.8.0/tests/test_release_08_edge_cases.py +243 -0
  19. fastapi_errors_plus-0.7.0/fastapi_errors_plus/__init__.py +0 -14
  20. fastapi_errors_plus-0.7.0/fastapi_errors_plus/protocol.py +0 -60
  21. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.8.0}/LICENSE +0 -0
  22. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.8.0}/fastapi_errors_plus/py.typed +0 -0
  23. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.8.0}/fastapi_errors_plus.egg-info/dependency_links.txt +0 -0
  24. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.8.0}/fastapi_errors_plus.egg-info/top_level.txt +0 -0
  25. {fastapi_errors_plus-0.7.0 → fastapi_errors_plus-0.8.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.8.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-117-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.
@@ -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,37 @@ 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
+
287
321
  ### 5. Mixed Usage
288
322
 
289
323
  Combine flags, dict, and ErrorDTO:
@@ -363,7 +397,7 @@ Order matters only for overlaps: whichever **`dict`** is applied **later** in th
363
397
 
364
398
  ## ErrorDTO Protocol
365
399
 
366
- The `ErrorDTO` protocol defines the interface for error objects compatible with the library:
400
+ The canonical **`ErrorDTO`** protocol defines the interface for error objects compatible with the library:
367
401
 
368
402
  ```python
369
403
  from typing import Protocol, Dict, Any
@@ -372,27 +406,30 @@ class ErrorDTO(Protocol):
372
406
  status_code: int
373
407
  message: str
374
408
 
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
- """
409
+ def to_examples(self) -> Dict[str, Any]:
410
+ """OpenAPI ``examples`` map: {"Key": {"value": {...}, "summary": "..."}}."""
381
411
  ...
382
412
  ```
383
413
 
384
414
  Any class implementing this protocol (through structural typing) can be used with `Errors()`.
385
415
 
416
+ **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`).
417
+
386
418
  **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
419
 
388
420
  ### When to Use Protocol vs BaseErrorDTO
389
421
 
422
+ Optional on custom DTOs (OpenAPI media-type extras without duplicating a status `dict`):
423
+
424
+ - attribute **`openapi_json_extras`**: fragment for **`content["application/json"]`** (often `{"schema": ...}` — not for `example` / `examples`);
425
+ - or method **`to_openapi_json_media_type_extras() -> Optional[dict]`** — when non-empty, overrides **`openapi_json_extras`**.
426
+
390
427
  **Use Protocol (structural typing)** when:
391
428
  - Your project already has error DTOs that implement the protocol
392
429
  - You need maximum flexibility and custom implementations
393
430
  - You want to keep your existing error infrastructure
394
431
 
395
- **Use BaseErrorDTO/StandardErrorDTO** when:
432
+ **Use BaseErrorDTO/StandardErrorDTO/ErrorDoc** when:
396
433
  - Starting a new project or adding error documentation
397
434
  - You want a ready-to-use implementation without boilerplate
398
435
  - You need multiple examples for standard HTTP errors (401, 403, etc.)
@@ -403,7 +440,7 @@ Both approaches work together — you can mix them in the same `Errors()` call!
403
440
 
404
441
  **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
442
 
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.
443
+ 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
444
 
408
445
  ### Simple Pydantic Model as ErrorDTO
409
446
 
@@ -417,8 +454,8 @@ class PydanticErrorDTO(BaseModel):
417
454
  status_code: int = Field(..., ge=400, le=599, description="HTTP status code")
418
455
  message: str = Field(..., min_length=1, description="Error message")
419
456
 
420
- def to_example(self) -> Dict[str, Any]:
421
- """Generate example for OpenAPI."""
457
+ def to_examples(self) -> Dict[str, Any]:
458
+ """Generate examples for OpenAPI."""
422
459
  return {
423
460
  self.message: {
424
461
  "value": {"detail": self.message},
@@ -461,8 +498,8 @@ class DetailedErrorDTO(BaseModel):
461
498
  error_code: Optional[str] = Field(None, description="Internal error code")
462
499
  timestamp: Optional[str] = Field(None, description="Error timestamp")
463
500
 
464
- def to_example(self) -> Dict[str, Any]:
465
- """Generate example for OpenAPI."""
501
+ def to_examples(self) -> Dict[str, Any]:
502
+ """Generate examples for OpenAPI."""
466
503
  example = {"detail": self.message}
467
504
  if self.error_code:
468
505
  example["error_code"] = self.error_code
@@ -519,7 +556,7 @@ class DomainException(Exception):
519
556
  status_code: int
520
557
  message: str
521
558
 
522
- def to_example(self) -> Dict[str, Any]:
559
+ def to_examples(self) -> Dict[str, Any]:
523
560
  return {self.message: {"value": {"detail": self.message}}}
524
561
 
525
562
  @classmethod
@@ -562,7 +599,7 @@ See [examples/domain_exceptions.py](examples/domain_exceptions.py) for complete
562
599
 
563
600
  ## Compatibility with Existing Projects
564
601
 
565
- If your project already has error DTOs (like `ApiErrorDTO`), they can work with `fastapi-errors-plus` if they implement the `ErrorDTO` protocol:
602
+ 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
603
 
567
604
  ```python
568
605
  # Your existing ApiErrorDTO
@@ -571,7 +608,7 @@ class ApiErrorDTO:
571
608
  status_code: int
572
609
  message: str
573
610
 
574
- def to_example(self) -> dict:
611
+ def to_examples(self) -> dict:
575
612
  return {
576
613
  self.message: {
577
614
  "value": {"detail": self.message},
@@ -640,9 +677,13 @@ FastAPI automatically validates all parameters (Path, Query, Body), so 422 is re
640
677
  ```python
641
678
  # Instances are Mapping keyed by HTTP status codes
642
679
  error_responses = Errors(unauthorized_401=True, forbidden_403=True)
643
- documented = error_responses[401] # response block picked up by OpenAPI
680
+ documented = error_responses[401] # deep copy safe to read, won't mutate internal state
644
681
  ```
645
682
 
683
+ **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.
684
+
685
+ **Descriptions:** response blocks with only **`model`** (no `description`) get a default from **`HTTPStatus.phrase`** so OpenAPI generation does not fail.
686
+
646
687
  ### `ErrorDTO`
647
688
 
648
689
  Protocol for error objects compatible with the library.
@@ -652,9 +693,32 @@ Protocol for error objects compatible with the library.
652
693
  - `message: str` — Error message description
653
694
 
654
695
  **Required methods:**
655
- - `to_example() -> Dict[str, Any]` — Generate example for OpenAPI
696
+ - `to_examples() -> Dict[str, Any]` — OpenAPI `examples` map for `application/json`
697
+
698
+ 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.
699
+
700
+ ### `LegacyErrorDTO` / `ErrorDTOLike`
701
+
702
+ - **`LegacyErrorDTO`** — typing helper for classes that only implement deprecated **`to_example()`**.
703
+ - **`ErrorDTOLike`** — `Union[ErrorDTO, LegacyErrorDTO]` for transitional annotations.
704
+
705
+ ### `ErrorDoc`
706
+
707
+ Declarative DTO for arbitrary example bodies and per-example **`summary`**.
708
+
709
+ **Constructor:**
710
+ ```python
711
+ ErrorDoc(
712
+ status_code: int,
713
+ message: str,
714
+ examples: Optional[Dict[str, str | dict]] = None,
715
+ body: Optional[Dict[str, Any]] = None,
716
+ example_key: Optional[str] = None,
717
+ openapi_json_extras: Optional[Dict[str, Any]] = None,
718
+ )
719
+ ```
656
720
 
657
- During `Errors(...)` initialization, non-`dict` objects in `*errors` missing `status_code`, `message`, or a callable `to_example` raise **`TypeError`** naming what was missing.
721
+ When **`examples`** is omitted, a single example is built from **`body`** or `{"detail": message}`.
658
722
 
659
723
  ### `BaseErrorDTO`
660
724
 
@@ -793,7 +857,7 @@ class DomainException(Exception):
793
857
  def __init__(self) -> None:
794
858
  super().__init__(self.message)
795
859
 
796
- def to_example(self) -> Dict[str, Any]:
860
+ def to_examples(self) -> Dict[str, Any]:
797
861
  return {
798
862
  self.message: {
799
863
  "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-117-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.
@@ -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,37 @@ 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
+
261
292
  ### 5. Mixed Usage
262
293
 
263
294
  Combine flags, dict, and ErrorDTO:
@@ -337,7 +368,7 @@ Order matters only for overlaps: whichever **`dict`** is applied **later** in th
337
368
 
338
369
  ## ErrorDTO Protocol
339
370
 
340
- The `ErrorDTO` protocol defines the interface for error objects compatible with the library:
371
+ The canonical **`ErrorDTO`** protocol defines the interface for error objects compatible with the library:
341
372
 
342
373
  ```python
343
374
  from typing import Protocol, Dict, Any
@@ -346,27 +377,30 @@ class ErrorDTO(Protocol):
346
377
  status_code: int
347
378
  message: str
348
379
 
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
- """
380
+ def to_examples(self) -> Dict[str, Any]:
381
+ """OpenAPI ``examples`` map: {"Key": {"value": {...}, "summary": "..."}}."""
355
382
  ...
356
383
  ```
357
384
 
358
385
  Any class implementing this protocol (through structural typing) can be used with `Errors()`.
359
386
 
387
+ **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`).
388
+
360
389
  **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
390
 
362
391
  ### When to Use Protocol vs BaseErrorDTO
363
392
 
393
+ Optional on custom DTOs (OpenAPI media-type extras without duplicating a status `dict`):
394
+
395
+ - attribute **`openapi_json_extras`**: fragment for **`content["application/json"]`** (often `{"schema": ...}` — not for `example` / `examples`);
396
+ - or method **`to_openapi_json_media_type_extras() -> Optional[dict]`** — when non-empty, overrides **`openapi_json_extras`**.
397
+
364
398
  **Use Protocol (structural typing)** when:
365
399
  - Your project already has error DTOs that implement the protocol
366
400
  - You need maximum flexibility and custom implementations
367
401
  - You want to keep your existing error infrastructure
368
402
 
369
- **Use BaseErrorDTO/StandardErrorDTO** when:
403
+ **Use BaseErrorDTO/StandardErrorDTO/ErrorDoc** when:
370
404
  - Starting a new project or adding error documentation
371
405
  - You want a ready-to-use implementation without boilerplate
372
406
  - You need multiple examples for standard HTTP errors (401, 403, etc.)
@@ -377,7 +411,7 @@ Both approaches work together — you can mix them in the same `Errors()` call!
377
411
 
378
412
  **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
413
 
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.
414
+ 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
415
 
382
416
  ### Simple Pydantic Model as ErrorDTO
383
417
 
@@ -391,8 +425,8 @@ class PydanticErrorDTO(BaseModel):
391
425
  status_code: int = Field(..., ge=400, le=599, description="HTTP status code")
392
426
  message: str = Field(..., min_length=1, description="Error message")
393
427
 
394
- def to_example(self) -> Dict[str, Any]:
395
- """Generate example for OpenAPI."""
428
+ def to_examples(self) -> Dict[str, Any]:
429
+ """Generate examples for OpenAPI."""
396
430
  return {
397
431
  self.message: {
398
432
  "value": {"detail": self.message},
@@ -435,8 +469,8 @@ class DetailedErrorDTO(BaseModel):
435
469
  error_code: Optional[str] = Field(None, description="Internal error code")
436
470
  timestamp: Optional[str] = Field(None, description="Error timestamp")
437
471
 
438
- def to_example(self) -> Dict[str, Any]:
439
- """Generate example for OpenAPI."""
472
+ def to_examples(self) -> Dict[str, Any]:
473
+ """Generate examples for OpenAPI."""
440
474
  example = {"detail": self.message}
441
475
  if self.error_code:
442
476
  example["error_code"] = self.error_code
@@ -493,7 +527,7 @@ class DomainException(Exception):
493
527
  status_code: int
494
528
  message: str
495
529
 
496
- def to_example(self) -> Dict[str, Any]:
530
+ def to_examples(self) -> Dict[str, Any]:
497
531
  return {self.message: {"value": {"detail": self.message}}}
498
532
 
499
533
  @classmethod
@@ -536,7 +570,7 @@ See [examples/domain_exceptions.py](examples/domain_exceptions.py) for complete
536
570
 
537
571
  ## Compatibility with Existing Projects
538
572
 
539
- If your project already has error DTOs (like `ApiErrorDTO`), they can work with `fastapi-errors-plus` if they implement the `ErrorDTO` protocol:
573
+ 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
574
 
541
575
  ```python
542
576
  # Your existing ApiErrorDTO
@@ -545,7 +579,7 @@ class ApiErrorDTO:
545
579
  status_code: int
546
580
  message: str
547
581
 
548
- def to_example(self) -> dict:
582
+ def to_examples(self) -> dict:
549
583
  return {
550
584
  self.message: {
551
585
  "value": {"detail": self.message},
@@ -614,9 +648,13 @@ FastAPI automatically validates all parameters (Path, Query, Body), so 422 is re
614
648
  ```python
615
649
  # Instances are Mapping keyed by HTTP status codes
616
650
  error_responses = Errors(unauthorized_401=True, forbidden_403=True)
617
- documented = error_responses[401] # response block picked up by OpenAPI
651
+ documented = error_responses[401] # deep copy safe to read, won't mutate internal state
618
652
  ```
619
653
 
654
+ **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.
655
+
656
+ **Descriptions:** response blocks with only **`model`** (no `description`) get a default from **`HTTPStatus.phrase`** so OpenAPI generation does not fail.
657
+
620
658
  ### `ErrorDTO`
621
659
 
622
660
  Protocol for error objects compatible with the library.
@@ -626,9 +664,32 @@ Protocol for error objects compatible with the library.
626
664
  - `message: str` — Error message description
627
665
 
628
666
  **Required methods:**
629
- - `to_example() -> Dict[str, Any]` — Generate example for OpenAPI
667
+ - `to_examples() -> Dict[str, Any]` — OpenAPI `examples` map for `application/json`
668
+
669
+ 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.
670
+
671
+ ### `LegacyErrorDTO` / `ErrorDTOLike`
672
+
673
+ - **`LegacyErrorDTO`** — typing helper for classes that only implement deprecated **`to_example()`**.
674
+ - **`ErrorDTOLike`** — `Union[ErrorDTO, LegacyErrorDTO]` for transitional annotations.
675
+
676
+ ### `ErrorDoc`
677
+
678
+ Declarative DTO for arbitrary example bodies and per-example **`summary`**.
679
+
680
+ **Constructor:**
681
+ ```python
682
+ ErrorDoc(
683
+ status_code: int,
684
+ message: str,
685
+ examples: Optional[Dict[str, str | dict]] = None,
686
+ body: Optional[Dict[str, Any]] = None,
687
+ example_key: Optional[str] = None,
688
+ openapi_json_extras: Optional[Dict[str, Any]] = None,
689
+ )
690
+ ```
630
691
 
631
- During `Errors(...)` initialization, non-`dict` objects in `*errors` missing `status_code`, `message`, or a callable `to_example` raise **`TypeError`** naming what was missing.
692
+ When **`examples`** is omitted, a single example is built from **`body`** or `{"detail": message}`.
632
693
 
633
694
  ### `BaseErrorDTO`
634
695
 
@@ -767,7 +828,7 @@ class DomainException(Exception):
767
828
  def __init__(self) -> None:
768
829
  super().__init__(self.message)
769
830
 
770
- def to_example(self) -> Dict[str, Any]:
831
+ def to_examples(self) -> Dict[str, Any]:
771
832
  return {
772
833
  self.message: {
773
834
  "value": {"detail": self.message},
@@ -0,0 +1,17 @@
1
+ """Universal library for documenting errors in FastAPI endpoints."""
2
+
3
+ from fastapi_errors_plus.base import BaseErrorDTO, StandardErrorDTO
4
+ from fastapi_errors_plus.error_doc import ErrorDoc
5
+ from fastapi_errors_plus.errors import Errors
6
+ from fastapi_errors_plus.protocol import ErrorDTO, ErrorDTOLike, LegacyErrorDTO
7
+
8
+ __all__ = [
9
+ "Errors",
10
+ "ErrorDTO",
11
+ "LegacyErrorDTO",
12
+ "ErrorDTOLike",
13
+ "ErrorDoc",
14
+ "BaseErrorDTO",
15
+ "StandardErrorDTO",
16
+ ]
17
+ __version__ = "0.8.0"
@@ -1,28 +1,31 @@
1
1
  """Base implementations of ErrorDTO protocol for convenience."""
2
2
 
3
+ import warnings
3
4
  from dataclasses import dataclass, field
4
5
  from typing import Any, Dict, Optional
5
6
 
7
+ from fastapi_errors_plus.example_utils import ExampleSpec, _normalize_example_specs
8
+
6
9
 
7
10
  @dataclass
8
11
  class BaseErrorDTO:
9
12
  """Base implementation of ErrorDTO Protocol.
10
-
13
+
11
14
  Projects can use this class directly or create their own implementation
12
15
  that implements ErrorDTO Protocol through structural typing.
13
-
16
+
14
17
  This class provides a simple way to create error DTOs without writing
15
18
  boilerplate code in every project.
16
-
19
+
17
20
  Example:
18
21
  ```python
19
22
  from fastapi_errors_plus import Errors, BaseErrorDTO
20
-
23
+
21
24
  notification_error = BaseErrorDTO(
22
25
  status_code=404,
23
26
  message="Notification not found",
24
27
  )
25
-
28
+
26
29
  @router.delete(
27
30
  "/{id}",
28
31
  responses=Errors(notification_error),
@@ -31,51 +34,48 @@ class BaseErrorDTO:
31
34
  pass
32
35
  ```
33
36
  """
37
+
34
38
  status_code: int
35
39
  """HTTP status code for the error."""
36
-
40
+
37
41
  message: str
38
42
  """Error message description."""
39
-
43
+
40
44
  openapi_json_extras: Optional[Dict[str, Any]] = field(default=None)
41
45
  """Optional OpenAPI fragment under ``content['application/json']`` besides examples,
42
46
  typically ``{\"schema\": ...}`` or ``encoding``. Do **not** put ``example`` / ``examples``
43
47
  here — use :meth:`to_example`. Arbitrary implementations may instead implement
44
48
  :meth:`to_openapi_json_media_type_extras`; that return value wins over this attribute."""
45
-
46
- def to_example(self) -> Dict[str, Any]:
47
- """Generate example for OpenAPI.
48
-
49
- Returns:
50
- Dict in format: {"key": {"value": {"detail": "message"}}}
51
-
52
- Example:
53
- ```python
54
- {
55
- "Notification not found": {
56
- "value": {"detail": "Notification not found"},
57
- },
58
- }
59
- ```
60
- """
49
+
50
+ def to_examples(self) -> Dict[str, Any]:
51
+ """Generate OpenAPI ``examples`` for this error."""
61
52
  return {
62
53
  self.message: {
63
54
  "value": {"detail": self.message},
64
55
  },
65
56
  }
66
57
 
58
+ def to_example(self) -> Dict[str, Any]:
59
+ """Deprecated alias for :meth:`to_examples`."""
60
+ warnings.warn(
61
+ "to_example() is deprecated; use to_examples() instead.",
62
+ DeprecationWarning,
63
+ stacklevel=2,
64
+ )
65
+ return self.to_examples()
66
+
67
67
 
68
68
  @dataclass
69
69
  class StandardErrorDTO(BaseErrorDTO):
70
70
  """Extended implementation for errors with multiple examples.
71
-
71
+
72
72
  Useful for standard HTTP errors (401, 403) that can have different
73
73
  causes with different messages.
74
-
74
+
75
75
  Example:
76
76
  ```python
77
77
  from fastapi_errors_plus import Errors, StandardErrorDTO
78
-
78
+
79
79
  unauthorized_error = StandardErrorDTO(
80
80
  status_code=401,
81
81
  message="Unauthorized",
@@ -84,7 +84,7 @@ class StandardErrorDTO(BaseErrorDTO):
84
84
  "SessionNotFound": "Сессия пользователя не была найдена.",
85
85
  },
86
86
  )
87
-
87
+
88
88
  @router.delete(
89
89
  "/{id}",
90
90
  responses=Errors(unauthorized_error),
@@ -93,39 +93,25 @@ class StandardErrorDTO(BaseErrorDTO):
93
93
  pass
94
94
  ```
95
95
  """
96
- examples: Optional[Dict[str, str]] = field(default=None)
97
- """Dictionary of examples: {"key": "message"}."""
98
-
96
+
97
+ examples: Optional[Dict[str, ExampleSpec]] = field(default=None)
98
+ """Examples: ``str`` detail text or full OpenAPI example object (with ``summary``)."""
99
+
99
100
  def __post_init__(self) -> None:
100
101
  """Initialize examples with default value if not provided."""
101
102
  if self.examples is None:
102
103
  self.examples = {self.message: self.message}
103
-
104
- def to_example(self) -> Dict[str, Any]:
105
- """Generate examples for OpenAPI with multiple examples.
106
-
107
- Returns:
108
- Dict in format: {"key": {"value": {"detail": "message"}}, ...}
109
-
110
- Example:
111
- ```python
112
- {
113
- "InvalidToken": {
114
- "value": {"detail": "Ошибка декодирования токена."},
115
- },
116
- "SessionNotFound": {
117
- "value": {"detail": "Сессия пользователя не была найдена."},
118
- },
119
- }
120
- ```
121
- """
122
- return {
123
- key: {
124
- "value": {"detail": message},
125
- }
126
- for key, message in self.examples.items()
127
- }
128
-
129
-
130
104
 
105
+ def to_examples(self) -> Dict[str, Any]:
106
+ """Generate OpenAPI ``examples`` with optional summaries."""
107
+ assert self.examples is not None
108
+ return _normalize_example_specs(self.examples)
131
109
 
110
+ def to_example(self) -> Dict[str, Any]:
111
+ """Deprecated alias for :meth:`to_examples`."""
112
+ warnings.warn(
113
+ "to_example() is deprecated; use to_examples() instead.",
114
+ DeprecationWarning,
115
+ stacklevel=2,
116
+ )
117
+ return self.to_examples()