fastapi-mongo-base 1.3.7__tar.gz → 1.4.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 (64) hide show
  1. {fastapi_mongo_base-1.3.7/src/fastapi_mongo_base.egg-info → fastapi_mongo_base-1.4.0}/PKG-INFO +6 -1
  2. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/pyproject.toml +4 -1
  3. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/core/app_factory.py +2 -0
  4. fastapi_mongo_base-1.4.0/src/fastapi_mongo_base/core/config.py +284 -0
  5. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/core/db.py +18 -6
  6. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/core/error_responses.py +5 -7
  7. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/core/errors/resource_errors.py +2 -1
  8. fastapi_mongo_base-1.4.0/src/fastapi_mongo_base/core/middlewares/prometheus.py +165 -0
  9. fastapi_mongo_base-1.4.0/src/fastapi_mongo_base/core/prometheus/mongo.py +138 -0
  10. fastapi_mongo_base-1.4.0/src/fastapi_mongo_base/core/sentry.py +80 -0
  11. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/utils/basic.py +42 -2
  12. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/utils/usso_routes.py +17 -1
  13. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0/src/fastapi_mongo_base.egg-info}/PKG-INFO +6 -1
  14. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base.egg-info/SOURCES.txt +6 -0
  15. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base.egg-info/requires.txt +7 -0
  16. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/tests/conftest.py +5 -2
  17. fastapi_mongo_base-1.4.0/tests/test_db.py +67 -0
  18. fastapi_mongo_base-1.4.0/tests/test_sentry.py +89 -0
  19. fastapi_mongo_base-1.4.0/tests/test_usso_routes_workspace.py +100 -0
  20. fastapi_mongo_base-1.3.7/src/fastapi_mongo_base/core/config.py +0 -117
  21. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/LICENSE.txt +0 -0
  22. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/MANIFEST.in +0 -0
  23. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/README.md +0 -0
  24. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/boilerplate.md +0 -0
  25. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/changelog.md +0 -0
  26. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/contributing.md +0 -0
  27. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/endpoints.md +0 -0
  28. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/extra.css +0 -0
  29. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/index.md +0 -0
  30. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/installation.md +0 -0
  31. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/license.md +0 -0
  32. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/quickstart.md +0 -0
  33. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/settings.md +0 -0
  34. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/tutorial.md +0 -0
  35. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/usage.md +0 -0
  36. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/mkdocs.yml +0 -0
  37. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/pytest.ini +0 -0
  38. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/setup.cfg +0 -0
  39. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/__init__.py +0 -0
  40. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/core/__init__.py +0 -0
  41. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/core/enums.py +0 -0
  42. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/core/errors/mongodb_errors.py +0 -0
  43. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/core/exceptions.py +0 -0
  44. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/models.py +0 -0
  45. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/py.typed +0 -0
  46. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/routes.py +0 -0
  47. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/schemas.py +0 -0
  48. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/sql.py +0 -0
  49. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/tasks.py +0 -0
  50. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/utils/__init__.py +0 -0
  51. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/utils/bsontools.py +0 -0
  52. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/utils/timezone.py +0 -0
  53. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base.egg-info/dependency_links.txt +0 -0
  54. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base.egg-info/top_level.txt +0 -0
  55. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/tests/__init__.py +0 -0
  56. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/tests/app/__init__.py +0 -0
  57. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/tests/app/logs/app.log +0 -0
  58. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/tests/app/main.py +0 -0
  59. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/tests/app/server.py +0 -0
  60. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/tests/test_apis.py +0 -0
  61. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/tests/test_app.py +0 -0
  62. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/tests/test_mongodb_errors.py +0 -0
  63. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/tests/test_openapi_errors.py +0 -0
  64. {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/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.7
3
+ Version: 1.4.0
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 @@ Requires-Dist: fastapi>=0.129.0
33
33
  Requires-Dist: uvicorn[standard]>=0.41.0
34
34
  Requires-Dist: uuid6>=2025.0.1
35
35
  Requires-Dist: typing_extensions>=4.5.0; python_version < "3.11"
36
+ Requires-Dist: pydantic-settings>=2.14.2
36
37
  Provides-Extra: usso
37
38
  Requires-Dist: usso>=0.30.1; extra == "usso"
38
39
  Provides-Extra: sql
@@ -43,6 +44,10 @@ Requires-Dist: pytest-cov; extra == "test"
43
44
  Requires-Dist: pytest-asyncio; extra == "test"
44
45
  Requires-Dist: mongomock_motor; extra == "test"
45
46
  Requires-Dist: coverage; extra == "test"
47
+ Provides-Extra: prometheus
48
+ Requires-Dist: prometheus-client>=0.25.0; extra == "prometheus"
49
+ Provides-Extra: sentry
50
+ Requires-Dist: sentry-sdk[fastapi]>=2.0.0; extra == "sentry"
46
51
  Dynamic: license-file
47
52
 
48
53
  # FastAPI MongoDB Base
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fastapi-mongo-base"
7
- version = "1.3.7"
7
+ version = "1.4.0"
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"
@@ -35,12 +35,15 @@ dependencies = [
35
35
  "uvicorn[standard]>=0.41.0",
36
36
  "uuid6>=2025.0.1",
37
37
  "typing_extensions>=4.5.0; python_version<'3.11'",
38
+ "pydantic-settings>=2.14.2",
38
39
  ]
39
40
 
40
41
  [project.optional-dependencies]
41
42
  usso = ["usso>=0.30.1"]
42
43
  sql = ["sqlalchemy>=2.0.43"]
43
44
  test = ["pytest", "pytest-cov", "pytest-asyncio", "mongomock_motor", "coverage"]
45
+ prometheus = ["prometheus-client>=0.25.0"]
46
+ sentry = ["sentry-sdk[fastapi]>=2.0.0"]
44
47
 
45
48
  [project.urls]
46
49
  "Homepage" = "https://github.com/mahdikiani/fastapi-mongo-base-app"
@@ -13,6 +13,7 @@ from fastapi.staticfiles import StaticFiles
13
13
 
14
14
  from . import config, db, exceptions
15
15
  from .error_responses import COMMON_ERROR_RESPONSES, setup_openapi_errors
16
+ from .sentry import setup_sentry
16
17
 
17
18
 
18
19
  def health(request: fastapi.Request) -> dict[str, str]:
@@ -160,6 +161,7 @@ def get_app_kwargs(
160
161
  init_functions = []
161
162
 
162
163
  settings.config_logger()
164
+ setup_sentry(settings)
163
165
 
164
166
  if settings is None:
165
167
  settings = config.Settings()
@@ -0,0 +1,284 @@
1
+ """FastAPI server configuration."""
2
+
3
+ import dataclasses
4
+ import json
5
+ import logging
6
+ import logging.config
7
+ from pathlib import Path
8
+ from typing import Literal
9
+
10
+ from pydantic import Field, field_validator
11
+ from pydantic_settings import BaseSettings, SettingsConfigDict
12
+ from singleton import Singleton
13
+
14
+
15
+ class JsonFormatter(logging.Formatter):
16
+ """
17
+ Logging formatter that outputs a single-line structured JSON object.
18
+
19
+ Using json_advanced.dumps ensures special types (datetime, UUID,
20
+ ObjectId, Pydantic models, etc.) are serialised without raising errors.
21
+ """
22
+
23
+ def format(self, record: logging.LogRecord) -> str:
24
+ """Serialise *record* to a JSON string."""
25
+ data: dict[str, object] = {
26
+ "timestamp": self.formatTime(record, self.datefmt),
27
+ "level": record.levelname,
28
+ "logger": record.name,
29
+ "filename": record.filename,
30
+ "lineno": record.lineno,
31
+ "funcName": record.funcName,
32
+ "message": record.getMessage(),
33
+ }
34
+ if record.exc_info:
35
+ data["exc_info"] = self.formatException(record.exc_info)
36
+ if record.stack_info:
37
+ data["stack_info"] = self.formatStack(record.stack_info)
38
+ return json.dumps(data, ensure_ascii=False)
39
+
40
+
41
+ class ProjectSettings(BaseSettings):
42
+ """Environment-backed settings loaded by pydantic-settings."""
43
+
44
+ model_config = SettingsConfigDict(
45
+ env_file=".env",
46
+ env_file_encoding="utf-8",
47
+ extra="ignore",
48
+ case_sensitive=False,
49
+ )
50
+
51
+ root_url: str = Field(
52
+ default="http://localhost:8000",
53
+ validation_alias="DOMAIN",
54
+ )
55
+ project_name: str = Field(
56
+ default="PROJECT", validation_alias="PROJECT_NAME"
57
+ )
58
+ base_path: str = "/api/v1"
59
+ worker_update_time: int = 180
60
+ debug: bool = False
61
+
62
+ @field_validator("worker_update_time")
63
+ @classmethod
64
+ def normalize_worker_update_time(cls, value: int) -> int:
65
+ """
66
+ Preserve the previous fallback for falsy worker update values.
67
+
68
+ Args:
69
+ value: Configured worker update interval.
70
+
71
+ Returns:
72
+ Worker update interval with the legacy fallback applied.
73
+
74
+ """
75
+ return value or 180
76
+
77
+ cors_origins: str | None = Field(
78
+ default=None,
79
+ validation_alias="CORS_ORIGINS",
80
+ )
81
+ page_max_limit: int = 100
82
+ mongo_uri: str = Field(
83
+ default="mongodb://localhost:27017/", validation_alias="MONGO_URI"
84
+ )
85
+ mongo_server_selection_timeout_ms: int = 5000
86
+ mongo_connect_timeout_ms: int = 5000
87
+ sentry_dsn: str | None = Field(default=None, validation_alias="SENTRY_DSN")
88
+ sentry_environment: str | None = Field(
89
+ default=None,
90
+ validation_alias="SENTRY_ENVIRONMENT",
91
+ )
92
+ sentry_release: str | None = Field(
93
+ default=None,
94
+ validation_alias="SENTRY_RELEASE",
95
+ )
96
+ sentry_traces_sample_rate: float | None = Field(
97
+ default=None,
98
+ validation_alias="SENTRY_TRACES_SAMPLE_RATE",
99
+ )
100
+ sentry_profiles_sample_rate: float | None = Field(
101
+ default=None,
102
+ validation_alias="SENTRY_PROFILES_SAMPLE_RATE",
103
+ )
104
+ sentry_send_default_pii: bool = Field(
105
+ default=False,
106
+ validation_alias="SENTRY_SEND_DEFAULT_PII",
107
+ )
108
+ log_format: Literal["json", "text"] = Field(
109
+ default="json",
110
+ validation_alias="LOG_FORMAT",
111
+ )
112
+
113
+ @classmethod
114
+ def get_coverage_dir(cls) -> str:
115
+ """
116
+ Get the directory path for coverage reports.
117
+
118
+ Returns:
119
+ Path string to coverage directory.
120
+
121
+ """
122
+ return getattr(cls, "base_dir", Path(".")) / "htmlcov"
123
+
124
+ @classmethod
125
+ def get_log_config(
126
+ cls,
127
+ console_level: str = "INFO",
128
+ log_format: str | None = None,
129
+ **kwargs: object,
130
+ ) -> dict[str, object]:
131
+ """
132
+ Get logging configuration dictionary.
133
+
134
+ Args:
135
+ console_level: Logging level for console handler.
136
+ log_format: ``"json"`` for structured JSON output (default, suited
137
+ for Kubernetes / log-aggregation pipelines) or ``"text"`` for
138
+ the human-readable bracketed format. When *None* the value is
139
+ read from the ``log_format`` attribute on the class (which is
140
+ itself populated from the ``LOG_FORMAT`` environment variable).
141
+ **kwargs: Additional keyword arguments (reserved for subclasses).
142
+
143
+ Returns:
144
+ Dictionary with logging configuration.
145
+ """
146
+ resolved_format = log_format or getattr(cls, "log_format", "json")
147
+
148
+ if resolved_format == "json":
149
+ formatter_config: dict[str, object] = {"()": JsonFormatter}
150
+ else:
151
+ formatter_config = {
152
+ "format": "[{levelname} : {filename}:{lineno} : {asctime} -> {funcName:10}] {message}", # noqa: E501
153
+ "style": "{",
154
+ }
155
+
156
+ return {
157
+ "version": 1,
158
+ "formatters": {"standard": formatter_config},
159
+ "handlers": {
160
+ "console": {
161
+ "class": "logging.StreamHandler",
162
+ "level": console_level,
163
+ "formatter": "standard",
164
+ }
165
+ },
166
+ "loggers": {
167
+ "": {
168
+ "handlers": ["console"],
169
+ "level": "INFO",
170
+ "propagate": True,
171
+ },
172
+ },
173
+ }
174
+
175
+ @classmethod
176
+ def config_logger(cls) -> None:
177
+ """Configure Python logging with settings from get_log_config."""
178
+ log_format = project_settings.log_format
179
+ log_config = cls.get_log_config(log_format=log_format)
180
+ if log_config["handlers"].get("file"):
181
+ (cls.get_coverage_dir() / "logs").mkdir(
182
+ parents=True, exist_ok=True
183
+ )
184
+ logging.config.dictConfig(log_config)
185
+
186
+
187
+ project_settings = ProjectSettings()
188
+
189
+
190
+ @dataclasses.dataclass
191
+ class Settings(metaclass=Singleton):
192
+ """Server config settings."""
193
+
194
+ # base_dir: Path = Path(__file__).resolve().parent.parent # noqa: ERA001
195
+ root_url: str = project_settings.root_url
196
+ project_name: str = project_settings.project_name
197
+ base_path: str = project_settings.base_path
198
+ worker_update_time: int = project_settings.worker_update_time
199
+ debug: bool = project_settings.debug
200
+
201
+ _cors_origins_str: str | None = project_settings.cors_origins
202
+
203
+ @property
204
+ def cors_origins(self) -> list[str]:
205
+ """
206
+ CORS allowed origins as a list.
207
+
208
+ Returns:
209
+ List of allowed origin URLs.
210
+
211
+ """
212
+ if self._cors_origins_str and "[" in self._cors_origins_str:
213
+ return json.loads(self._cors_origins_str)
214
+ if self._cors_origins_str:
215
+ return [s.strip() for s in self._cors_origins_str.split(",")]
216
+ return ["http://localhost:8000"]
217
+
218
+ page_max_limit: int = project_settings.page_max_limit
219
+ mongo_uri: str = project_settings.mongo_uri
220
+ mongo_server_selection_timeout_ms: int = (
221
+ project_settings.mongo_server_selection_timeout_ms
222
+ )
223
+ mongo_connect_timeout_ms: int = project_settings.mongo_connect_timeout_ms
224
+ sentry_dsn: str | None = project_settings.sentry_dsn
225
+ sentry_environment: str | None = project_settings.sentry_environment
226
+ sentry_release: str | None = project_settings.sentry_release
227
+ sentry_traces_sample_rate: float | None = (
228
+ project_settings.sentry_traces_sample_rate
229
+ )
230
+ sentry_profiles_sample_rate: float | None = (
231
+ project_settings.sentry_profiles_sample_rate
232
+ )
233
+ sentry_send_default_pii: bool = project_settings.sentry_send_default_pii
234
+ log_format: str = project_settings.log_format
235
+
236
+ @classmethod
237
+ def get_coverage_dir(cls) -> str:
238
+ """
239
+ Get the directory path for coverage reports.
240
+
241
+ Returns:
242
+ Path string to coverage directory.
243
+
244
+ """
245
+ return project_settings.get_coverage_dir()
246
+
247
+ @classmethod
248
+ def get_log_config(
249
+ cls,
250
+ console_level: str = "INFO",
251
+ log_format: str | None = None,
252
+ **kwargs: object,
253
+ ) -> dict[str, object]:
254
+ """
255
+ Get logging configuration dictionary.
256
+
257
+ Args:
258
+ console_level: Logging level for console handler.
259
+ log_format: ``"json"`` for structured JSON output (default, suited
260
+ for Kubernetes / log-aggregation pipelines) or ``"text"`` for
261
+ the human-readable bracketed format. When *None* the value is
262
+ read from the ``log_format`` attribute on the class (which is
263
+ itself populated from the ``LOG_FORMAT`` environment variable).
264
+ **kwargs: Additional keyword arguments (reserved for subclasses).
265
+
266
+ Returns:
267
+ Dictionary with logging configuration.
268
+ """
269
+ return project_settings.get_log_config(
270
+ console_level=console_level,
271
+ log_format=log_format,
272
+ **kwargs,
273
+ )
274
+
275
+ @classmethod
276
+ def config_logger(cls) -> None:
277
+ """Configure Python logging with settings from get_log_config."""
278
+ log_format = getattr(cls, "log_format", "json")
279
+ log_config = cls.get_log_config(log_format=log_format)
280
+ if log_config["handlers"].get("file"):
281
+ (getattr(cls, "base_dir", Path(".")) / "logs").mkdir(
282
+ parents=True, exist_ok=True
283
+ )
284
+ logging.config.dictConfig(log_config)
@@ -4,10 +4,10 @@ import logging
4
4
 
5
5
  from beanie import init_beanie
6
6
 
7
- from fastapi_mongo_base.models import BaseEntity
8
- from fastapi_mongo_base.utils import basic
9
-
7
+ from ..models import BaseEntity
8
+ from ..utils import basic
10
9
  from .config import Settings
10
+ from .errors.mongodb_errors import MongoDBConnectionError
11
11
 
12
12
 
13
13
  async def init_mongo_db(settings: Settings | None = None) -> object:
@@ -42,6 +42,18 @@ async def init_mongo_db(settings: Settings | None = None) -> object:
42
42
  if settings is None:
43
43
  settings = Settings()
44
44
 
45
+ try:
46
+ from pymongo import monitoring
47
+
48
+ from ..core.prometheus.mongo import DatabasePoolMonitor
49
+
50
+ pool_monitor = DatabasePoolMonitor(
51
+ database_name=settings.project_name,
52
+ )
53
+ monitoring.register(pool_monitor)
54
+ except ImportError:
55
+ pass
56
+
45
57
  client = AsyncMongoClient(
46
58
  settings.mongo_uri,
47
59
  serverSelectionTimeoutMS=settings.mongo_server_selection_timeout_ms,
@@ -65,15 +77,15 @@ async def init_mongo_db(settings: Settings | None = None) -> object:
65
77
  logging.exception(
66
78
  "MongoDB connection timeout at %s", settings.mongo_uri
67
79
  )
68
- raise SystemExit(1) from e
80
+ raise MongoDBConnectionError("Failed to connect to MongoDB") from e
69
81
 
70
82
  except PyMongoError as e:
71
83
  logging.exception("MongoDB error at %s", settings.mongo_uri)
72
- raise SystemExit(1) from e
84
+ raise MongoDBConnectionError("Failed to connect to MongoDB") from e
73
85
 
74
86
  except Exception as e:
75
87
  logging.exception("Unexpected failure initializing MongoDB")
76
- raise SystemExit(1) from e
88
+ raise MongoDBConnectionError("Failed to connect to MongoDB") from e
77
89
 
78
90
  return db
79
91
 
@@ -1,7 +1,5 @@
1
1
  """OpenAPI error response schemas aligned with exception handlers."""
2
2
 
3
- from typing import Any
4
-
5
3
  import fastapi
6
4
  from fastapi.openapi.utils import get_openapi
7
5
  from pydantic import BaseModel, ConfigDict, Field
@@ -25,8 +23,8 @@ class ValidationReason(BaseModel):
25
23
  field: str
26
24
  type: str | None = None
27
25
  msg: str | None = None
28
- input: Any = None
29
- ctx: dict[str, Any] | None = None
26
+ input: object = None
27
+ ctx: dict[str, object] | None = None
30
28
 
31
29
 
32
30
  class ValidationErrorResponseModel(BaseModel):
@@ -45,7 +43,7 @@ class InternalErrorResponseModel(BaseModel):
45
43
  error: str = "Exception"
46
44
 
47
45
 
48
- COMMON_ERROR_RESPONSES: dict[int, dict[str, Any]] = {
46
+ COMMON_ERROR_RESPONSES: dict[int, dict[str, object]] = {
49
47
  400: {
50
48
  "model": APIErrorResponseModel,
51
49
  "description": "Bad request",
@@ -89,7 +87,7 @@ COMMON_ERROR_RESPONSES: dict[int, dict[str, Any]] = {
89
87
  }
90
88
 
91
89
 
92
- def _patch_validation_responses(schema: dict[str, Any]) -> None:
90
+ def _patch_validation_responses(schema: dict[str, object]) -> None:
93
91
  validation_ref = {
94
92
  "$ref": "#/components/schemas/ValidationErrorResponseModel"
95
93
  }
@@ -119,7 +117,7 @@ def setup_openapi_errors(app: fastapi.FastAPI) -> None:
119
117
 
120
118
  """
121
119
 
122
- def custom_openapi() -> dict[str, Any]:
120
+ def custom_openapi() -> dict[str, object]:
123
121
  if app.openapi_schema:
124
122
  return app.openapi_schema
125
123
 
@@ -13,6 +13,7 @@ class ResourceError(BaseHTTPException):
13
13
 
14
14
  def __init__(
15
15
  self,
16
+ error_code: str | None = None,
16
17
  detail: str | None = None,
17
18
  message: dict | None = None,
18
19
  **kwargs: object,
@@ -20,7 +21,7 @@ class ResourceError(BaseHTTPException):
20
21
  """Initialize ResourceError with optional detail, message, and data."""
21
22
  super().__init__(
22
23
  status_code=self.status_code,
23
- error_code=self.error_code,
24
+ error_code=error_code or self.error_code,
24
25
  detail=detail or self.message_en,
25
26
  message=message,
26
27
  **kwargs,
@@ -0,0 +1,165 @@
1
+ """Prometheus metrics middleware for FastAPI applications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Awaitable, Callable, Iterable
7
+ from http import HTTPStatus
8
+ from typing import ClassVar
9
+
10
+ from prometheus_client import Counter, Gauge, Histogram
11
+ from starlette.middleware.base import BaseHTTPMiddleware
12
+ from starlette.requests import Request
13
+ from starlette.responses import Response
14
+ from starlette.types import ASGIApp
15
+
16
+ RequestEndpoint = Callable[[Request], Awaitable[Response]]
17
+
18
+
19
+ class PrometheusMiddleware(BaseHTTPMiddleware):
20
+ """
21
+ Collect Prometheus HTTP metrics for a FastAPI/Starlette app.
22
+
23
+ The middleware records request count, in-progress requests, and request
24
+ latency. Metrics are labelled by HTTP method, route template, and status
25
+ code so endpoint-level latency can be queried without exploding label
26
+ cardinality from path parameters.
27
+
28
+ Example:
29
+ app.add_middleware(PrometheusMiddleware)
30
+
31
+ """
32
+
33
+ DEFAULT_EXCLUDED_PATHS: ClassVar[set[str]] = {
34
+ "/metrics",
35
+ "/docs",
36
+ "/redoc",
37
+ "/openapi.json",
38
+ "/favicon.ico",
39
+ }
40
+
41
+ DEFAULT_BUCKETS: ClassVar[tuple[float, ...]] = (
42
+ 0.005,
43
+ 0.01,
44
+ 0.025,
45
+ 0.05,
46
+ 0.075,
47
+ 0.1,
48
+ 0.25,
49
+ 0.5,
50
+ 0.75,
51
+ 1.0,
52
+ 2.5,
53
+ 5.0,
54
+ 7.5,
55
+ 10.0,
56
+ )
57
+
58
+ REQUESTS_TOTAL: ClassVar[Counter] = Counter(
59
+ "http_requests_total",
60
+ "Total number of HTTP requests.",
61
+ ("method", "endpoint", "status_code"),
62
+ )
63
+ REQUESTS_IN_PROGRESS: ClassVar[Gauge] = Gauge(
64
+ "http_requests_in_progress",
65
+ "Number of HTTP requests currently being processed.",
66
+ ("method", "endpoint"),
67
+ )
68
+ REQUEST_LATENCY_SECONDS: ClassVar[Histogram] = Histogram(
69
+ "http_request_duration_seconds",
70
+ "HTTP request latency in seconds.",
71
+ ("method", "endpoint", "status_code"),
72
+ buckets=DEFAULT_BUCKETS,
73
+ )
74
+
75
+ def __init__(
76
+ self,
77
+ app: ASGIApp,
78
+ *,
79
+ excluded_paths: Iterable[str] | None = None,
80
+ ) -> None:
81
+ """
82
+ Initialize the Prometheus metrics middleware.
83
+
84
+ Args:
85
+ app: ASGI application instance.
86
+ excluded_paths: Exact URL paths that should not be measured.
87
+
88
+ """
89
+ super().__init__(app)
90
+ self.excluded_paths = set(
91
+ excluded_paths or self.DEFAULT_EXCLUDED_PATHS
92
+ )
93
+
94
+ async def dispatch(
95
+ self,
96
+ request: Request,
97
+ call_next: RequestEndpoint,
98
+ ) -> Response:
99
+ """
100
+ Measure request metrics and forward the request downstream.
101
+
102
+ Args:
103
+ request: Incoming HTTP request.
104
+ call_next: Next middleware or route handler.
105
+
106
+ Returns:
107
+ HTTP response returned by downstream application.
108
+
109
+ Raises:
110
+ Exception: Re-raises downstream exceptions after recording metrics.
111
+
112
+ """
113
+ if request.url.path in self.excluded_paths:
114
+ return await call_next(request)
115
+
116
+ method = request.method
117
+ endpoint = self._get_endpoint(request)
118
+ status_code = str(HTTPStatus.INTERNAL_SERVER_ERROR.value)
119
+ start_time = time.perf_counter()
120
+
121
+ in_progress = self.REQUESTS_IN_PROGRESS.labels(
122
+ method=method,
123
+ endpoint=endpoint,
124
+ )
125
+ in_progress.inc()
126
+
127
+ try:
128
+ response = await call_next(request)
129
+ status_code = str(response.status_code)
130
+ except Exception:
131
+ status_code = str(HTTPStatus.INTERNAL_SERVER_ERROR.value)
132
+ raise
133
+ else:
134
+ return response
135
+ finally:
136
+ elapsed = time.perf_counter() - start_time
137
+ in_progress.dec()
138
+ self.REQUESTS_TOTAL.labels(
139
+ method=method,
140
+ endpoint=endpoint,
141
+ status_code=status_code,
142
+ ).inc()
143
+ self.REQUEST_LATENCY_SECONDS.labels(
144
+ method=method,
145
+ endpoint=endpoint,
146
+ status_code=status_code,
147
+ ).observe(elapsed)
148
+
149
+ @staticmethod
150
+ def _get_endpoint(request: Request) -> str:
151
+ """
152
+ Return the matched route path template for a request.
153
+
154
+ Args:
155
+ request: Incoming HTTP request.
156
+
157
+ Returns:
158
+ Route template when available, otherwise the raw URL path.
159
+
160
+ """
161
+ route = request.scope.get("route")
162
+ path = getattr(route, "path", None)
163
+ if isinstance(path, str):
164
+ return path
165
+ return request.url.path