fastapi-mongo-base 1.3.5__tar.gz → 1.3.7__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 (57) hide show
  1. {fastapi_mongo_base-1.3.5/src/fastapi_mongo_base.egg-info → fastapi_mongo_base-1.3.7}/PKG-INFO +1 -1
  2. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/pyproject.toml +1 -1
  3. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base/core/app_factory.py +7 -1
  4. fastapi_mongo_base-1.3.7/src/fastapi_mongo_base/core/error_responses.py +137 -0
  5. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base/core/exceptions.py +64 -28
  6. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base/routes.py +8 -3
  7. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base/utils/usso_routes.py +3 -3
  8. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7/src/fastapi_mongo_base.egg-info}/PKG-INFO +1 -1
  9. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base.egg-info/SOURCES.txt +2 -0
  10. fastapi_mongo_base-1.3.7/tests/test_openapi_errors.py +116 -0
  11. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/LICENSE.txt +0 -0
  12. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/MANIFEST.in +0 -0
  13. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/README.md +0 -0
  14. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/docs/boilerplate.md +0 -0
  15. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/docs/changelog.md +0 -0
  16. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/docs/contributing.md +0 -0
  17. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/docs/endpoints.md +0 -0
  18. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/docs/extra.css +0 -0
  19. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/docs/index.md +0 -0
  20. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/docs/installation.md +0 -0
  21. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/docs/license.md +0 -0
  22. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/docs/quickstart.md +0 -0
  23. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/docs/settings.md +0 -0
  24. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/docs/tutorial.md +0 -0
  25. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/docs/usage.md +0 -0
  26. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/mkdocs.yml +0 -0
  27. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/pytest.ini +0 -0
  28. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/setup.cfg +0 -0
  29. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base/__init__.py +0 -0
  30. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base/core/__init__.py +0 -0
  31. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base/core/config.py +0 -0
  32. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base/core/db.py +0 -0
  33. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base/core/enums.py +0 -0
  34. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base/core/errors/mongodb_errors.py +0 -0
  35. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base/core/errors/resource_errors.py +0 -0
  36. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base/models.py +0 -0
  37. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base/py.typed +0 -0
  38. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base/schemas.py +0 -0
  39. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base/sql.py +0 -0
  40. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base/tasks.py +0 -0
  41. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base/utils/__init__.py +0 -0
  42. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base/utils/basic.py +0 -0
  43. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base/utils/bsontools.py +0 -0
  44. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base/utils/timezone.py +0 -0
  45. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base.egg-info/dependency_links.txt +0 -0
  46. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base.egg-info/requires.txt +0 -0
  47. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/src/fastapi_mongo_base.egg-info/top_level.txt +0 -0
  48. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/tests/__init__.py +0 -0
  49. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/tests/app/__init__.py +0 -0
  50. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/tests/app/logs/app.log +0 -0
  51. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/tests/app/main.py +0 -0
  52. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/tests/app/server.py +0 -0
  53. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/tests/conftest.py +0 -0
  54. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/tests/test_apis.py +0 -0
  55. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/tests/test_app.py +0 -0
  56. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/tests/test_mongodb_errors.py +0 -0
  57. {fastapi_mongo_base-1.3.5 → fastapi_mongo_base-1.3.7}/tests/test_resource_errors.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-mongo-base
3
- Version: 1.3.5
3
+ Version: 1.3.7
4
4
  Summary: A simple boilerplate application, including models and schemas and abstract router, for FastAPI with MongoDB
5
5
  Author-email: Mahdi Kiani <mahdikiany@gmail.com>
6
6
  Maintainer-email: Mahdi Kiani <mahdikiany@gmail.com>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fastapi-mongo-base"
7
- version = "1.3.5"
7
+ version = "1.3.7"
8
8
  description = "A simple boilerplate application, including models and schemas and abstract router, for FastAPI with MongoDB"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -12,6 +12,7 @@ from fastapi.responses import RedirectResponse
12
12
  from fastapi.staticfiles import StaticFiles
13
13
 
14
14
  from . import config, db, exceptions
15
+ from .error_responses import COMMON_ERROR_RESPONSES, setup_openapi_errors
15
16
 
16
17
 
17
18
  def health(request: fastapi.Request) -> dict[str, str]:
@@ -258,7 +259,10 @@ def create_app(
258
259
  license_info=license_info,
259
260
  )
260
261
 
261
- app = fastapi.FastAPI(**data)
262
+ app = fastapi.FastAPI(
263
+ **data,
264
+ responses=kwargs.pop("responses", COMMON_ERROR_RESPONSES),
265
+ )
262
266
 
263
267
  app = configure_app(
264
268
  app=app,
@@ -356,4 +360,6 @@ def configure_app(
356
360
  name="coverage",
357
361
  )
358
362
 
363
+ setup_openapi_errors(app)
364
+
359
365
  return app
@@ -0,0 +1,137 @@
1
+ """OpenAPI error response schemas aligned with exception handlers."""
2
+
3
+ from typing import Any
4
+
5
+ import fastapi
6
+ from fastapi.openapi.utils import get_openapi
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+
10
+ class APIErrorResponseModel(BaseModel):
11
+ """Structured error response for BaseHTTPException handlers."""
12
+
13
+ model_config = ConfigDict(extra="allow")
14
+
15
+ message: dict[str, str]
16
+ error_code: str
17
+ detail: str | None = None
18
+
19
+
20
+ class ValidationReason(BaseModel):
21
+ """Single field validation failure."""
22
+
23
+ model_config = ConfigDict(extra="allow")
24
+
25
+ field: str
26
+ type: str | None = None
27
+ msg: str | None = None
28
+ input: Any = None
29
+ ctx: dict[str, Any] | None = None
30
+
31
+
32
+ class ValidationErrorResponseModel(BaseModel):
33
+ """Structured validation error response."""
34
+
35
+ message: dict[str, str]
36
+ error_code: str = "validation_error"
37
+ detail: None = None
38
+ reasons: list[ValidationReason] = Field(default_factory=list)
39
+
40
+
41
+ class InternalErrorResponseModel(BaseModel):
42
+ """Fallback response for unhandled exceptions."""
43
+
44
+ message: str
45
+ error: str = "Exception"
46
+
47
+
48
+ COMMON_ERROR_RESPONSES: dict[int, dict[str, Any]] = {
49
+ 400: {
50
+ "model": APIErrorResponseModel,
51
+ "description": "Bad request",
52
+ },
53
+ 401: {
54
+ "model": APIErrorResponseModel,
55
+ "description": "Unauthorized",
56
+ },
57
+ 402: {
58
+ "model": APIErrorResponseModel,
59
+ "description": "Payment required",
60
+ },
61
+ 403: {
62
+ "model": APIErrorResponseModel,
63
+ "description": "Forbidden",
64
+ },
65
+ 404: {
66
+ "model": APIErrorResponseModel,
67
+ "description": "Not found",
68
+ },
69
+ 409: {
70
+ "model": APIErrorResponseModel,
71
+ "description": "Conflict",
72
+ },
73
+ 410: {
74
+ "model": APIErrorResponseModel,
75
+ "description": "Gone",
76
+ },
77
+ 422: {
78
+ "model": ValidationErrorResponseModel,
79
+ "description": "Validation error",
80
+ },
81
+ 423: {
82
+ "model": APIErrorResponseModel,
83
+ "description": "Locked",
84
+ },
85
+ 500: {
86
+ "model": APIErrorResponseModel,
87
+ "description": "Internal server error",
88
+ },
89
+ }
90
+
91
+
92
+ def _patch_validation_responses(schema: dict[str, Any]) -> None:
93
+ validation_ref = {
94
+ "$ref": "#/components/schemas/ValidationErrorResponseModel"
95
+ }
96
+ for path_item in schema.get("paths", {}).values():
97
+ for operation in path_item.values():
98
+ if not isinstance(operation, dict):
99
+ continue
100
+ responses = operation.get("responses")
101
+ if not isinstance(responses, dict) or "422" not in responses:
102
+ continue
103
+ responses["422"] = {
104
+ "description": "Validation error",
105
+ "content": {
106
+ "application/json": {
107
+ "schema": validation_ref,
108
+ },
109
+ },
110
+ }
111
+
112
+
113
+ def setup_openapi_errors(app: fastapi.FastAPI) -> None:
114
+ """
115
+ Replace default FastAPI validation error schemas in OpenAPI.
116
+
117
+ Args:
118
+ app: FastAPI application instance.
119
+
120
+ """
121
+
122
+ def custom_openapi() -> dict[str, Any]:
123
+ if app.openapi_schema:
124
+ return app.openapi_schema
125
+
126
+ schema = get_openapi(
127
+ title=app.title,
128
+ version=app.version,
129
+ openapi_version=app.openapi_version,
130
+ description=app.description,
131
+ routes=app.routes,
132
+ )
133
+ _patch_validation_responses(schema)
134
+ app.openapi_schema = schema
135
+ return app.openapi_schema
136
+
137
+ app.openapi = custom_openapi
@@ -15,6 +15,13 @@ from fastapi.exceptions import (
15
15
  from fastapi.responses import JSONResponse
16
16
  from pydantic import ValidationError
17
17
 
18
+ from fastapi_mongo_base.core.error_responses import (
19
+ APIErrorResponseModel,
20
+ InternalErrorResponseModel,
21
+ ValidationErrorResponseModel,
22
+ ValidationReason,
23
+ )
24
+
18
25
  try:
19
26
  from usso.integrations.fastapi import (
20
27
  EXCEPTION_HANDLERS as usso_exception_handler, # noqa: N811
@@ -110,40 +117,75 @@ def base_http_exception_handler(
110
117
  else:
111
118
  message = exc.message
112
119
 
113
- return JSONResponse(
114
- status_code=exc.status_code,
115
- content={
116
- "message": message,
117
- "error_code": exc.error_code,
118
- "detail": exc.detail,
119
- **exc.data,
120
- },
121
- )
120
+ content = APIErrorResponseModel(
121
+ message=message,
122
+ error_code=exc.error_code,
123
+ detail=exc.detail,
124
+ **exc.data,
125
+ ).model_dump()
126
+ return JSONResponse(status_code=exc.status_code, content=content)
127
+
128
+
129
+ def _resolve_validation_message(request: Request) -> dict[str, str]:
130
+ message = {
131
+ "en": "Validation error",
132
+ "fa": "اطلاعات وارد شده صحیح نیست",
133
+ }
134
+ if request.headers.get("accept-language"):
135
+ locales = request.headers.get("accept-language").split(",")
136
+ filtered_message = {}
137
+ for locale in locales:
138
+ lang = locale.split("-")[0]
139
+ if lang in message:
140
+ filtered_message[lang] = message[lang]
141
+ return filtered_message or message
142
+ return message
143
+
144
+
145
+ def _format_validation_reasons(errors: list[dict]) -> list[dict]:
146
+ reasons_by_field: dict[str, dict] = {}
147
+ for error in json.loads(json_advanced.dumps(errors)):
148
+ loc = error.pop("loc", ())
149
+ field = str(loc[-1]) if loc else "unknown"
150
+ error.pop("url", None)
151
+ error["field"] = field
152
+ reasons_by_field.setdefault(field, error)
153
+ return list(reasons_by_field.values())
154
+
155
+
156
+ def _validation_error_response(
157
+ request: Request,
158
+ errors: list[dict],
159
+ status_code: int = 422,
160
+ ) -> JSONResponse:
161
+ content = ValidationErrorResponseModel(
162
+ message=_resolve_validation_message(request),
163
+ reasons=[
164
+ ValidationReason(**reason)
165
+ for reason in _format_validation_reasons(errors)
166
+ ],
167
+ ).model_dump()
168
+ return JSONResponse(status_code=status_code, content=content)
122
169
 
123
170
 
124
171
  def pydantic_exception_handler(
125
- request: Request, exc: ValidationError
172
+ request: Request, exc: RequestValidationError | ResponseValidationError
126
173
  ) -> JSONResponse:
127
174
  """
128
175
  Handle Pydantic validation errors and return JSON response.
129
176
 
130
177
  Args:
131
178
  request: FastAPI request object.
132
- exc: ValidationError instance.
179
+ exc: RequestValidationError or ResponseValidationError instance.
133
180
 
134
181
  Returns:
135
182
  JSONResponse with validation error details.
136
183
 
137
184
  """
138
185
  logging.debug("pydantic_exception_handler: %s\n%s", request.url, exc)
139
- return JSONResponse(
140
- status_code=500,
141
- content={
142
- "message": str(exc),
143
- "error": "Exception",
144
- "errors": json.loads(json_advanced.dumps(exc.errors())),
145
- },
146
- )
186
+
187
+ status_code = 500 if isinstance(exc, ResponseValidationError) else 422
188
+ return _validation_error_response(request, exc.errors(), status_code)
147
189
 
148
190
 
149
191
  async def request_validation_exception_handler(
@@ -185,11 +227,7 @@ async def request_validation_exception_handler(
185
227
  dict(request.headers),
186
228
  )
187
229
 
188
- from fastapi.exception_handlers import (
189
- request_validation_exception_handler as default_handler,
190
- )
191
-
192
- return await default_handler(request, exc)
230
+ return _validation_error_response(request, exc.errors())
193
231
 
194
232
 
195
233
  def mongodb_exception_handler(
@@ -253,10 +291,8 @@ def general_exception_handler(
253
291
  except ImportError:
254
292
  pass
255
293
 
256
- return JSONResponse(
257
- status_code=500,
258
- content={"message": str(exc), "error": "Exception"},
259
- )
294
+ content = InternalErrorResponseModel(message=str(exc)).model_dump()
295
+ return JSONResponse(status_code=500, content=content)
260
296
 
261
297
 
262
298
  # A dictionary for dynamic registration
@@ -11,6 +11,7 @@ from fastapi import APIRouter, BackgroundTasks, Query, Request
11
11
  from pydantic import BaseModel
12
12
 
13
13
  from .core.config import Settings
14
+ from .core.error_responses import COMMON_ERROR_RESPONSES
14
15
  from .core.exceptions import BaseHTTPException
15
16
  from .models import BaseEntity
16
17
  from .schemas import BaseEntitySchema, PaginatedResponse
@@ -90,9 +91,13 @@ class AbstractBaseRouter(metaclass=singleton.Singleton):
90
91
  if tags is None:
91
92
  tags = [self.model.__name__]
92
93
 
93
- self.router = APIRouter(
94
- prefix=prefix, tags=cast(list[str | Enum], tags), **kwargs
95
- )
94
+ router_kwargs = {
95
+ "prefix": prefix,
96
+ "tags": cast(list[str | Enum], tags),
97
+ "responses": kwargs.pop("responses", COMMON_ERROR_RESPONSES),
98
+ **kwargs,
99
+ }
100
+ self.router = APIRouter(**router_kwargs)
96
101
 
97
102
  self.config_schemas(self.schema, **kwargs)
98
103
  self.config_routes(**kwargs)
@@ -188,7 +188,7 @@ class AbstractUSSORouterBase(AbstractBaseRouter):
188
188
  if item is None:
189
189
  raise exceptions.BaseHTTPException(
190
190
  status_code=404,
191
- error="item_not_found",
191
+ error_code="item_not_found",
192
192
  message={
193
193
  "en": f"{self.model.__name__.capitalize()} not found"
194
194
  },
@@ -223,7 +223,7 @@ class AbstractUSSORouterBase(AbstractBaseRouter):
223
223
  if filters.get("__deny__"):
224
224
  raise exceptions.BaseHTTPException(
225
225
  status_code=403,
226
- error="forbidden",
226
+ error_code="forbidden",
227
227
  message={
228
228
  "en": "You are not authorized to access this resource"
229
229
  },
@@ -448,7 +448,7 @@ class AbstractOwnedUSSORouter(AbstractUSSORouterBase):
448
448
  ):
449
449
  raise exceptions.BaseHTTPException(
450
450
  status_code=400,
451
- error="workspace_required",
451
+ error_code="workspace_required",
452
452
  message={
453
453
  "en": "User must belong to a workspace for this resource"
454
454
  },
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-mongo-base
3
- Version: 1.3.5
3
+ Version: 1.3.7
4
4
  Summary: A simple boilerplate application, including models and schemas and abstract router, for FastAPI with MongoDB
5
5
  Author-email: Mahdi Kiani <mahdikiany@gmail.com>
6
6
  Maintainer-email: Mahdi Kiani <mahdikiany@gmail.com>
@@ -33,6 +33,7 @@ src/fastapi_mongo_base/core/app_factory.py
33
33
  src/fastapi_mongo_base/core/config.py
34
34
  src/fastapi_mongo_base/core/db.py
35
35
  src/fastapi_mongo_base/core/enums.py
36
+ src/fastapi_mongo_base/core/error_responses.py
36
37
  src/fastapi_mongo_base/core/exceptions.py
37
38
  src/fastapi_mongo_base/core/errors/mongodb_errors.py
38
39
  src/fastapi_mongo_base/core/errors/resource_errors.py
@@ -46,6 +47,7 @@ tests/conftest.py
46
47
  tests/test_apis.py
47
48
  tests/test_app.py
48
49
  tests/test_mongodb_errors.py
50
+ tests/test_openapi_errors.py
49
51
  tests/test_resource_errors.py
50
52
  tests/app/__init__.py
51
53
  tests/app/main.py
@@ -0,0 +1,116 @@
1
+ """Tests for OpenAPI error response schemas."""
2
+
3
+ from fastapi.testclient import TestClient
4
+
5
+ from fastapi_mongo_base.core.error_responses import (
6
+ APIErrorResponseModel,
7
+ ValidationErrorResponseModel,
8
+ )
9
+ from fastapi_mongo_base.core.exceptions import BaseHTTPException
10
+
11
+ from .app.server import app as fastapi_app
12
+
13
+
14
+ def test_openapi_documents_error_schemas() -> None:
15
+ """OpenAPI should document custom error response schemas."""
16
+ schema = fastapi_app.openapi()
17
+
18
+ assert "APIErrorResponseModel" in schema["components"]["schemas"]
19
+ assert "ValidationErrorResponseModel" in schema["components"]["schemas"]
20
+
21
+ list_get = schema["paths"]["/test"]["get"]["responses"]
22
+ assert "404" in list_get
23
+ assert (
24
+ list_get["404"]["content"]["application/json"]["schema"]["$ref"]
25
+ == "#/components/schemas/APIErrorResponseModel"
26
+ )
27
+
28
+ create_post = schema["paths"]["/test"]["post"]["responses"]
29
+ assert "422" in create_post
30
+ assert (
31
+ create_post["422"]["content"]["application/json"]["schema"]["$ref"]
32
+ == "#/components/schemas/ValidationErrorResponseModel"
33
+ )
34
+
35
+
36
+ def test_openapi_error_models_match_handler_output() -> None:
37
+ """Documented schemas should include fields returned by handlers."""
38
+ api_schema = APIErrorResponseModel.model_json_schema()
39
+ assert "message" in api_schema["properties"]
40
+ assert "error_code" in api_schema["properties"]
41
+ assert "detail" in api_schema["properties"]
42
+
43
+ validation_schema = ValidationErrorResponseModel.model_json_schema()
44
+ assert "reasons" in validation_schema["properties"]
45
+ assert validation_schema["properties"]["error_code"]["default"] == (
46
+ "validation_error"
47
+ )
48
+
49
+
50
+ def test_runtime_validation_error_matches_openapi_schema() -> None:
51
+ """Validation handler response should match documented error shape."""
52
+ client = TestClient(fastapi_app)
53
+ response = client.post("/test", json={})
54
+
55
+ assert response.status_code == 422
56
+ body = response.json()
57
+ assert body["error_code"] == "validation_error"
58
+ assert "message" in body
59
+ assert "reasons" in body
60
+ assert isinstance(body["reasons"], list)
61
+
62
+
63
+ def test_runtime_base_http_exception_matches_openapi_schema() -> None:
64
+ """BaseHTTPException handler response should match documented shape."""
65
+ from starlette.requests import Request
66
+
67
+ from fastapi_mongo_base.core.exceptions import base_http_exception_handler
68
+
69
+ request = Request({
70
+ "type": "http",
71
+ "headers": [],
72
+ "method": "GET",
73
+ "path": "/test/not-a-real-uid",
74
+ })
75
+ exc = BaseHTTPException(
76
+ status_code=404,
77
+ error_code="item_not_found",
78
+ message={"en": "Testentity not found"},
79
+ )
80
+
81
+ response = base_http_exception_handler(request, exc)
82
+ body = response.body.decode()
83
+
84
+ assert response.status_code == 404
85
+ assert "item_not_found" in body
86
+ assert "Testentity not found" in body
87
+
88
+
89
+ def test_custom_route_documents_api_error_response() -> None:
90
+ """App-level responses should document APIErrorResponseModel on routes."""
91
+ from fastapi import FastAPI
92
+
93
+ from fastapi_mongo_base.core.app_factory import setup_exception_handlers
94
+ from fastapi_mongo_base.core.error_responses import (
95
+ COMMON_ERROR_RESPONSES,
96
+ setup_openapi_errors,
97
+ )
98
+
99
+ app = FastAPI(responses=COMMON_ERROR_RESPONSES)
100
+ setup_exception_handlers(app=app)
101
+ setup_openapi_errors(app)
102
+
103
+ @app.get("/missing")
104
+ def raise_not_found() -> None:
105
+ raise BaseHTTPException(
106
+ status_code=404,
107
+ error_code="item_not_found",
108
+ message={"en": "Item not found"},
109
+ )
110
+
111
+ schema = app.openapi()
112
+ responses = schema["paths"]["/missing"]["get"]["responses"]
113
+ assert (
114
+ responses["404"]["content"]["application/json"]["schema"]["$ref"]
115
+ == "#/components/schemas/APIErrorResponseModel"
116
+ )