fastapi-modular 0.1.0__py3-none-any.whl
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_modular-0.1.0.dist-info/METADATA +377 -0
- fastapi_modular-0.1.0.dist-info/RECORD +69 -0
- fastapi_modular-0.1.0.dist-info/WHEEL +4 -0
- fastapi_modular-0.1.0.dist-info/entry_points.txt +3 -0
- fastapi_modular-0.1.0.dist-info/licenses/LICENSE +21 -0
- pymodular/__init__.py +74 -0
- pymodular/cli/__init__.py +0 -0
- pymodular/cli/clean.py +39 -0
- pymodular/cli/configure_env.py +569 -0
- pymodular/cli/cong_cu.py +111 -0
- pymodular/cli/info.py +62 -0
- pymodular/cli/install.py +83 -0
- pymodular/cli/main.py +247 -0
- pymodular/cli/new_module.py +492 -0
- pymodular/cli/new_project.py +471 -0
- pymodular/cli/serve.py +59 -0
- pymodular/core/__init__.py +0 -0
- pymodular/core/clock.py +15 -0
- pymodular/core/compat.py +39 -0
- pymodular/core/config.py +495 -0
- pymodular/core/container.py +354 -0
- pymodular/core/context.py +78 -0
- pymodular/core/controller.py +208 -0
- pymodular/core/error_handlers.py +272 -0
- pymodular/core/exceptions.py +104 -0
- pymodular/core/guards.py +117 -0
- pymodular/core/lifespan.py +150 -0
- pymodular/core/logging.py +88 -0
- pymodular/core/metrics.py +190 -0
- pymodular/core/schemas.py +105 -0
- pymodular/core/websocket/__init__.py +31 -0
- pymodular/core/websocket/adapter.py +192 -0
- pymodular/core/websocket/gateway.py +735 -0
- pymodular/core/websocket/namespace.py +148 -0
- pymodular/core/websocket/protocol.py +157 -0
- pymodular/core/websocket/server.py +175 -0
- pymodular/core/websocket/socket.py +241 -0
- pymodular/discovery.py +180 -0
- pymodular/factory.py +126 -0
- pymodular/infrastructure/__init__.py +1 -0
- pymodular/infrastructure/database/__init__.py +8 -0
- pymodular/infrastructure/database/base.py +228 -0
- pymodular/infrastructure/database/circuit.py +207 -0
- pymodular/infrastructure/database/factory.py +88 -0
- pymodular/infrastructure/database/memory.py +112 -0
- pymodular/infrastructure/database/mongo.py +186 -0
- pymodular/infrastructure/database/repository.py +188 -0
- pymodular/infrastructure/database/sql.py +520 -0
- pymodular/infrastructure/kafka/__init__.py +26 -0
- pymodular/infrastructure/kafka/broker.py +231 -0
- pymodular/infrastructure/kafka/consumers.py +371 -0
- pymodular/infrastructure/kafka/metrics.py +17 -0
- pymodular/infrastructure/mqtt/__init__.py +35 -0
- pymodular/infrastructure/mqtt/client.py +292 -0
- pymodular/infrastructure/mqtt/consumers.py +219 -0
- pymodular/infrastructure/mqtt/metrics.py +17 -0
- pymodular/infrastructure/mqtt/patterns.py +116 -0
- pymodular/infrastructure/rabbitmq/__init__.py +33 -0
- pymodular/infrastructure/rabbitmq/broker.py +616 -0
- pymodular/infrastructure/rabbitmq/consumers.py +450 -0
- pymodular/infrastructure/rabbitmq/metrics.py +34 -0
- pymodular/infrastructure/rabbitmq/patterns.py +64 -0
- pymodular/infrastructure/redis/__init__.py +31 -0
- pymodular/infrastructure/redis/client.py +362 -0
- pymodular/infrastructure/redis/metrics.py +20 -0
- pymodular/infrastructure/redis/pubsub.py +262 -0
- pymodular/middleware/__init__.py +0 -0
- pymodular/middleware/request_context.py +164 -0
- pymodular/py.typed +0 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Backend MongoDB dùng motor (driver async chính thức).
|
|
2
|
+
|
|
3
|
+
File này CHỈ được import khi settings chọn driver mongodb — xem `factory.py`.
|
|
4
|
+
|
|
5
|
+
Ánh xạ: mỗi entity một collection, trường `id` của entity lưu vào `_id` của
|
|
6
|
+
document nên không tốn thêm index.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import uuid
|
|
12
|
+
from typing import Any, TypeVar
|
|
13
|
+
|
|
14
|
+
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorCollection
|
|
15
|
+
|
|
16
|
+
from pymodular.core.logging import get_logger
|
|
17
|
+
from pymodular.infrastructure.database.base import (
|
|
18
|
+
DatabaseBackend,
|
|
19
|
+
Filters,
|
|
20
|
+
Match,
|
|
21
|
+
active_filters,
|
|
22
|
+
from_document,
|
|
23
|
+
mapping_for,
|
|
24
|
+
to_document,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
log = get_logger(__name__)
|
|
28
|
+
|
|
29
|
+
E = TypeVar("E")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _to_mongo(doc: dict[str, Any]) -> dict[str, Any]:
|
|
33
|
+
out = dict(doc)
|
|
34
|
+
out["_id"] = out.pop("id")
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _from_mongo(doc: dict[str, Any]) -> dict[str, Any]:
|
|
39
|
+
out = dict(doc)
|
|
40
|
+
out["id"] = out.pop("_id")
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MongoBackend(DatabaseBackend):
|
|
45
|
+
name = "mongodb"
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
dsn: str,
|
|
50
|
+
*,
|
|
51
|
+
database: str,
|
|
52
|
+
connect_timeout_seconds: float = 10.0,
|
|
53
|
+
query_timeout_seconds: float = 15.0,
|
|
54
|
+
) -> None:
|
|
55
|
+
self._dsn = dsn
|
|
56
|
+
self._database_name = database
|
|
57
|
+
self._timeout_ms = int(connect_timeout_seconds * 1000)
|
|
58
|
+
self._query_timeout_ms = int(query_timeout_seconds * 1000)
|
|
59
|
+
self._client: AsyncIOMotorClient | None = None
|
|
60
|
+
|
|
61
|
+
async def startup(self) -> None:
|
|
62
|
+
# motor/pymongo tự dò lại server và kết nối lại; chỉ cần siết thời gian
|
|
63
|
+
# chờ, vì mặc định 30 giây sẽ khiến request treo rất lâu khi Mongo chết.
|
|
64
|
+
self._client = AsyncIOMotorClient(
|
|
65
|
+
self._dsn,
|
|
66
|
+
uuidRepresentation="standard",
|
|
67
|
+
serverSelectionTimeoutMS=self._timeout_ms,
|
|
68
|
+
connectTimeoutMS=self._timeout_ms,
|
|
69
|
+
# Chặn câu lệnh đã gửi: server treo thì socket đọc mãi không xong.
|
|
70
|
+
socketTimeoutMS=self._query_timeout_ms,
|
|
71
|
+
)
|
|
72
|
+
log.info("db.connected", backend=self.name, database=self._database_name)
|
|
73
|
+
|
|
74
|
+
async def shutdown(self) -> None:
|
|
75
|
+
if self._client is not None:
|
|
76
|
+
self._client.close()
|
|
77
|
+
self._client = None
|
|
78
|
+
|
|
79
|
+
async def ping(self) -> bool:
|
|
80
|
+
await self._client[self._database_name].command("ping") # type: ignore[index]
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
async def create_schema(self, *entities: type) -> None:
|
|
84
|
+
"""Mongo không cần tạo collection, nhưng index thì có.
|
|
85
|
+
|
|
86
|
+
Không có index, mọi truy vấn lọc đều quét toàn bộ collection; và không
|
|
87
|
+
có unique index thì hai request đồng thời cùng tạo được bản trùng.
|
|
88
|
+
"""
|
|
89
|
+
for entity in entities:
|
|
90
|
+
mapping = mapping_for(entity)
|
|
91
|
+
collection = self._collection(entity)
|
|
92
|
+
for name, columns, is_unique in mapping.index_specs():
|
|
93
|
+
try:
|
|
94
|
+
await collection.create_index(
|
|
95
|
+
[(column, 1) for column in columns], unique=is_unique, name=name
|
|
96
|
+
)
|
|
97
|
+
except Exception as exc: # noqa: BLE001 - index hỏng không được làm chết app
|
|
98
|
+
log.error(
|
|
99
|
+
"db.index_failed",
|
|
100
|
+
collection=mapping.storage,
|
|
101
|
+
index=name,
|
|
102
|
+
columns=list(columns),
|
|
103
|
+
unique=is_unique,
|
|
104
|
+
error=str(exc).splitlines()[0],
|
|
105
|
+
hint="dọn document trùng rồi khởi động lại",
|
|
106
|
+
)
|
|
107
|
+
log.info("db.indexes_ready", collections=[mapping_for(e).storage for e in entities])
|
|
108
|
+
|
|
109
|
+
def _collection(self, entity: type) -> AsyncIOMotorCollection:
|
|
110
|
+
assert self._client is not None, "backend chưa startup()"
|
|
111
|
+
return self._client[self._database_name][mapping_for(entity).storage]
|
|
112
|
+
|
|
113
|
+
def _query(self, filters: Filters) -> dict[str, Any]:
|
|
114
|
+
query = active_filters(filters)
|
|
115
|
+
if "id" in query:
|
|
116
|
+
query["_id"] = query.pop("id")
|
|
117
|
+
return query
|
|
118
|
+
|
|
119
|
+
async def get(self, entity: type[E], id_: str) -> E | None:
|
|
120
|
+
doc = await self._collection(entity).find_one({"_id": id_})
|
|
121
|
+
return from_document(entity, _from_mongo(doc)) if doc else None
|
|
122
|
+
|
|
123
|
+
async def find(
|
|
124
|
+
self,
|
|
125
|
+
entity: type[E],
|
|
126
|
+
*,
|
|
127
|
+
filters: Filters,
|
|
128
|
+
match: Match = None,
|
|
129
|
+
order_by: str | None = None,
|
|
130
|
+
limit: int | None = None,
|
|
131
|
+
offset: int = 0,
|
|
132
|
+
) -> list[E]:
|
|
133
|
+
cursor = self._collection(entity).find(self._query(filters))
|
|
134
|
+
if order_by:
|
|
135
|
+
cursor = cursor.sort(order_by, 1)
|
|
136
|
+
|
|
137
|
+
# `match=` là predicate Python, không dịch sang query Mongo được:
|
|
138
|
+
# phải lấy về rồi lọc. Chỉ phân trang ở DB khi không có match.
|
|
139
|
+
if match is None:
|
|
140
|
+
if offset:
|
|
141
|
+
cursor = cursor.skip(offset)
|
|
142
|
+
if limit is not None:
|
|
143
|
+
cursor = cursor.limit(limit)
|
|
144
|
+
|
|
145
|
+
items = [from_document(entity, _from_mongo(d)) async for d in cursor]
|
|
146
|
+
if match is not None:
|
|
147
|
+
items = [o for o in items if match(o)][offset:]
|
|
148
|
+
if limit is not None:
|
|
149
|
+
items = items[:limit]
|
|
150
|
+
return items
|
|
151
|
+
|
|
152
|
+
async def find_one(
|
|
153
|
+
self, entity: type[E], *, filters: Filters, match: Match = None
|
|
154
|
+
) -> E | None:
|
|
155
|
+
items = await self.find(entity, filters=filters, match=match, limit=1)
|
|
156
|
+
return items[0] if items else None
|
|
157
|
+
|
|
158
|
+
async def count(
|
|
159
|
+
self, entity: type[E], *, filters: Filters, match: Match = None
|
|
160
|
+
) -> int:
|
|
161
|
+
if match is not None:
|
|
162
|
+
return len(await self.find(entity, filters=filters, match=match))
|
|
163
|
+
return await self._collection(entity).count_documents(self._query(filters))
|
|
164
|
+
|
|
165
|
+
async def save(self, entity: type[E], obj: E) -> E:
|
|
166
|
+
if not getattr(obj, "id", None):
|
|
167
|
+
obj.id = uuid.uuid4().hex # type: ignore[attr-defined]
|
|
168
|
+
doc = _to_mongo(to_document(obj))
|
|
169
|
+
await self._collection(entity).replace_one({"_id": doc["_id"]}, doc, upsert=True)
|
|
170
|
+
return obj
|
|
171
|
+
|
|
172
|
+
async def delete(self, entity: type[E], id_: str) -> bool:
|
|
173
|
+
result = await self._collection(entity).delete_one({"_id": id_})
|
|
174
|
+
return result.deleted_count > 0
|
|
175
|
+
|
|
176
|
+
async def delete_where(
|
|
177
|
+
self, entity: type[E], *, filters: Filters, match: Match = None
|
|
178
|
+
) -> int:
|
|
179
|
+
if match is not None:
|
|
180
|
+
victims = await self.find(entity, filters=filters, match=match)
|
|
181
|
+
removed = 0
|
|
182
|
+
for obj in victims:
|
|
183
|
+
removed += int(await self.delete(entity, obj.id)) # type: ignore[attr-defined]
|
|
184
|
+
return removed
|
|
185
|
+
result = await self._collection(entity).delete_many(self._query(filters))
|
|
186
|
+
return int(result.deleted_count)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""`Database` (kết nối) và `Repository[E]` (kho dữ liệu của một entity).
|
|
2
|
+
|
|
3
|
+
Service chỉ khai báo `repo: Repository[User]` là có sẵn CRUD, giống
|
|
4
|
+
`Repository<Camera>` của TypeORM trong NestJS. Repository không biết đang chạy
|
|
5
|
+
trên memory, SQL hay Mongo — nó chuyển tiếp xuống backend do `Database` chọn
|
|
6
|
+
theo cấu hình.
|
|
7
|
+
|
|
8
|
+
Quy ước lọc: tham số `**equals` có giá trị None đều bị BỎ QUA (coi như "không
|
|
9
|
+
lọc theo trường này"). Cần điều kiện phức tạp hơn bằng nhau thì dùng `match=`
|
|
10
|
+
với một predicate — lưu ý predicate chạy trong Python nên KHÔNG đẩy được
|
|
11
|
+
xuống SQL/Mongo (backend sẽ lấy dữ liệu về rồi mới lọc).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from typing import Any, Generic, TypeVar
|
|
18
|
+
|
|
19
|
+
from pymodular.core.clock import utcnow
|
|
20
|
+
from pymodular.core.config import Settings
|
|
21
|
+
from pymodular.core.container import injectable
|
|
22
|
+
from pymodular.core.logging import get_logger
|
|
23
|
+
from pymodular.infrastructure.database.base import DatabaseBackend, is_transient_error
|
|
24
|
+
from pymodular.infrastructure.database.factory import create_backend
|
|
25
|
+
|
|
26
|
+
log = get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
E = TypeVar("E")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@injectable
|
|
32
|
+
class Database:
|
|
33
|
+
"""Sở hữu kết nối tới database đang được cấu hình."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, settings: Settings) -> None:
|
|
36
|
+
self._settings = settings
|
|
37
|
+
self._backend: DatabaseBackend = create_backend(settings.db)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def backend(self) -> DatabaseBackend:
|
|
41
|
+
return self._backend
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def driver(self) -> str:
|
|
45
|
+
return self._backend.name
|
|
46
|
+
|
|
47
|
+
async def startup(self, *entities: type) -> None:
|
|
48
|
+
await self._backend.startup()
|
|
49
|
+
await self._wait_until_reachable()
|
|
50
|
+
|
|
51
|
+
create_schema = getattr(self._backend, "create_schema", None)
|
|
52
|
+
if create_schema is not None and entities:
|
|
53
|
+
await create_schema(*entities)
|
|
54
|
+
|
|
55
|
+
async def _wait_until_reachable(self) -> None:
|
|
56
|
+
"""Thử ping vài lần trước khi bỏ cuộc.
|
|
57
|
+
|
|
58
|
+
Hay gặp với docker compose: app khởi động xong trước khi database sẵn
|
|
59
|
+
sàng nhận kết nối. Thử lại vài giây rẻ hơn nhiều so với để container
|
|
60
|
+
chết rồi chờ orchestrator dựng lại.
|
|
61
|
+
"""
|
|
62
|
+
import asyncio
|
|
63
|
+
|
|
64
|
+
attempts = max(1, self._settings.db.startup_retries)
|
|
65
|
+
delay = self._settings.db.startup_retry_delay_seconds
|
|
66
|
+
|
|
67
|
+
for attempt in range(1, attempts + 1):
|
|
68
|
+
try:
|
|
69
|
+
await self._backend.ping()
|
|
70
|
+
if attempt > 1:
|
|
71
|
+
log.info("db.connected_after_retry", backend=self._backend.name,
|
|
72
|
+
attempts=attempt)
|
|
73
|
+
return
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
reason = f"{type(exc).__name__}: {exc}"
|
|
76
|
+
|
|
77
|
+
# Sai mật khẩu / sai tên database thì thử lại vô ích, chỉ làm
|
|
78
|
+
# chậm lúc phát hiện cấu hình sai. Dừng ngay.
|
|
79
|
+
if not is_transient_error(exc):
|
|
80
|
+
log.error(
|
|
81
|
+
"db.config_error_at_startup",
|
|
82
|
+
backend=self._backend.name,
|
|
83
|
+
error=reason,
|
|
84
|
+
hint="lỗi này không tự hết khi thử lại — kiểm tra APP_DB__DSN",
|
|
85
|
+
)
|
|
86
|
+
raise
|
|
87
|
+
|
|
88
|
+
if attempt == attempts:
|
|
89
|
+
log.error(
|
|
90
|
+
"db.unreachable_at_startup",
|
|
91
|
+
backend=self._backend.name,
|
|
92
|
+
attempts=attempts,
|
|
93
|
+
error=reason,
|
|
94
|
+
)
|
|
95
|
+
raise
|
|
96
|
+
|
|
97
|
+
log.warning(
|
|
98
|
+
"db.retry_connect",
|
|
99
|
+
backend=self._backend.name,
|
|
100
|
+
attempt=attempt,
|
|
101
|
+
of=attempts,
|
|
102
|
+
retry_in=delay,
|
|
103
|
+
error=reason, # nói luôn lý do, đừng bắt chờ tới lần cuối
|
|
104
|
+
)
|
|
105
|
+
await asyncio.sleep(delay)
|
|
106
|
+
|
|
107
|
+
async def shutdown(self) -> None:
|
|
108
|
+
await self._backend.shutdown()
|
|
109
|
+
|
|
110
|
+
async def ping(self) -> bool:
|
|
111
|
+
return await self._backend.ping()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@injectable
|
|
115
|
+
class Repository(Generic[E]):
|
|
116
|
+
"""CRUD sẵn có cho một entity, bất kể backend nào bên dưới."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, entity: type[E], database: Database) -> None:
|
|
119
|
+
self._entity = entity
|
|
120
|
+
self._db = database
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def _backend(self) -> DatabaseBackend:
|
|
124
|
+
return self._db.backend
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------ đọc
|
|
127
|
+
async def get(self, id_: str) -> E | None:
|
|
128
|
+
return await self._backend.get(self._entity, id_)
|
|
129
|
+
|
|
130
|
+
async def find(
|
|
131
|
+
self,
|
|
132
|
+
*,
|
|
133
|
+
match: Callable[[E], bool] | None = None,
|
|
134
|
+
order_by: str | None = "created_at",
|
|
135
|
+
limit: int | None = None,
|
|
136
|
+
offset: int = 0,
|
|
137
|
+
**equals: Any,
|
|
138
|
+
) -> list[E]:
|
|
139
|
+
return await self._backend.find(
|
|
140
|
+
self._entity,
|
|
141
|
+
filters=equals,
|
|
142
|
+
match=match,
|
|
143
|
+
order_by=order_by,
|
|
144
|
+
limit=limit,
|
|
145
|
+
offset=offset,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
async def find_one(
|
|
149
|
+
self, *, match: Callable[[E], bool] | None = None, **equals: Any
|
|
150
|
+
) -> E | None:
|
|
151
|
+
return await self._backend.find_one(self._entity, filters=equals, match=match)
|
|
152
|
+
|
|
153
|
+
async def count(
|
|
154
|
+
self, *, match: Callable[[E], bool] | None = None, **equals: Any
|
|
155
|
+
) -> int:
|
|
156
|
+
return await self._backend.count(self._entity, filters=equals, match=match)
|
|
157
|
+
|
|
158
|
+
async def exists(
|
|
159
|
+
self, *, match: Callable[[E], bool] | None = None, **equals: Any
|
|
160
|
+
) -> bool:
|
|
161
|
+
return await self.find_one(match=match, **equals) is not None
|
|
162
|
+
|
|
163
|
+
# ------------------------------------------------------------------ ghi
|
|
164
|
+
async def save(self, obj: E) -> E:
|
|
165
|
+
"""Upsert. Tự sinh id nếu entity chưa có, và tự đóng dấu `updated_at`.
|
|
166
|
+
|
|
167
|
+
Đóng dấu ở đây chứ không ở service: mọi đường ghi đều đi qua save(),
|
|
168
|
+
nên không có chỗ nào quên. Tương đương @UpdateDateColumn của TypeORM.
|
|
169
|
+
Entity không có trường `updated_at` thì bỏ qua.
|
|
170
|
+
"""
|
|
171
|
+
if hasattr(obj, "updated_at"):
|
|
172
|
+
# Bản ghi mới: cho updated_at trùng created_at thay vì lệch vài
|
|
173
|
+
# micro giây, để "chưa từng sửa" nhận ra được bằng created == updated.
|
|
174
|
+
is_new = not getattr(obj, "id", None)
|
|
175
|
+
created = getattr(obj, "created_at", None)
|
|
176
|
+
obj.updated_at = created if (is_new and created is not None) else utcnow() # type: ignore[attr-defined]
|
|
177
|
+
return await self._backend.save(self._entity, obj)
|
|
178
|
+
|
|
179
|
+
async def delete(self, id_: str) -> bool:
|
|
180
|
+
return await self._backend.delete(self._entity, id_)
|
|
181
|
+
|
|
182
|
+
async def delete_where(
|
|
183
|
+
self, *, match: Callable[[E], bool] | None = None, **equals: Any
|
|
184
|
+
) -> int:
|
|
185
|
+
return await self._backend.delete_where(self._entity, filters=equals, match=match)
|
|
186
|
+
|
|
187
|
+
def __repr__(self) -> str:
|
|
188
|
+
return f"<Repository[{self._entity.__name__}] on {self._db.driver}>"
|