fastapi-errors-plus 0.6.0__tar.gz → 0.6.2__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.0 → fastapi_errors_plus-0.6.2}/LICENSE +1 -0
  2. {fastapi_errors_plus-0.6.0 → fastapi_errors_plus-0.6.2}/PKG-INFO +97 -1
  3. {fastapi_errors_plus-0.6.0 → fastapi_errors_plus-0.6.2}/README.md +96 -0
  4. {fastapi_errors_plus-0.6.0 → fastapi_errors_plus-0.6.2}/fastapi_errors_plus/__init__.py +1 -1
  5. {fastapi_errors_plus-0.6.0 → fastapi_errors_plus-0.6.2}/fastapi_errors_plus/base.py +1 -0
  6. {fastapi_errors_plus-0.6.0 → fastapi_errors_plus-0.6.2}/fastapi_errors_plus/errors.py +3 -3
  7. {fastapi_errors_plus-0.6.0 → fastapi_errors_plus-0.6.2}/fastapi_errors_plus/protocol.py +1 -0
  8. {fastapi_errors_plus-0.6.0 → fastapi_errors_plus-0.6.2}/fastapi_errors_plus.egg-info/PKG-INFO +97 -1
  9. {fastapi_errors_plus-0.6.0 → fastapi_errors_plus-0.6.2}/pyproject.toml +15 -1
  10. {fastapi_errors_plus-0.6.0 → fastapi_errors_plus-0.6.2}/tests/test_app.py +67 -0
  11. {fastapi_errors_plus-0.6.0 → fastapi_errors_plus-0.6.2}/tests/test_base.py +5 -0
  12. {fastapi_errors_plus-0.6.0 → fastapi_errors_plus-0.6.2}/tests/test_errors.py +114 -15
  13. {fastapi_errors_plus-0.6.0 → fastapi_errors_plus-0.6.2}/tests/test_integration.py +109 -0
  14. {fastapi_errors_plus-0.6.0 → fastapi_errors_plus-0.6.2}/fastapi_errors_plus/py.typed +0 -0
  15. {fastapi_errors_plus-0.6.0 → fastapi_errors_plus-0.6.2}/fastapi_errors_plus.egg-info/SOURCES.txt +0 -0
  16. {fastapi_errors_plus-0.6.0 → fastapi_errors_plus-0.6.2}/fastapi_errors_plus.egg-info/dependency_links.txt +0 -0
  17. {fastapi_errors_plus-0.6.0 → fastapi_errors_plus-0.6.2}/fastapi_errors_plus.egg-info/requires.txt +0 -0
  18. {fastapi_errors_plus-0.6.0 → fastapi_errors_plus-0.6.2}/fastapi_errors_plus.egg-info/top_level.txt +0 -0
  19. {fastapi_errors_plus-0.6.0 → fastapi_errors_plus-0.6.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-errors-plus
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: Universal library for documenting errors in FastAPI endpoints
5
5
  Author-email: Igor Selivanov <seligoroff@gmail.com>
6
6
  License: MIT
@@ -335,6 +335,102 @@ Any class implementing this protocol (through structural typing) can be used wit
335
335
 
336
336
  Both approaches work together — you can mix them in the same `Errors()` call!
337
337
 
338
+ ## Using Pydantic with ErrorDTO
339
+
340
+ **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
+
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.
343
+
344
+ ### Simple Pydantic Model as ErrorDTO
345
+
346
+ ```python
347
+ from pydantic import BaseModel, Field
348
+ from fastapi_errors_plus import Errors
349
+ from typing import Dict, Any
350
+
351
+ class PydanticErrorDTO(BaseModel):
352
+ """Pydantic model implementing ErrorDTO Protocol."""
353
+ status_code: int = Field(..., ge=400, le=599, description="HTTP status code")
354
+ message: str = Field(..., min_length=1, description="Error message")
355
+
356
+ def to_example(self) -> Dict[str, Any]:
357
+ """Generate example for OpenAPI."""
358
+ return {
359
+ self.message: {
360
+ "value": {"detail": self.message},
361
+ },
362
+ }
363
+
364
+ # Usage
365
+ notification_error = PydanticErrorDTO(
366
+ status_code=404,
367
+ message="Notification not found",
368
+ )
369
+
370
+ @router.delete(
371
+ "/{id}",
372
+ responses=Errors(notification_error),
373
+ )
374
+ def delete_item(id: int):
375
+ pass
376
+ ```
377
+
378
+ **Benefits:**
379
+ - Runtime validation through Pydantic
380
+ - Type safety
381
+ - Automatic field documentation
382
+ - Works with ErrorDTO Protocol through structural typing
383
+
384
+ ### Complex ErrorDTO with Pydantic
385
+
386
+ For errors with additional fields:
387
+
388
+ ```python
389
+ from pydantic import BaseModel, Field
390
+ from fastapi_errors_plus import Errors
391
+ from typing import Dict, Any, Optional
392
+
393
+ class DetailedErrorDTO(BaseModel):
394
+ """Pydantic model for errors with additional fields."""
395
+ status_code: int = Field(..., ge=400, le=599)
396
+ message: str = Field(..., min_length=1)
397
+ error_code: Optional[str] = Field(None, description="Internal error code")
398
+ timestamp: Optional[str] = Field(None, description="Error timestamp")
399
+
400
+ def to_example(self) -> Dict[str, Any]:
401
+ """Generate example for OpenAPI."""
402
+ example = {"detail": self.message}
403
+ if self.error_code:
404
+ example["error_code"] = self.error_code
405
+ if self.timestamp:
406
+ example["timestamp"] = self.timestamp
407
+
408
+ return {
409
+ self.message: {
410
+ "value": example,
411
+ },
412
+ }
413
+
414
+ # Usage
415
+ validation_error = DetailedErrorDTO(
416
+ status_code=422,
417
+ message="Validation failed",
418
+ error_code="VALIDATION_ERROR",
419
+ timestamp="2025-01-15T10:30:00Z",
420
+ )
421
+ ```
422
+
423
+ **When to use Pydantic with ErrorDTO:**
424
+ - Your project already uses Pydantic extensively
425
+ - You need runtime validation for error objects
426
+ - You want automatic field documentation
427
+ - You have complex error structures with multiple fields
428
+
429
+ **When not to use Pydantic:**
430
+ - Your project doesn't use Pydantic (use `BaseErrorDTO` or `StandardErrorDTO` instead)
431
+ - You need simple error objects (dataclasses are sufficient)
432
+ - You want to keep dependencies minimal
433
+
338
434
  ## Best Practice: Connecting Exceptions and ErrorDTO
339
435
 
340
436
  ### Problem
@@ -309,6 +309,102 @@ Any class implementing this protocol (through structural typing) can be used wit
309
309
 
310
310
  Both approaches work together — you can mix them in the same `Errors()` call!
311
311
 
312
+ ## Using Pydantic with ErrorDTO
313
+
314
+ **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.
315
+
316
+ Since the library uses structural typing (Protocol), any class that implements the required attributes (`status_code`, `message`, `to_example()`) will work, including Pydantic models.
317
+
318
+ ### Simple Pydantic Model as ErrorDTO
319
+
320
+ ```python
321
+ from pydantic import BaseModel, Field
322
+ from fastapi_errors_plus import Errors
323
+ from typing import Dict, Any
324
+
325
+ class PydanticErrorDTO(BaseModel):
326
+ """Pydantic model implementing ErrorDTO Protocol."""
327
+ status_code: int = Field(..., ge=400, le=599, description="HTTP status code")
328
+ message: str = Field(..., min_length=1, description="Error message")
329
+
330
+ def to_example(self) -> Dict[str, Any]:
331
+ """Generate example for OpenAPI."""
332
+ return {
333
+ self.message: {
334
+ "value": {"detail": self.message},
335
+ },
336
+ }
337
+
338
+ # Usage
339
+ notification_error = PydanticErrorDTO(
340
+ status_code=404,
341
+ message="Notification not found",
342
+ )
343
+
344
+ @router.delete(
345
+ "/{id}",
346
+ responses=Errors(notification_error),
347
+ )
348
+ def delete_item(id: int):
349
+ pass
350
+ ```
351
+
352
+ **Benefits:**
353
+ - Runtime validation through Pydantic
354
+ - Type safety
355
+ - Automatic field documentation
356
+ - Works with ErrorDTO Protocol through structural typing
357
+
358
+ ### Complex ErrorDTO with Pydantic
359
+
360
+ For errors with additional fields:
361
+
362
+ ```python
363
+ from pydantic import BaseModel, Field
364
+ from fastapi_errors_plus import Errors
365
+ from typing import Dict, Any, Optional
366
+
367
+ class DetailedErrorDTO(BaseModel):
368
+ """Pydantic model for errors with additional fields."""
369
+ status_code: int = Field(..., ge=400, le=599)
370
+ message: str = Field(..., min_length=1)
371
+ error_code: Optional[str] = Field(None, description="Internal error code")
372
+ timestamp: Optional[str] = Field(None, description="Error timestamp")
373
+
374
+ def to_example(self) -> Dict[str, Any]:
375
+ """Generate example for OpenAPI."""
376
+ example = {"detail": self.message}
377
+ if self.error_code:
378
+ example["error_code"] = self.error_code
379
+ if self.timestamp:
380
+ example["timestamp"] = self.timestamp
381
+
382
+ return {
383
+ self.message: {
384
+ "value": example,
385
+ },
386
+ }
387
+
388
+ # Usage
389
+ validation_error = DetailedErrorDTO(
390
+ status_code=422,
391
+ message="Validation failed",
392
+ error_code="VALIDATION_ERROR",
393
+ timestamp="2025-01-15T10:30:00Z",
394
+ )
395
+ ```
396
+
397
+ **When to use Pydantic with ErrorDTO:**
398
+ - Your project already uses Pydantic extensively
399
+ - You need runtime validation for error objects
400
+ - You want automatic field documentation
401
+ - You have complex error structures with multiple fields
402
+
403
+ **When not to use Pydantic:**
404
+ - Your project doesn't use Pydantic (use `BaseErrorDTO` or `StandardErrorDTO` instead)
405
+ - You need simple error objects (dataclasses are sufficient)
406
+ - You want to keep dependencies minimal
407
+
312
408
  ## Best Practice: Connecting Exceptions and ErrorDTO
313
409
 
314
410
  ### Problem
@@ -10,5 +10,5 @@ __all__ = [
10
10
  "BaseErrorDTO", # Base implementation (optional)
11
11
  "StandardErrorDTO", # Extended implementation (optional)
12
12
  ]
13
- __version__ = "0.6.0"
13
+ __version__ = "0.6.2"
14
14
 
@@ -122,3 +122,4 @@ class StandardErrorDTO(BaseErrorDTO):
122
122
 
123
123
 
124
124
 
125
+
@@ -140,7 +140,7 @@ class Errors(Mapping):
140
140
 
141
141
  if add_422:
142
142
  self._add_standard_error(
143
- status.HTTP_422_UNPROCESSABLE_ENTITY,
143
+ status.HTTP_422_UNPROCESSABLE_CONTENT,
144
144
  "Validation Error",
145
145
  {"detail": "Validation error"},
146
146
  )
@@ -239,7 +239,7 @@ class Errors(Mapping):
239
239
  STANDARD_DESCRIPTIONS = {
240
240
  status.HTTP_401_UNAUTHORIZED: "Unauthorized",
241
241
  status.HTTP_403_FORBIDDEN: "Forbidden",
242
- status.HTTP_422_UNPROCESSABLE_ENTITY: "Validation Error",
242
+ status.HTTP_422_UNPROCESSABLE_CONTENT: "Validation Error",
243
243
  status.HTTP_500_INTERNAL_SERVER_ERROR: "Internal Server Error",
244
244
  }
245
245
 
@@ -263,7 +263,7 @@ class Errors(Mapping):
263
263
  standard_keys = {
264
264
  status.HTTP_401_UNAUTHORIZED: "StandardUnauthorized",
265
265
  status.HTTP_403_FORBIDDEN: "StandardForbidden",
266
- status.HTTP_422_UNPROCESSABLE_ENTITY: "StandardValidationError",
266
+ status.HTTP_422_UNPROCESSABLE_CONTENT: "StandardValidationError",
267
267
  status.HTTP_500_INTERNAL_SERVER_ERROR: "StandardInternalServerError",
268
268
  }
269
269
  example_key = standard_keys.get(status_code, f"Standard{status_code}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-errors-plus
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: Universal library for documenting errors in FastAPI endpoints
5
5
  Author-email: Igor Selivanov <seligoroff@gmail.com>
6
6
  License: MIT
@@ -335,6 +335,102 @@ Any class implementing this protocol (through structural typing) can be used wit
335
335
 
336
336
  Both approaches work together — you can mix them in the same `Errors()` call!
337
337
 
338
+ ## Using Pydantic with ErrorDTO
339
+
340
+ **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
+
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.
343
+
344
+ ### Simple Pydantic Model as ErrorDTO
345
+
346
+ ```python
347
+ from pydantic import BaseModel, Field
348
+ from fastapi_errors_plus import Errors
349
+ from typing import Dict, Any
350
+
351
+ class PydanticErrorDTO(BaseModel):
352
+ """Pydantic model implementing ErrorDTO Protocol."""
353
+ status_code: int = Field(..., ge=400, le=599, description="HTTP status code")
354
+ message: str = Field(..., min_length=1, description="Error message")
355
+
356
+ def to_example(self) -> Dict[str, Any]:
357
+ """Generate example for OpenAPI."""
358
+ return {
359
+ self.message: {
360
+ "value": {"detail": self.message},
361
+ },
362
+ }
363
+
364
+ # Usage
365
+ notification_error = PydanticErrorDTO(
366
+ status_code=404,
367
+ message="Notification not found",
368
+ )
369
+
370
+ @router.delete(
371
+ "/{id}",
372
+ responses=Errors(notification_error),
373
+ )
374
+ def delete_item(id: int):
375
+ pass
376
+ ```
377
+
378
+ **Benefits:**
379
+ - Runtime validation through Pydantic
380
+ - Type safety
381
+ - Automatic field documentation
382
+ - Works with ErrorDTO Protocol through structural typing
383
+
384
+ ### Complex ErrorDTO with Pydantic
385
+
386
+ For errors with additional fields:
387
+
388
+ ```python
389
+ from pydantic import BaseModel, Field
390
+ from fastapi_errors_plus import Errors
391
+ from typing import Dict, Any, Optional
392
+
393
+ class DetailedErrorDTO(BaseModel):
394
+ """Pydantic model for errors with additional fields."""
395
+ status_code: int = Field(..., ge=400, le=599)
396
+ message: str = Field(..., min_length=1)
397
+ error_code: Optional[str] = Field(None, description="Internal error code")
398
+ timestamp: Optional[str] = Field(None, description="Error timestamp")
399
+
400
+ def to_example(self) -> Dict[str, Any]:
401
+ """Generate example for OpenAPI."""
402
+ example = {"detail": self.message}
403
+ if self.error_code:
404
+ example["error_code"] = self.error_code
405
+ if self.timestamp:
406
+ example["timestamp"] = self.timestamp
407
+
408
+ return {
409
+ self.message: {
410
+ "value": example,
411
+ },
412
+ }
413
+
414
+ # Usage
415
+ validation_error = DetailedErrorDTO(
416
+ status_code=422,
417
+ message="Validation failed",
418
+ error_code="VALIDATION_ERROR",
419
+ timestamp="2025-01-15T10:30:00Z",
420
+ )
421
+ ```
422
+
423
+ **When to use Pydantic with ErrorDTO:**
424
+ - Your project already uses Pydantic extensively
425
+ - You need runtime validation for error objects
426
+ - You want automatic field documentation
427
+ - You have complex error structures with multiple fields
428
+
429
+ **When not to use Pydantic:**
430
+ - Your project doesn't use Pydantic (use `BaseErrorDTO` or `StandardErrorDTO` instead)
431
+ - You need simple error objects (dataclasses are sufficient)
432
+ - You want to keep dependencies minimal
433
+
338
434
  ## Best Practice: Connecting Exceptions and ErrorDTO
339
435
 
340
436
  ### Problem
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fastapi-errors-plus"
7
- version = "0.6.0"
7
+ version = "0.6.2"
8
8
  description = "Universal library for documenting errors in FastAPI endpoints"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -45,9 +45,23 @@ testpaths = ["tests"]
45
45
  python_files = "test_*.py"
46
46
  python_classes = "Test*"
47
47
  python_functions = "test_*"
48
+ markers = [
49
+ "unit: Unit tests for individual components",
50
+ "integration: Integration tests with FastAPI application",
51
+ ]
52
+ addopts = [
53
+ "--cov=fastapi_errors_plus",
54
+ "--cov-report=term-missing",
55
+ "--cov-report=html:tests/htmlcov",
56
+ "--cov-report=xml:tests/coverage.xml",
57
+ "--cov-branch",
58
+ "--cov-fail-under=80",
59
+ "-v",
60
+ ]
48
61
 
49
62
  [tool.coverage.run]
50
63
  source = ["fastapi_errors_plus"]
64
+ data_file = "tests/.coverage"
51
65
  omit = [
52
66
  "*/tests/*",
53
67
  "*/__pycache__/*",
@@ -1,5 +1,7 @@
1
1
  """Test FastAPI application for integration testing."""
2
2
 
3
+ from typing import Dict, Any
4
+
3
5
  from fastapi import APIRouter, FastAPI, status
4
6
  from fastapi_errors_plus import BaseErrorDTO, Errors, StandardErrorDTO
5
7
  from tests.conftest import SimpleErrorDTO
@@ -11,6 +13,57 @@ app = FastAPI(title="Test API for fastapi-errors-plus")
11
13
  router = APIRouter()
12
14
 
13
15
 
16
+ # Domain Exception pattern for testing (Best Practice example)
17
+ class DomainException(Exception):
18
+ """Base exception implementing ErrorDTO protocol."""
19
+ status_code: int
20
+ message: str
21
+
22
+ def to_example(self) -> Dict[str, Any]:
23
+ """Generate example for OpenAPI."""
24
+ return {
25
+ self.message: {
26
+ "value": {"detail": self.message},
27
+ },
28
+ }
29
+
30
+ @classmethod
31
+ def for_openapi(cls):
32
+ """Returns instance for OpenAPI documentation."""
33
+ return cls()
34
+
35
+
36
+ class MockItemNotFoundError(DomainException):
37
+ """Test item not found error."""
38
+ status_code = status.HTTP_404_NOT_FOUND
39
+ message = "Test item not found"
40
+
41
+ def __init__(self, item_id: str = ""):
42
+ self.item_id = item_id
43
+ super().__init__(self.message)
44
+
45
+ @classmethod
46
+ def for_openapi(cls):
47
+ """Returns instance for OpenAPI documentation."""
48
+ return cls(item_id="test_id")
49
+
50
+
51
+ class MockItemAccessDeniedError(DomainException):
52
+ """Test item access denied error."""
53
+ status_code = status.HTTP_403_FORBIDDEN
54
+ message = "Test item access denied"
55
+
56
+ def __init__(self, item_id: str = "", user_id: str = ""):
57
+ self.item_id = item_id
58
+ self.user_id = user_id
59
+ super().__init__(self.message)
60
+
61
+ @classmethod
62
+ def for_openapi(cls):
63
+ """Returns instance for OpenAPI documentation."""
64
+ return cls(item_id="test_id", user_id="test_user")
65
+
66
+
14
67
  # Example 1: Standard flags only
15
68
  @router.get(
16
69
  "/standard-flags",
@@ -220,6 +273,20 @@ def create_item_mixed_base_dto(item_id: int):
220
273
  return {"message": f"Item {item_id} created"}
221
274
 
222
275
 
276
+ # Example 11: Domain Exception as ErrorDTO (Best Practice pattern)
277
+ @router.delete(
278
+ "/domain-exception/{item_id}",
279
+ responses=Errors(
280
+ MockItemNotFoundError.for_openapi(), # Using for_openapi() pattern
281
+ MockItemAccessDeniedError.for_openapi(), # Using for_openapi() pattern
282
+ unauthorized_401=True, # Standard flag
283
+ ),
284
+ )
285
+ def delete_item_domain_exception(item_id: str):
286
+ """Endpoint demonstrating Domain Exception as ErrorDTO pattern."""
287
+ return {"message": f"Item {item_id} deleted"}
288
+
289
+
223
290
  # Register router
224
291
  app.include_router(router, prefix="/api/v1", tags=["test"])
225
292
 
@@ -6,6 +6,7 @@ from fastapi import status
6
6
  from fastapi_errors_plus import BaseErrorDTO, Errors, StandardErrorDTO
7
7
 
8
8
 
9
+ @pytest.mark.unit
9
10
  class TestBaseErrorDTO:
10
11
  """Tests for BaseErrorDTO class."""
11
12
 
@@ -69,6 +70,7 @@ class TestBaseErrorDTO:
69
70
  assert examples["Item not found"]["value"] == {"detail": "Item not found"}
70
71
 
71
72
 
73
+ @pytest.mark.unit
72
74
  class TestStandardErrorDTO:
73
75
  """Tests for StandardErrorDTO class."""
74
76
 
@@ -197,6 +199,7 @@ class TestStandardErrorDTO:
197
199
  assert "InvalidToken" in examples # From StandardErrorDTO
198
200
 
199
201
 
202
+ @pytest.mark.unit
200
203
  class TestBaseErrorDTOInheritance:
201
204
  """Tests for inheritance from BaseErrorDTO."""
202
205
 
@@ -254,6 +257,7 @@ class TestBaseErrorDTOInheritance:
254
257
  assert "Custom" in examples
255
258
 
256
259
 
260
+ @pytest.mark.unit
257
261
  class TestBaseErrorDTOCompatibility:
258
262
  """Tests for compatibility with structural typing (Protocol)."""
259
263
 
@@ -304,3 +308,4 @@ class TestBaseErrorDTOCompatibility:
304
308
 
305
309
 
306
310
 
311
+
@@ -9,6 +9,7 @@ from fastapi_errors_plus import Errors
9
9
  from tests.conftest import SimpleErrorDTO
10
10
 
11
11
 
12
+ @pytest.mark.unit
12
13
  class TestErrorsStandardFlags:
13
14
  """Tests for standard HTTP status flags."""
14
15
 
@@ -36,8 +37,8 @@ class TestErrorsStandardFlags:
36
37
  errors = Errors(validation_error=True)
37
38
  responses = errors
38
39
 
39
- assert status.HTTP_422_UNPROCESSABLE_ENTITY in responses
40
- assert responses[status.HTTP_422_UNPROCESSABLE_ENTITY]["description"] == "Validation Error"
40
+ assert status.HTTP_422_UNPROCESSABLE_CONTENT in responses
41
+ assert responses[status.HTTP_422_UNPROCESSABLE_CONTENT]["description"] == "Validation Error"
41
42
 
42
43
  def test_internal_server_error_flag(self):
43
44
  """Test generation of 500 Internal Server Error from flag."""
@@ -68,8 +69,8 @@ class TestErrorsStandardFlags:
68
69
  errors = Errors(validation_error_422=True)
69
70
  responses = errors
70
71
 
71
- assert status.HTTP_422_UNPROCESSABLE_ENTITY in responses
72
- assert responses[status.HTTP_422_UNPROCESSABLE_ENTITY]["description"] == "Validation Error"
72
+ assert status.HTTP_422_UNPROCESSABLE_CONTENT in responses
73
+ assert responses[status.HTTP_422_UNPROCESSABLE_CONTENT]["description"] == "Validation Error"
73
74
 
74
75
  def test_internal_server_error_500_flag(self):
75
76
  """Test generation of 500 Internal Server Error from explicit flag."""
@@ -108,7 +109,7 @@ class TestErrorsStandardFlags:
108
109
 
109
110
  assert status.HTTP_401_UNAUTHORIZED in responses
110
111
  assert status.HTTP_403_FORBIDDEN in responses
111
- assert status.HTTP_422_UNPROCESSABLE_ENTITY in responses
112
+ assert status.HTTP_422_UNPROCESSABLE_CONTENT in responses
112
113
  assert status.HTTP_500_INTERNAL_SERVER_ERROR in responses
113
114
  assert len(responses) == 4
114
115
 
@@ -133,11 +134,12 @@ class TestErrorsStandardFlags:
133
134
 
134
135
  assert status.HTTP_401_UNAUTHORIZED in responses
135
136
  assert status.HTTP_403_FORBIDDEN in responses
136
- assert status.HTTP_422_UNPROCESSABLE_ENTITY in responses
137
+ assert status.HTTP_422_UNPROCESSABLE_CONTENT in responses
137
138
  assert status.HTTP_500_INTERNAL_SERVER_ERROR in responses
138
139
  assert len(responses) == 4
139
140
 
140
141
 
142
+ @pytest.mark.unit
141
143
  class TestErrorsDict:
142
144
  """Tests for dict-based errors."""
143
145
 
@@ -214,6 +216,7 @@ class TestErrorsDict:
214
216
  assert "SessionNotFound" in examples
215
217
 
216
218
 
219
+ @pytest.mark.unit
217
220
  class TestErrorsErrorDTO:
218
221
  """Tests for ErrorDTO-based errors."""
219
222
 
@@ -240,6 +243,7 @@ class TestErrorsErrorDTO:
240
243
  assert len(responses) == 2
241
244
 
242
245
 
246
+ @pytest.mark.unit
243
247
  class TestErrorsMergeExamples:
244
248
  """Tests for merging examples for the same status code."""
245
249
 
@@ -296,6 +300,7 @@ class TestErrorsMergeExamples:
296
300
  assert "examples" in content or "example" in content
297
301
 
298
302
 
303
+ @pytest.mark.unit
299
304
  class TestErrorsMixed:
300
305
  """Tests for mixed usage (flags + dict + ErrorDTO)."""
301
306
 
@@ -327,6 +332,7 @@ class TestErrorsMixed:
327
332
  assert len(responses) == 4
328
333
 
329
334
 
335
+ @pytest.mark.unit
330
336
  class TestErrorsEdgeCases:
331
337
  """Tests for edge cases."""
332
338
 
@@ -337,7 +343,7 @@ class TestErrorsEdgeCases:
337
343
 
338
344
  assert isinstance(responses, Mapping)
339
345
  # validation_error=True by default, so 422 should be present
340
- assert status.HTTP_422_UNPROCESSABLE_ENTITY in responses
346
+ assert status.HTTP_422_UNPROCESSABLE_CONTENT in responses
341
347
  assert len(responses) == 1
342
348
 
343
349
  def test_validation_error_default_true(self):
@@ -345,15 +351,15 @@ class TestErrorsEdgeCases:
345
351
  errors = Errors()
346
352
  responses = errors
347
353
 
348
- assert status.HTTP_422_UNPROCESSABLE_ENTITY in responses
349
- assert responses[status.HTTP_422_UNPROCESSABLE_ENTITY]["description"] == "Validation Error"
354
+ assert status.HTTP_422_UNPROCESSABLE_CONTENT in responses
355
+ assert responses[status.HTTP_422_UNPROCESSABLE_CONTENT]["description"] == "Validation Error"
350
356
 
351
357
  def test_validation_error_can_be_disabled(self):
352
358
  """Test that validation_error can be explicitly set to False."""
353
359
  errors = Errors(validation_error=False)
354
360
  responses = errors
355
361
 
356
- assert status.HTTP_422_UNPROCESSABLE_ENTITY not in responses
362
+ assert status.HTTP_422_UNPROCESSABLE_CONTENT not in responses
357
363
  assert len(responses) == 0
358
364
 
359
365
  def test_validation_error_422_default_true(self):
@@ -361,28 +367,28 @@ class TestErrorsEdgeCases:
361
367
  errors = Errors()
362
368
  responses = errors
363
369
 
364
- assert status.HTTP_422_UNPROCESSABLE_ENTITY in responses
370
+ assert status.HTTP_422_UNPROCESSABLE_CONTENT in responses
365
371
 
366
372
  def test_validation_error_422_can_be_disabled(self):
367
373
  """Test that validation_error_422 can be explicitly set to False."""
368
374
  errors = Errors(validation_error_422=False)
369
375
  responses = errors
370
376
 
371
- assert status.HTTP_422_UNPROCESSABLE_ENTITY not in responses
377
+ assert status.HTTP_422_UNPROCESSABLE_CONTENT not in responses
372
378
 
373
379
  def test_validation_error_explicit_true_still_works(self):
374
380
  """Test that explicit validation_error=True still works (backward compatibility)."""
375
381
  errors = Errors(validation_error=True)
376
382
  responses = errors
377
383
 
378
- assert status.HTTP_422_UNPROCESSABLE_ENTITY in responses
384
+ assert status.HTTP_422_UNPROCESSABLE_CONTENT in responses
379
385
 
380
386
  def test_validation_error_old_and_new_flags(self):
381
387
  """Test that both old and new validation_error flags work together."""
382
388
  errors = Errors(validation_error=False, validation_error_422=False)
383
389
  responses = errors
384
390
 
385
- assert status.HTTP_422_UNPROCESSABLE_ENTITY not in responses
391
+ assert status.HTTP_422_UNPROCESSABLE_CONTENT not in responses
386
392
 
387
393
  def test_validation_error_with_other_errors(self):
388
394
  """Test validation_error=True by default with other errors."""
@@ -391,7 +397,7 @@ class TestErrorsEdgeCases:
391
397
 
392
398
  assert status.HTTP_401_UNAUTHORIZED in responses
393
399
  assert status.HTTP_403_FORBIDDEN in responses
394
- assert status.HTTP_422_UNPROCESSABLE_ENTITY in responses # Default True
400
+ assert status.HTTP_422_UNPROCESSABLE_CONTENT in responses # Default True
395
401
  assert len(responses) == 3
396
402
 
397
403
  def test_no_flags_no_errors(self):
@@ -446,6 +452,7 @@ class TestErrorsEdgeCases:
446
452
  assert len(errors) == 1
447
453
 
448
454
 
455
+ @pytest.mark.unit
449
456
  class TestUniqueKeys:
450
457
  """Tests for unique key generation to prevent collisions."""
451
458
 
@@ -522,6 +529,7 @@ class TestUniqueKeys:
522
529
  assert unique_key == "CustomExample_4"
523
530
 
524
531
 
532
+ @pytest.mark.unit
525
533
  class TestErrorsValidation:
526
534
  """Tests for ErrorDTO validation."""
527
535
 
@@ -608,6 +616,7 @@ class TestErrorsValidation:
608
616
  assert "BadObject" in error_msg
609
617
 
610
618
 
619
+ @pytest.mark.unit
611
620
  class TestDTODescriptionPriority:
612
621
  """Tests for DTO description priority over standard flags."""
613
622
 
@@ -654,3 +663,93 @@ class TestDTODescriptionPriority:
654
663
  # Dict description should be preserved (dict > DTO)
655
664
  assert errors[401]["description"] == "My Custom Description"
656
665
 
666
+
667
+ @pytest.mark.unit
668
+ class TestPydanticIntegration:
669
+ """Tests for Pydantic model integration with ErrorDTO Protocol.
670
+
671
+ These tests are optional - they will be skipped if Pydantic is not installed.
672
+ This demonstrates that the library works with Pydantic models through structural typing,
673
+ but Pydantic is not a required dependency.
674
+ """
675
+
676
+ def test_pydantic_model_as_error_dto(self):
677
+ """Test that Pydantic models work with ErrorDTO Protocol (with proper skip)."""
678
+ try:
679
+ from pydantic import BaseModel, Field
680
+ from typing import Dict, Any
681
+ except ImportError:
682
+ pytest.skip("Pydantic is not installed - this is expected and OK")
683
+
684
+ class PydanticErrorDTO(BaseModel):
685
+ """Pydantic model implementing ErrorDTO Protocol."""
686
+ status_code: int = Field(..., ge=400, le=599)
687
+ message: str = Field(..., min_length=1)
688
+
689
+ def to_example(self) -> Dict[str, Any]:
690
+ """Generate example for OpenAPI."""
691
+ return {
692
+ self.message: {
693
+ "value": {"detail": self.message},
694
+ },
695
+ }
696
+
697
+ # Create instance
698
+ error = PydanticErrorDTO(
699
+ status_code=404,
700
+ message="Not found",
701
+ )
702
+
703
+ # Use with Errors class
704
+ errors = Errors(error, validation_error=False)
705
+ responses = errors
706
+
707
+ assert status.HTTP_404_NOT_FOUND in responses
708
+ assert responses[status.HTTP_404_NOT_FOUND]["description"] == "Not found"
709
+
710
+ def test_pydantic_model_with_additional_fields(self):
711
+ """Test Pydantic model with additional fields."""
712
+ try:
713
+ from pydantic import BaseModel, Field
714
+ from typing import Dict, Any, Optional
715
+ except ImportError:
716
+ pytest.skip("Pydantic is not installed - this is expected and OK")
717
+
718
+ class DetailedErrorDTO(BaseModel):
719
+ """Pydantic model for errors with additional fields."""
720
+ status_code: int = Field(..., ge=400, le=599)
721
+ message: str = Field(..., min_length=1)
722
+ error_code: Optional[str] = Field(None, description="Internal error code")
723
+
724
+ def to_example(self) -> Dict[str, Any]:
725
+ """Generate example for OpenAPI."""
726
+ example = {"detail": self.message}
727
+ if self.error_code:
728
+ example["error_code"] = self.error_code
729
+
730
+ return {
731
+ self.message: {
732
+ "value": example,
733
+ },
734
+ }
735
+
736
+ # Create instance
737
+ error = DetailedErrorDTO(
738
+ status_code=422,
739
+ message="Validation failed",
740
+ error_code="VALIDATION_ERROR",
741
+ )
742
+
743
+ # Use with Errors class
744
+ errors = Errors(error, validation_error=False)
745
+ responses = errors
746
+
747
+ assert status.HTTP_422_UNPROCESSABLE_CONTENT in responses
748
+ assert responses[status.HTTP_422_UNPROCESSABLE_CONTENT]["description"] == "Validation failed"
749
+
750
+ # Check that error_code is in the example
751
+ examples = responses[status.HTTP_422_UNPROCESSABLE_CONTENT]["content"]["application/json"]["examples"]
752
+ example_value = examples["Validation failed"]["value"]
753
+ assert example_value["detail"] == "Validation failed"
754
+ assert example_value["error_code"] == "VALIDATION_ERROR"
755
+
@@ -4,8 +4,10 @@ from fastapi import status
4
4
  from fastapi.testclient import TestClient
5
5
 
6
6
  from tests.test_app import app
7
+ import pytest
7
8
 
8
9
 
10
+ @pytest.mark.integration
9
11
  class TestOpenAPIGeneration:
10
12
  """Tests for OpenAPI specification generation."""
11
13
 
@@ -39,6 +41,7 @@ class TestOpenAPIGeneration:
39
41
  assert "/api/v1/mixed-base-dto/{item_id}" in paths
40
42
 
41
43
 
44
+ @pytest.mark.integration
42
45
  class TestStandardFlagsEndpoint:
43
46
  """Tests for standard flags endpoint."""
44
47
 
@@ -71,6 +74,7 @@ class TestStandardFlagsEndpoint:
71
74
  assert "example" in error_401["content"]["application/json"]
72
75
 
73
76
 
77
+ @pytest.mark.integration
74
78
  class TestDictErrorsEndpoint:
75
79
  """Tests for dict errors endpoint."""
76
80
 
@@ -100,6 +104,7 @@ class TestDictErrorsEndpoint:
100
104
  assert "application/json" in error_404["content"]
101
105
 
102
106
 
107
+ @pytest.mark.integration
103
108
  class TestErrorDTOEndpoint:
104
109
  """Tests for ErrorDTO endpoint."""
105
110
 
@@ -129,6 +134,7 @@ class TestErrorDTOEndpoint:
129
134
  assert "examples" in error_404["content"]["application/json"]
130
135
 
131
136
 
137
+ @pytest.mark.integration
132
138
  class TestMixedEndpoint:
133
139
  """Tests for mixed endpoint (flags + dict + ErrorDTO)."""
134
140
 
@@ -166,6 +172,7 @@ class TestMixedEndpoint:
166
172
  assert responses["409"]["description"] == "Conflict"
167
173
 
168
174
 
175
+ @pytest.mark.integration
169
176
  class TestMergeExamplesEndpoint:
170
177
  """Tests for merge examples endpoint."""
171
178
 
@@ -187,6 +194,7 @@ class TestMergeExamplesEndpoint:
187
194
  assert "Error 2" in examples
188
195
 
189
196
 
197
+ @pytest.mark.integration
190
198
  class TestEmptyErrorsEndpoint:
191
199
  """Tests for empty errors endpoint."""
192
200
 
@@ -206,6 +214,7 @@ class TestEmptyErrorsEndpoint:
206
214
  assert len(error_codes) == 0
207
215
 
208
216
 
217
+ @pytest.mark.integration
209
218
  class TestMergeFlagDictEndpoint:
210
219
  """Tests for merge flag and dict endpoint."""
211
220
 
@@ -226,6 +235,7 @@ class TestMergeFlagDictEndpoint:
226
235
  assert "SessionNotFound" in examples
227
236
 
228
237
 
238
+ @pytest.mark.integration
229
239
  class TestBaseErrorDTOEndpoint:
230
240
  """Tests for BaseErrorDTO endpoint."""
231
241
 
@@ -257,6 +267,7 @@ class TestBaseErrorDTOEndpoint:
257
267
  assert "Item not found" in examples
258
268
 
259
269
 
270
+ @pytest.mark.integration
260
271
  class TestStandardErrorDTOEndpoint:
261
272
  """Tests for StandardErrorDTO endpoint."""
262
273
 
@@ -301,6 +312,7 @@ class TestStandardErrorDTOEndpoint:
301
312
  assert "RoleHasNoAccess" in examples
302
313
 
303
314
 
315
+ @pytest.mark.integration
304
316
  class TestMixedBaseDTOEndpoint:
305
317
  """Tests for mixed BaseErrorDTO + StandardErrorDTO + flags endpoint."""
306
318
 
@@ -319,6 +331,95 @@ class TestMixedBaseDTOEndpoint:
319
331
  assert "500" in responses # From flag
320
332
 
321
333
 
334
+ @pytest.mark.integration
335
+ class TestDomainExceptionEndpoint:
336
+ """Tests for Domain Exception as ErrorDTO endpoint (Best Practice pattern)."""
337
+
338
+ def test_responses_in_openapi(self):
339
+ """Test that Domain Exception responses are in OpenAPI."""
340
+ client = TestClient(app)
341
+ response = client.get("/openapi.json")
342
+ schema = response.json()
343
+
344
+ endpoint = schema["paths"]["/api/v1/domain-exception/{item_id}"]["delete"]
345
+ responses = endpoint["responses"]
346
+
347
+ assert "401" in responses # From flag
348
+ assert "403" in responses # From MockItemAccessDeniedError.for_openapi()
349
+ assert "404" in responses # From MockItemNotFoundError.for_openapi()
350
+
351
+ def test_404_response_structure_from_domain_exception(self):
352
+ """Test 404 response structure from Domain Exception."""
353
+ client = TestClient(app)
354
+ response = client.get("/openapi.json")
355
+ schema = response.json()
356
+
357
+ endpoint = schema["paths"]["/api/v1/domain-exception/{item_id}"]["delete"]
358
+ error_404 = endpoint["responses"]["404"]
359
+
360
+ assert error_404["description"] == "Test item not found"
361
+ assert "content" in error_404
362
+ assert "application/json" in error_404["content"]
363
+ assert "examples" in error_404["content"]["application/json"]
364
+ examples = error_404["content"]["application/json"]["examples"]
365
+ assert "Test item not found" in examples
366
+
367
+ def test_403_response_structure_from_domain_exception(self):
368
+ """Test 403 response structure from Domain Exception."""
369
+ client = TestClient(app)
370
+ response = client.get("/openapi.json")
371
+ schema = response.json()
372
+
373
+ endpoint = schema["paths"]["/api/v1/domain-exception/{item_id}"]["delete"]
374
+ error_403 = endpoint["responses"]["403"]
375
+
376
+ assert error_403["description"] == "Test item access denied"
377
+ assert "content" in error_403
378
+ assert "application/json" in error_403["content"]
379
+ assert "examples" in error_403["content"]["application/json"]
380
+ examples = error_403["content"]["application/json"]["examples"]
381
+ assert "Test item access denied" in examples
382
+
383
+ def test_for_openapi_method_returns_error_dto(self):
384
+ """Test that for_openapi() method returns instance implementing ErrorDTO."""
385
+ from tests.test_app import MockItemNotFoundError
386
+
387
+ error_instance = MockItemNotFoundError.for_openapi()
388
+
389
+ # Check that it implements ErrorDTO protocol
390
+ assert hasattr(error_instance, "status_code")
391
+ assert hasattr(error_instance, "message")
392
+ assert hasattr(error_instance, "to_example")
393
+ assert callable(error_instance.to_example)
394
+
395
+ # Check values
396
+ assert error_instance.status_code == 404
397
+ assert error_instance.message == "Test item not found"
398
+ assert error_instance.item_id == "test_id"
399
+
400
+ # Check to_example() works
401
+ example = error_instance.to_example()
402
+ assert isinstance(example, dict)
403
+ assert "Test item not found" in example
404
+
405
+ def test_domain_exception_works_with_errors_class(self):
406
+ """Test that Domain Exception instances work with Errors class."""
407
+ from fastapi_errors_plus import Errors
408
+ from tests.test_app import MockItemNotFoundError, MockItemAccessDeniedError
409
+
410
+ errors = Errors(
411
+ MockItemNotFoundError.for_openapi(),
412
+ MockItemAccessDeniedError.for_openapi(),
413
+ )
414
+ responses = errors
415
+
416
+ assert 404 in responses
417
+ assert 403 in responses
418
+ assert responses[404]["description"] == "Test item not found"
419
+ assert responses[403]["description"] == "Test item access denied"
420
+
421
+
422
+ @pytest.mark.integration
322
423
  class TestRealEndpoints:
323
424
  """Tests for actual endpoint responses."""
324
425
 
@@ -361,5 +462,13 @@ class TestRealEndpoints:
361
462
 
362
463
  assert response.status_code == 200
363
464
  assert response.json() == {"message": "Item 1 deleted"}
465
+
466
+ def test_domain_exception_endpoint_works(self):
467
+ """Test that Domain Exception endpoint returns 200."""
468
+ client = TestClient(app)
469
+ response = client.delete("/api/v1/domain-exception/1")
470
+
471
+ assert response.status_code == 200
472
+ assert response.json() == {"message": "Item 1 deleted"}
364
473
 
365
474