fastapi-errors-plus 0.6.2__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.6.2 → fastapi_errors_plus-0.8.0}/PKG-INFO +177 -52
  2. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.8.0}/README.md +173 -51
  3. fastapi_errors_plus-0.8.0/fastapi_errors_plus/__init__.py +17 -0
  4. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.8.0}/fastapi_errors_plus/base.py +48 -56
  5. fastapi_errors_plus-0.8.0/fastapi_errors_plus/error_doc.py +59 -0
  6. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.8.0}/fastapi_errors_plus/errors.py +244 -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.6.2 → fastapi_errors_plus-0.8.0}/fastapi_errors_plus.egg-info/PKG-INFO +177 -52
  10. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.8.0}/fastapi_errors_plus.egg-info/SOURCES.txt +5 -1
  11. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.8.0}/fastapi_errors_plus.egg-info/requires.txt +3 -0
  12. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.8.0}/pyproject.toml +19 -1
  13. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.8.0}/tests/test_app.py +26 -18
  14. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.8.0}/tests/test_base.py +106 -61
  15. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.8.0}/tests/test_errors.py +395 -170
  16. {fastapi_errors_plus-0.6.2 → 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.6.2/fastapi_errors_plus/__init__.py +0 -14
  20. fastapi_errors_plus-0.6.2/fastapi_errors_plus/protocol.py +0 -53
  21. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.8.0}/LICENSE +0 -0
  22. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.8.0}/fastapi_errors_plus/py.typed +0 -0
  23. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.8.0}/fastapi_errors_plus.egg-info/dependency_links.txt +0 -0
  24. {fastapi_errors_plus-0.6.2 → fastapi_errors_plus-0.8.0}/fastapi_errors_plus.egg-info/top_level.txt +0 -0
  25. {fastapi_errors_plus-0.6.2 → 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.6.2
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,12 +33,12 @@ 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-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)
36
+ [![Tests](https://img.shields.io/badge/tests-117-success.svg)](https://github.com/seligoroff/fastapi-errors-plus)
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.
37
40
 
38
- > [Русская версия README](README.ru.md)
41
+ > [Русская версия README](https://github.com/seligoroff/fastapi-errors-plus/blob/main/README.ru.md)
39
42
 
40
43
  ## Philosophy
41
44
 
@@ -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"},
@@ -207,6 +210,39 @@ def delete_item(id: int):
207
210
  pass
208
211
  ```
209
212
 
213
+ #### OpenAPI extras (`schema`) next to examples
214
+
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):
216
+
217
+ ```python
218
+ from fastapi import APIRouter, status
219
+ from fastapi_errors_plus import BaseErrorDTO, Errors
220
+
221
+ ADR_ERROR_BODY_SCHEMA = {
222
+ "type": "object",
223
+ "required": ["code", "detail"],
224
+ "properties": {
225
+ "code": {"type": "string"},
226
+ "detail": {"type": "string"},
227
+ "context": {"type": "object"},
228
+ },
229
+ }
230
+
231
+ business_conflict = BaseErrorDTO(
232
+ status_code=status.HTTP_409_CONFLICT,
233
+ message="BusinessRuleViolation",
234
+ openapi_json_extras={"schema": ADR_ERROR_BODY_SCHEMA},
235
+ )
236
+
237
+ router = APIRouter()
238
+
239
+ @router.post("/items", responses=Errors(business_conflict, validation_error=False))
240
+ def create_item():
241
+ ...
242
+ ```
243
+
244
+ 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).
245
+
210
246
  #### StandardErrorDTO
211
247
 
212
248
  Extended implementation for errors with multiple examples (useful for standard HTTP errors):
@@ -251,6 +287,37 @@ def delete_item(id: int):
251
287
  - Reusable across all endpoints
252
288
  - Supports inheritance for custom logic
253
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
+
254
321
  ### 5. Mixed Usage
255
322
 
256
323
  Combine flags, dict, and ErrorDTO:
@@ -297,9 +364,40 @@ def update_item(id: int):
297
364
 
298
365
  The OpenAPI spec will contain both examples under the 404 status code.
299
366
 
367
+ 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).
368
+
369
+ 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`**).
370
+
371
+ 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:
372
+
373
+ ```python
374
+ Errors(
375
+ conflict_error_doc, # implements ErrorDTO, e.g. .for_openapi() for ADR-shaped examples
376
+ {
377
+ status.HTTP_409_CONFLICT: {
378
+ "description": "Business rule violation",
379
+ "content": {
380
+ "application/json": {
381
+ "schema": {
382
+ "type": "object",
383
+ "properties": {
384
+ "code": {"type": "string"},
385
+ "detail": {"type": "string"},
386
+ "context": {"type": "object"},
387
+ },
388
+ },
389
+ },
390
+ },
391
+ },
392
+ },
393
+ )
394
+ ```
395
+
396
+ 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.
397
+
300
398
  ## ErrorDTO Protocol
301
399
 
302
- 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:
303
401
 
304
402
  ```python
305
403
  from typing import Protocol, Dict, Any
@@ -308,27 +406,30 @@ class ErrorDTO(Protocol):
308
406
  status_code: int
309
407
  message: str
310
408
 
311
- def to_example(self) -> Dict[str, Any]:
312
- """Generate example for OpenAPI.
313
-
314
- Returns:
315
- Dict in format: {"key": {"value": {"detail": "message"}}}
316
- """
409
+ def to_examples(self) -> Dict[str, Any]:
410
+ """OpenAPI ``examples`` map: {"Key": {"value": {...}, "summary": "..."}}."""
317
411
  ...
318
412
  ```
319
413
 
320
414
  Any class implementing this protocol (through structural typing) can be used with `Errors()`.
321
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
+
322
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.
323
419
 
324
420
  ### When to Use Protocol vs BaseErrorDTO
325
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
+
326
427
  **Use Protocol (structural typing)** when:
327
428
  - Your project already has error DTOs that implement the protocol
328
429
  - You need maximum flexibility and custom implementations
329
430
  - You want to keep your existing error infrastructure
330
431
 
331
- **Use BaseErrorDTO/StandardErrorDTO** when:
432
+ **Use BaseErrorDTO/StandardErrorDTO/ErrorDoc** when:
332
433
  - Starting a new project or adding error documentation
333
434
  - You want a ready-to-use implementation without boilerplate
334
435
  - You need multiple examples for standard HTTP errors (401, 403, etc.)
@@ -339,7 +440,7 @@ Both approaches work together — you can mix them in the same `Errors()` call!
339
440
 
340
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.
341
442
 
342
- 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.
343
444
 
344
445
  ### Simple Pydantic Model as ErrorDTO
345
446
 
@@ -353,8 +454,8 @@ class PydanticErrorDTO(BaseModel):
353
454
  status_code: int = Field(..., ge=400, le=599, description="HTTP status code")
354
455
  message: str = Field(..., min_length=1, description="Error message")
355
456
 
356
- def to_example(self) -> Dict[str, Any]:
357
- """Generate example for OpenAPI."""
457
+ def to_examples(self) -> Dict[str, Any]:
458
+ """Generate examples for OpenAPI."""
358
459
  return {
359
460
  self.message: {
360
461
  "value": {"detail": self.message},
@@ -397,8 +498,8 @@ class DetailedErrorDTO(BaseModel):
397
498
  error_code: Optional[str] = Field(None, description="Internal error code")
398
499
  timestamp: Optional[str] = Field(None, description="Error timestamp")
399
500
 
400
- def to_example(self) -> Dict[str, Any]:
401
- """Generate example for OpenAPI."""
501
+ def to_examples(self) -> Dict[str, Any]:
502
+ """Generate examples for OpenAPI."""
402
503
  example = {"detail": self.message}
403
504
  if self.error_code:
404
505
  example["error_code"] = self.error_code
@@ -455,7 +556,7 @@ class DomainException(Exception):
455
556
  status_code: int
456
557
  message: str
457
558
 
458
- def to_example(self) -> Dict[str, Any]:
559
+ def to_examples(self) -> Dict[str, Any]:
459
560
  return {self.message: {"value": {"detail": self.message}}}
460
561
 
461
562
  @classmethod
@@ -498,7 +599,7 @@ See [examples/domain_exceptions.py](examples/domain_exceptions.py) for complete
498
599
 
499
600
  ## Compatibility with Existing Projects
500
601
 
501
- 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):
502
603
 
503
604
  ```python
504
605
  # Your existing ApiErrorDTO
@@ -507,7 +608,7 @@ class ApiErrorDTO:
507
608
  status_code: int
508
609
  message: str
509
610
 
510
- def to_example(self) -> dict:
611
+ def to_examples(self) -> dict:
511
612
  return {
512
613
  self.message: {
513
614
  "value": {"detail": self.message},
@@ -568,16 +669,21 @@ Errors(
568
669
  FastAPI automatically validates all parameters (Path, Query, Body), so 422 is relevant in 95%+ of endpoints. For endpoints without parameters, explicitly set `validation_error=False` or `validation_error_422=False`.
569
670
 
570
671
  **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(...)`
672
+ - A dict-like `Mapping[int, ]` suitable for FastAPI’s `responses` / OpenAPI
673
+ - Pass the instance as-is **do not** call it like a function: `responses=Errors(...)`
573
674
 
574
675
  #### Usage
575
676
 
576
677
  ```python
577
- # Call the instance to get responses dict
578
- responses = Errors(unauthorized=True)
678
+ # Instances are Mapping keyed by HTTP status codes
679
+ error_responses = Errors(unauthorized_401=True, forbidden_403=True)
680
+ documented = error_responses[401] # deep copy — safe to read, won't mutate internal state
579
681
  ```
580
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
+
581
687
  ### `ErrorDTO`
582
688
 
583
689
  Protocol for error objects compatible with the library.
@@ -587,7 +693,32 @@ Protocol for error objects compatible with the library.
587
693
  - `message: str` — Error message description
588
694
 
589
695
  **Required methods:**
590
- - `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
+ ```
720
+
721
+ When **`examples`** is omitted, a single example is built from **`body`** or `{"detail": message}`.
591
722
 
592
723
  ### `BaseErrorDTO`
593
724
 
@@ -598,6 +729,7 @@ Base implementation of ErrorDTO Protocol for convenience.
598
729
  BaseErrorDTO(
599
730
  status_code: int,
600
731
  message: str,
732
+ openapi_json_extras: Optional[Dict[str, Any]] = None,
601
733
  )
602
734
  ```
603
735
 
@@ -615,6 +747,7 @@ Extended implementation for errors with multiple examples.
615
747
  StandardErrorDTO(
616
748
  status_code: int,
617
749
  message: str,
750
+ openapi_json_extras: Optional[Dict[str, Any]] = None,
618
751
  examples: Optional[Dict[str, str]] = None,
619
752
  )
620
753
  ```
@@ -666,7 +799,7 @@ def delete_item(id: int):
666
799
  ```python
667
800
  from fastapi import APIRouter
668
801
  from fastapi_errors_plus import Errors
669
- from api.exceptions.dto import errors # Your project's error DTOs
802
+ from api.exceptions.dto import notification_not_found_error # ErrorDTO-compatible instance
670
803
 
671
804
  router = APIRouter()
672
805
 
@@ -675,7 +808,7 @@ router = APIRouter()
675
808
  responses=Errors(
676
809
  unauthorized=True,
677
810
  forbidden=True,
678
- errors.notification_not_found_error, # Your ErrorDTO
811
+ notification_not_found_error,
679
812
  ),
680
813
  )
681
814
  async def delete_notification(notification_id: int):
@@ -714,40 +847,32 @@ This example shows how to use `fastapi-errors-plus` in a FastAPI project with Cl
714
847
 
715
848
  **Domain Layer** (`domain/errors.py`):
716
849
  ```python
717
- from typing import Protocol, Dict, Any
850
+ from typing import Dict, Any
718
851
 
719
- class DomainError(Protocol):
720
- """Domain error protocol compatible with ErrorDTO."""
852
+ class DomainException(Exception):
853
+ """Domain exception usable as runtime error and shaped like ErrorDTO for OpenAPI."""
721
854
  status_code: int
722
855
  message: str
723
-
724
- def to_example(self) -> Dict[str, Any]:
725
- """Generate example for OpenAPI."""
726
- ...
727
856
 
728
- class ItemNotFoundError:
729
- """Domain error for item not found."""
730
- status_code = 404
731
- message = "Item not found"
732
-
733
- def to_example(self) -> Dict[str, Any]:
857
+ def __init__(self) -> None:
858
+ super().__init__(self.message)
859
+
860
+ def to_examples(self) -> Dict[str, Any]:
734
861
  return {
735
- "ItemNotFound": {
736
- "value": {"detail": "Item not found"},
862
+ self.message: {
863
+ "value": {"detail": self.message},
737
864
  },
738
865
  }
739
866
 
740
- class ItemAlreadyExistsError:
741
- """Domain error for item already exists."""
867
+
868
+ class ItemNotFoundError(DomainException):
869
+ status_code = 404
870
+ message = "Item not found"
871
+
872
+
873
+ class ItemAlreadyExistsError(DomainException):
742
874
  status_code = 409
743
875
  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
876
  ```
752
877
 
753
878
  **Application Layer** (`application/use_cases.py`):