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.
- {fastapi_mongo_base-1.3.7/src/fastapi_mongo_base.egg-info → fastapi_mongo_base-1.4.0}/PKG-INFO +6 -1
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/pyproject.toml +4 -1
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/core/app_factory.py +2 -0
- fastapi_mongo_base-1.4.0/src/fastapi_mongo_base/core/config.py +284 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/core/db.py +18 -6
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/core/error_responses.py +5 -7
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/core/errors/resource_errors.py +2 -1
- fastapi_mongo_base-1.4.0/src/fastapi_mongo_base/core/middlewares/prometheus.py +165 -0
- fastapi_mongo_base-1.4.0/src/fastapi_mongo_base/core/prometheus/mongo.py +138 -0
- fastapi_mongo_base-1.4.0/src/fastapi_mongo_base/core/sentry.py +80 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/utils/basic.py +42 -2
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/utils/usso_routes.py +17 -1
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0/src/fastapi_mongo_base.egg-info}/PKG-INFO +6 -1
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base.egg-info/SOURCES.txt +6 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base.egg-info/requires.txt +7 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/tests/conftest.py +5 -2
- fastapi_mongo_base-1.4.0/tests/test_db.py +67 -0
- fastapi_mongo_base-1.4.0/tests/test_sentry.py +89 -0
- fastapi_mongo_base-1.4.0/tests/test_usso_routes_workspace.py +100 -0
- fastapi_mongo_base-1.3.7/src/fastapi_mongo_base/core/config.py +0 -117
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/LICENSE.txt +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/MANIFEST.in +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/README.md +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/boilerplate.md +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/changelog.md +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/contributing.md +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/endpoints.md +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/extra.css +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/index.md +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/installation.md +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/license.md +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/quickstart.md +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/settings.md +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/tutorial.md +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/docs/usage.md +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/mkdocs.yml +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/pytest.ini +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/setup.cfg +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/__init__.py +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/core/__init__.py +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/core/enums.py +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/core/errors/mongodb_errors.py +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/core/exceptions.py +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/models.py +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/py.typed +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/routes.py +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/schemas.py +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/sql.py +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/tasks.py +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/utils/__init__.py +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/utils/bsontools.py +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/utils/timezone.py +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base.egg-info/dependency_links.txt +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base.egg-info/top_level.txt +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/tests/__init__.py +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/tests/app/__init__.py +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/tests/app/logs/app.log +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/tests/app/main.py +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/tests/app/server.py +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/tests/test_apis.py +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/tests/test_app.py +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/tests/test_mongodb_errors.py +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/tests/test_openapi_errors.py +0 -0
- {fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/tests/test_resource_errors.py +0 -0
{fastapi_mongo_base-1.3.7/src/fastapi_mongo_base.egg-info → fastapi_mongo_base-1.4.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-mongo-base
|
|
3
|
-
Version: 1.
|
|
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.
|
|
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"
|
{fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/core/app_factory.py
RENAMED
|
@@ -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
|
|
8
|
-
from
|
|
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
|
|
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
|
|
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
|
|
88
|
+
raise MongoDBConnectionError("Failed to connect to MongoDB") from e
|
|
77
89
|
|
|
78
90
|
return db
|
|
79
91
|
|
{fastapi_mongo_base-1.3.7 → fastapi_mongo_base-1.4.0}/src/fastapi_mongo_base/core/error_responses.py
RENAMED
|
@@ -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:
|
|
29
|
-
ctx: dict[str,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|