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,241 @@
|
|
|
1
|
+
"""Một kết nối của một client.
|
|
2
|
+
|
|
3
|
+
`Socket` bọc quanh `WebSocket` của Starlette và thêm ba thứ mà bản trần không
|
|
4
|
+
có, nhưng thiếu chúng thì không chạy nổi ngoài đời:
|
|
5
|
+
|
|
6
|
+
1. **Hàng đợi gửi + một task ghi duy nhất.** Starlette KHÔNG cho hai task cùng
|
|
7
|
+
`send_text()` trên một kết nối — làm vậy sẽ hỏng khung tin hoặc ném
|
|
8
|
+
RuntimeError. Mà broadcast thì bản chất là nhiều nơi cùng gửi. Nên mọi lời
|
|
9
|
+
gọi `emit()` chỉ bỏ tin vào hàng đợi; đúng một task lấy ra và ghi.
|
|
10
|
+
|
|
11
|
+
2. **Chống nghẽn (backpressure).** Hàng đợi có TRẦN. Client đọc chậm (mạng 3G,
|
|
12
|
+
tab bị treo) mà server cứ đẩy thì bộ nhớ server phình tới lúc sập — một
|
|
13
|
+
client hỏng kéo sập cả server. Đầy hàng đợi thì xử theo chính sách đã chọn:
|
|
14
|
+
ngắt kết nối (mặc định) hoặc bỏ tin cũ nhất.
|
|
15
|
+
|
|
16
|
+
3. **Danh tính và phòng.** `user_id` (một người có thể mở nhiều tab = nhiều
|
|
17
|
+
socket) và tập phòng đang tham gia.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import time
|
|
24
|
+
import uuid
|
|
25
|
+
from collections.abc import Awaitable, Callable
|
|
26
|
+
from typing import TYPE_CHECKING, Any
|
|
27
|
+
|
|
28
|
+
from starlette.websockets import WebSocket, WebSocketState
|
|
29
|
+
|
|
30
|
+
from pymodular.core.compat import TimeoutErrors
|
|
31
|
+
from pymodular.core.logging import get_logger
|
|
32
|
+
from pymodular.core.metrics import ws_messages_out, ws_send_dropped
|
|
33
|
+
from pymodular.core.websocket.protocol import CloseCode, Frame
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from pymodular.core.websocket.namespace import Namespace
|
|
37
|
+
|
|
38
|
+
log = get_logger(__name__)
|
|
39
|
+
|
|
40
|
+
# Bỏ vào hàng đợi để báo task ghi dừng lại.
|
|
41
|
+
_STOP = object()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Socket:
|
|
45
|
+
"""Một client đang kết nối. Do framework tạo, code nghiệp vụ chỉ dùng."""
|
|
46
|
+
|
|
47
|
+
__slots__ = (
|
|
48
|
+
"_close_hooks",
|
|
49
|
+
"_closer",
|
|
50
|
+
"_closing",
|
|
51
|
+
"_overflow",
|
|
52
|
+
"_queue",
|
|
53
|
+
"_writer",
|
|
54
|
+
"connected_at",
|
|
55
|
+
"data",
|
|
56
|
+
"id",
|
|
57
|
+
"namespace",
|
|
58
|
+
"roles",
|
|
59
|
+
"rooms",
|
|
60
|
+
"user_id",
|
|
61
|
+
"ws",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
ws: WebSocket,
|
|
67
|
+
namespace: Namespace,
|
|
68
|
+
*,
|
|
69
|
+
user_id: str | None = None,
|
|
70
|
+
roles: frozenset[str] = frozenset(),
|
|
71
|
+
queue_size: int = 100,
|
|
72
|
+
overflow: str = "close",
|
|
73
|
+
) -> None:
|
|
74
|
+
self.id = uuid.uuid4().hex[:16]
|
|
75
|
+
self.ws = ws
|
|
76
|
+
self.namespace = namespace
|
|
77
|
+
self.user_id = user_id
|
|
78
|
+
self.roles = roles
|
|
79
|
+
self.rooms: set[str] = set()
|
|
80
|
+
# Chỗ để handler ghi trạng thái riêng của kết nối này (tên hiển thị,
|
|
81
|
+
# bộ lọc đang chọn...). Không đụng tới framework.
|
|
82
|
+
self.data: dict[str, Any] = {}
|
|
83
|
+
self.connected_at = time.time()
|
|
84
|
+
|
|
85
|
+
self._queue: asyncio.Queue[Frame | object] = asyncio.Queue(maxsize=queue_size)
|
|
86
|
+
self._overflow = overflow
|
|
87
|
+
self._writer: asyncio.Task[None] | None = None
|
|
88
|
+
self._closer: asyncio.Task[None] | None = None
|
|
89
|
+
self._closing = False
|
|
90
|
+
self._close_hooks: list[Callable[[Socket], Awaitable[None]]] = []
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------ gửi
|
|
93
|
+
def emit(
|
|
94
|
+
self,
|
|
95
|
+
event: str,
|
|
96
|
+
data: Any = None,
|
|
97
|
+
*,
|
|
98
|
+
ack: str | None = None,
|
|
99
|
+
meta: dict[str, Any] | None = None,
|
|
100
|
+
) -> bool:
|
|
101
|
+
"""Xếp một tin vào hàng đợi gửi. Trả về False nếu tin bị bỏ.
|
|
102
|
+
|
|
103
|
+
KHÔNG phải coroutine: gọi được từ bất cứ đâu (kể cả handler HTTP) mà
|
|
104
|
+
không phải await, và không bao giờ chặn người gọi vì client chậm.
|
|
105
|
+
"""
|
|
106
|
+
return self.send(Frame(event=event, data=data, ack=ack, meta=meta))
|
|
107
|
+
|
|
108
|
+
def send(self, frame: Frame) -> bool:
|
|
109
|
+
if self._closing:
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
self._queue.put_nowait(frame)
|
|
114
|
+
except asyncio.QueueFull:
|
|
115
|
+
return self._on_full(frame)
|
|
116
|
+
|
|
117
|
+
ws_messages_out.inc(namespace=self.namespace.path, event=frame.event)
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
def _on_full(self, frame: Frame) -> bool:
|
|
121
|
+
"""Hàng đợi đầy: client không theo kịp tốc độ server đẩy."""
|
|
122
|
+
ws_send_dropped.inc(namespace=self.namespace.path)
|
|
123
|
+
|
|
124
|
+
if self._overflow == "drop_oldest":
|
|
125
|
+
# Hợp với dữ liệu "chỉ cần bản mới nhất" (vị trí, nhiệt độ, tiến độ).
|
|
126
|
+
try:
|
|
127
|
+
self._queue.get_nowait()
|
|
128
|
+
self._queue.task_done()
|
|
129
|
+
self._queue.put_nowait(frame)
|
|
130
|
+
return True
|
|
131
|
+
except (asyncio.QueueEmpty, asyncio.QueueFull):
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
# Mặc định: ngắt kết nối. Client tự nối lại và tải lại trạng thái, như
|
|
135
|
+
# vậy trung thực hơn là âm thầm nuốt tin khiến client tưởng mình vẫn
|
|
136
|
+
# đang đồng bộ.
|
|
137
|
+
log.warning(
|
|
138
|
+
"ws.slow_consumer",
|
|
139
|
+
socket_id=self.id,
|
|
140
|
+
user_id=self.user_id,
|
|
141
|
+
namespace=self.namespace.path,
|
|
142
|
+
queue_size=self._queue.maxsize,
|
|
143
|
+
)
|
|
144
|
+
self.close_soon(CloseCode.TRY_AGAIN_LATER, "client đọc quá chậm")
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
# ----------------------------------------------------------------- vòng ghi
|
|
148
|
+
def start_writer(self) -> None:
|
|
149
|
+
self._writer = asyncio.create_task(self._writer_loop(), name=f"ws-writer-{self.id}")
|
|
150
|
+
|
|
151
|
+
async def _writer_loop(self) -> None:
|
|
152
|
+
while True:
|
|
153
|
+
item = await self._queue.get()
|
|
154
|
+
if item is _STOP:
|
|
155
|
+
return
|
|
156
|
+
try:
|
|
157
|
+
await self.ws.send_text(item.to_json()) # type: ignore[union-attr]
|
|
158
|
+
except (RuntimeError, ConnectionError, asyncio.CancelledError):
|
|
159
|
+
# Kết nối đã đứt giữa chừng; vòng nhận sẽ dọn dẹp.
|
|
160
|
+
return
|
|
161
|
+
except Exception as exc: # noqa: BLE001 - lỗi khi ghi socket không được giết vòng nhận
|
|
162
|
+
log.warning("ws.send_failed", socket_id=self.id, error=str(exc))
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
async def stop_writer(self) -> None:
|
|
166
|
+
if self._writer is None:
|
|
167
|
+
return
|
|
168
|
+
# Đợi task ghi xả nốt hàng đợi, đừng cắt ngang giữa lúc đang ghi.
|
|
169
|
+
try:
|
|
170
|
+
self._queue.put_nowait(_STOP)
|
|
171
|
+
except asyncio.QueueFull:
|
|
172
|
+
self._writer.cancel()
|
|
173
|
+
try:
|
|
174
|
+
await asyncio.wait_for(asyncio.shield(self._writer), timeout=2.0)
|
|
175
|
+
except (*TimeoutErrors, asyncio.CancelledError):
|
|
176
|
+
self._writer.cancel()
|
|
177
|
+
self._writer = None
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------------- dọn khi đóng
|
|
180
|
+
def on_close(self, hook: Callable[[Socket], Awaitable[None]]) -> None:
|
|
181
|
+
"""Đăng ký việc cần làm khi kết nối này đóng.
|
|
182
|
+
|
|
183
|
+
Đây là chỗ để những lớp NGOÀI framework gắn phần dọn dẹp của mình mà
|
|
184
|
+
không phải sửa vào lõi WebSocket — ví dụ lớp nhắn tin gỡ các đăng ký
|
|
185
|
+
sự kiện của kết nối. Lõi chỉ biết "có việc phải gọi", không biết đó là
|
|
186
|
+
việc gì.
|
|
187
|
+
"""
|
|
188
|
+
self._close_hooks.append(hook)
|
|
189
|
+
|
|
190
|
+
async def run_close_hooks(self) -> None:
|
|
191
|
+
for hook in self._close_hooks:
|
|
192
|
+
try:
|
|
193
|
+
await hook(self)
|
|
194
|
+
except Exception as exc: # noqa: BLE001 - một hook hỏng không được cản phần dọn còn lại
|
|
195
|
+
log.warning("ws.close_hook_failed", socket_id=self.id, error=str(exc))
|
|
196
|
+
self._close_hooks.clear()
|
|
197
|
+
|
|
198
|
+
# ------------------------------------------------------------------ phòng
|
|
199
|
+
def join(self, room: str) -> None:
|
|
200
|
+
"""Vào phòng. Gọi lại nhiều lần không sao."""
|
|
201
|
+
self.namespace.join(self, room)
|
|
202
|
+
|
|
203
|
+
def leave(self, room: str) -> None:
|
|
204
|
+
self.namespace.leave(self, room)
|
|
205
|
+
|
|
206
|
+
def in_room(self, room: str) -> bool:
|
|
207
|
+
return room in self.rooms
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------- đóng
|
|
210
|
+
def close_soon(self, code: int = CloseCode.NORMAL, reason: str = "") -> None:
|
|
211
|
+
"""Yêu cầu đóng mà không cần await — dùng được trong hàm đồng bộ."""
|
|
212
|
+
if self._closing:
|
|
213
|
+
return
|
|
214
|
+
self._closing = True
|
|
215
|
+
# Giữ tham chiếu: task không có ai nắm có thể bị GC dọn giữa chừng.
|
|
216
|
+
self._closer = asyncio.create_task(self._close(code, reason), name=f"ws-close-{self.id}")
|
|
217
|
+
|
|
218
|
+
async def close(self, code: int = CloseCode.NORMAL, reason: str = "") -> None:
|
|
219
|
+
if self._closing:
|
|
220
|
+
return
|
|
221
|
+
self._closing = True
|
|
222
|
+
await self._close(code, reason)
|
|
223
|
+
|
|
224
|
+
async def _close(self, code: int, reason: str) -> None:
|
|
225
|
+
try:
|
|
226
|
+
if self.ws.client_state is WebSocketState.CONNECTED:
|
|
227
|
+
await self.ws.close(code=code, reason=reason)
|
|
228
|
+
except (RuntimeError, ConnectionError):
|
|
229
|
+
pass # đối phương đã đóng trước
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def closing(self) -> bool:
|
|
233
|
+
return self._closing
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def pending(self) -> int:
|
|
237
|
+
"""Số tin còn nằm trong hàng đợi — soi được client nào đang chậm."""
|
|
238
|
+
return self._queue.qsize()
|
|
239
|
+
|
|
240
|
+
def __repr__(self) -> str:
|
|
241
|
+
return f"<Socket {self.id} user={self.user_id} rooms={sorted(self.rooms)}>"
|
pymodular/discovery.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Module registry — tự động gom controller của mọi module trong package ứng dụng.
|
|
2
|
+
|
|
3
|
+
Tương đương module scanning của NestJS. Mỗi thư mục con của package ứng dụng là
|
|
4
|
+
một module; hàm ở đây nạp hết submodule bên trong để các decorator @controller
|
|
5
|
+
chạy, rồi dựng router từ những controller vừa đăng ký.
|
|
6
|
+
|
|
7
|
+
Thêm module mới = tạo thư mục, viết @controller. Không sửa file này, và cũng
|
|
8
|
+
không phải export biến `router` nào trong module.
|
|
9
|
+
|
|
10
|
+
Trường hợp cần một APIRouter dựng tay (websocket, router của thư viện ngoài),
|
|
11
|
+
module vẫn có thể export biến `router` — nếu có, khung dùng luôn cái đó.
|
|
12
|
+
|
|
13
|
+
Package ứng dụng mặc định là `src.api`. Dự án xếp khác thì truyền vào:
|
|
14
|
+
|
|
15
|
+
create_app(settings, package="cong_ty.dich_vu")
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import pkgutil
|
|
21
|
+
from importlib import import_module
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from fastapi import APIRouter, FastAPI
|
|
25
|
+
|
|
26
|
+
from pymodular.core.controller import build_router, controllers_in
|
|
27
|
+
from pymodular.core.logging import get_logger
|
|
28
|
+
from pymodular.core.websocket import build_ws_router, gateways_in
|
|
29
|
+
|
|
30
|
+
log = get_logger(__name__)
|
|
31
|
+
|
|
32
|
+
DEFAULT_PACKAGE = "src.api"
|
|
33
|
+
"""Package chứa các module ứng dụng, khi không truyền gì khác.
|
|
34
|
+
|
|
35
|
+
Khớp với bộ khung `pym init` sinh ra: `src/api/<module>/`. Dự án xếp khác thì
|
|
36
|
+
nói ra một lần trong `src/main.py`:
|
|
37
|
+
|
|
38
|
+
register_routes(app, package="cong_ty.dich_vu")
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _package_dir(package: str) -> Path:
|
|
43
|
+
"""Thư mục thật của package — quét file phải dựa vào đây, không phải __file__.
|
|
44
|
+
|
|
45
|
+
Trước đây hàm này lấy `Path(__file__).parent`, đúng khi khung và ứng dụng
|
|
46
|
+
nằm chung một cây thư mục. Cài bằng pip thì khung nằm trong site-packages
|
|
47
|
+
còn ứng dụng nằm ở dự án của người dùng, nên phải hỏi chính package đó.
|
|
48
|
+
"""
|
|
49
|
+
module = import_module(package)
|
|
50
|
+
paths = list(getattr(module, "__path__", []))
|
|
51
|
+
if not paths:
|
|
52
|
+
raise RuntimeError(
|
|
53
|
+
f"{package!r} không phải một package (thiếu __init__.py?). "
|
|
54
|
+
"Đây là nơi chứa các module ứng dụng — xem pymodular.discovery."
|
|
55
|
+
)
|
|
56
|
+
return Path(paths[0])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _import_submodules(package: str, package_dir: Path) -> None:
|
|
60
|
+
"""Nạp mọi submodule để decorator @controller/@injectable/@entity chạy."""
|
|
61
|
+
for info in pkgutil.walk_packages([str(package_dir)], prefix=f"{package}."):
|
|
62
|
+
import_module(info.name)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_all_modules(package: str = DEFAULT_PACKAGE) -> None:
|
|
66
|
+
"""Nạp mọi module trong package ứng dụng để decorator @entity/@injectable chạy.
|
|
67
|
+
|
|
68
|
+
Migration cần hàm này: Alembic phải thấy đủ entity mới sinh được diff, mà
|
|
69
|
+
nó không đi qua đường khởi động app.
|
|
70
|
+
"""
|
|
71
|
+
_iter_packages(package)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _iter_packages(package: str) -> list[str]:
|
|
75
|
+
"""Tên các module con, đã nạp xong, theo thứ tự alphabet."""
|
|
76
|
+
goc = _package_dir(package)
|
|
77
|
+
names: list[str] = []
|
|
78
|
+
for info in sorted(pkgutil.iter_modules([str(goc)]), key=lambda i: i.name):
|
|
79
|
+
if not info.ispkg or info.name.startswith("_"):
|
|
80
|
+
continue
|
|
81
|
+
import_module(f"{package}.{info.name}")
|
|
82
|
+
_import_submodules(f"{package}.{info.name}", goc / info.name)
|
|
83
|
+
names.append(info.name)
|
|
84
|
+
return names
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def discover_routers(package: str = DEFAULT_PACKAGE) -> list[tuple[str, APIRouter]]:
|
|
88
|
+
"""Quét package ứng dụng, trả về [(tên module, router HTTP)] theo alphabet."""
|
|
89
|
+
found: list[tuple[str, APIRouter]] = []
|
|
90
|
+
|
|
91
|
+
for name in _iter_packages(package):
|
|
92
|
+
module_package = f"{package}.{name}"
|
|
93
|
+
module = import_module(module_package)
|
|
94
|
+
|
|
95
|
+
explicit = getattr(module, "router", None)
|
|
96
|
+
if isinstance(explicit, APIRouter):
|
|
97
|
+
found.append((name, explicit))
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
controllers = controllers_in(module_package)
|
|
101
|
+
if not controllers:
|
|
102
|
+
if gateways_in(module_package):
|
|
103
|
+
# Module chỉ có gateway WebSocket là hợp lệ, không phải thiếu sót.
|
|
104
|
+
continue
|
|
105
|
+
# Im lặng ở đây là nguyên nhân số một của "sao route của tôi 404?".
|
|
106
|
+
log.warning("api.module_without_controller", module=name)
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
log.debug(
|
|
110
|
+
"api.module_scanned",
|
|
111
|
+
module=name,
|
|
112
|
+
controllers=[c.__name__ for c in controllers],
|
|
113
|
+
)
|
|
114
|
+
found.append((name, build_router(*controllers)))
|
|
115
|
+
|
|
116
|
+
return found
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def discover_gateways(package: str = DEFAULT_PACKAGE) -> list[tuple[str, list[type]]]:
|
|
120
|
+
"""Quét package ứng dụng, trả về [(tên module, các class gateway)]."""
|
|
121
|
+
return [
|
|
122
|
+
(name, found)
|
|
123
|
+
for name in _iter_packages(package)
|
|
124
|
+
if (found := gateways_in(f"{package}.{name}"))
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def register_routes(
|
|
129
|
+
app: FastAPI, *, prefix: str = "/api", package: str = DEFAULT_PACKAGE
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Gắn toàn bộ module đã phát hiện vào app."""
|
|
132
|
+
api = APIRouter(prefix=prefix)
|
|
133
|
+
names: list[str] = []
|
|
134
|
+
|
|
135
|
+
for name, router in discover_routers(package):
|
|
136
|
+
api.include_router(router)
|
|
137
|
+
names.append(name)
|
|
138
|
+
|
|
139
|
+
app.include_router(api)
|
|
140
|
+
log.info(
|
|
141
|
+
"api.modules_registered",
|
|
142
|
+
package=package,
|
|
143
|
+
prefix=prefix,
|
|
144
|
+
count=len(names),
|
|
145
|
+
modules=names,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
register_gateways(app, package=package)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def register_gateways(app: FastAPI, *, package: str = DEFAULT_PACKAGE) -> None:
|
|
152
|
+
"""Gắn route WebSocket.
|
|
153
|
+
|
|
154
|
+
KHÔNG nằm dưới tiền tố /api: đường dẫn WebSocket do @gateway(path=...) khai
|
|
155
|
+
trọn vẹn, vì WebSocket không phải REST và thường được reverse proxy định
|
|
156
|
+
tuyến riêng (nginx phải bật Upgrade cho đúng những đường dẫn này).
|
|
157
|
+
"""
|
|
158
|
+
classes: list[type] = []
|
|
159
|
+
paths: dict[str, str] = {}
|
|
160
|
+
|
|
161
|
+
for module_name, gateways in discover_gateways(package):
|
|
162
|
+
for cls in gateways:
|
|
163
|
+
path = cls.__gateway_meta__.path
|
|
164
|
+
if path in paths:
|
|
165
|
+
raise RuntimeError(
|
|
166
|
+
f"Hai gateway cùng path {path!r}: {paths[path]} và {cls.__name__}"
|
|
167
|
+
)
|
|
168
|
+
paths[path] = cls.__name__
|
|
169
|
+
classes.append(cls)
|
|
170
|
+
log.debug("api.gateways_scanned", module=module_name)
|
|
171
|
+
|
|
172
|
+
if not classes:
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
app.include_router(build_ws_router(*classes))
|
|
176
|
+
log.info(
|
|
177
|
+
"api.gateways_registered",
|
|
178
|
+
count=len(classes),
|
|
179
|
+
gateways=[f"{cls.__name__} {cls.__gateway_meta__.path}" for cls in classes],
|
|
180
|
+
)
|
pymodular/factory.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Các mảnh ghép để dựng app — và `create_app()` gom sẵn chúng lại.
|
|
2
|
+
|
|
3
|
+
`src/main.py` là file CỦA BẠN, không phải của khung. Nên mỗi bước lắp ráp ở đây
|
|
4
|
+
là một hàm công khai, gọi riêng được, chèn thêm được:
|
|
5
|
+
|
|
6
|
+
settings = bind_settings(AppSettings())
|
|
7
|
+
configure_logging(settings.log)
|
|
8
|
+
|
|
9
|
+
app = new_fastapi(settings, lifespan=lifespan)
|
|
10
|
+
add_middleware(app, settings)
|
|
11
|
+
register_error_handlers(app, debug=settings.debug)
|
|
12
|
+
register_routes(app, prefix=settings.api_prefix)
|
|
13
|
+
|
|
14
|
+
Cần thêm middleware của mình, đổi thứ tự, bỏ CORS, gắn router bên thứ ba? Sửa
|
|
15
|
+
thẳng trong main.py của bạn, không phải ngồi tìm cách "ghi đè" cái gì trong
|
|
16
|
+
khung.
|
|
17
|
+
|
|
18
|
+
`create_app()` chạy đúng dãy trên, không hơn. Dùng nó khi chưa cần sửa gì:
|
|
19
|
+
|
|
20
|
+
app = create_app(AppSettings())
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from fastapi import FastAPI
|
|
28
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
29
|
+
|
|
30
|
+
from pymodular.core.config import Settings, get_settings, use_settings
|
|
31
|
+
from pymodular.core.container import container
|
|
32
|
+
from pymodular.core.error_handlers import register_error_handlers
|
|
33
|
+
from pymodular.core.lifespan import lifespan
|
|
34
|
+
from pymodular.core.logging import configure_logging
|
|
35
|
+
from pymodular.discovery import DEFAULT_PACKAGE, register_routes
|
|
36
|
+
from pymodular.middleware.request_context import AccessLogMiddleware, RequestContextMiddleware
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def bind_settings(settings: Settings | None = None) -> Settings:
|
|
40
|
+
"""Chốt cấu hình cho cả tiến trình và cắm nó vào DI container.
|
|
41
|
+
|
|
42
|
+
Truyền `None` thì đọc từ `.env` bằng lớp `Settings` gốc. Truyền một instance
|
|
43
|
+
lớp con thì lớp đó được ghi nhớ, để những chỗ khung tự dựng cấu hình
|
|
44
|
+
(Alembic, gateway lúc không có request) không ra một bản cấu hình khác.
|
|
45
|
+
|
|
46
|
+
Đăng ký dưới CẢ chuỗi kế thừa: container tra provider theo TÊN lớp, nên khai
|
|
47
|
+
`def __init__(self, settings: AppSettings)` mà chỉ đăng ký "Settings" thì sẽ
|
|
48
|
+
báo không có provider 'AppSettings'.
|
|
49
|
+
"""
|
|
50
|
+
if settings is None:
|
|
51
|
+
settings = get_settings()
|
|
52
|
+
else:
|
|
53
|
+
use_settings(type(settings))
|
|
54
|
+
|
|
55
|
+
for lop in type(settings).__mro__:
|
|
56
|
+
if not (isinstance(lop, type) and issubclass(lop, Settings)):
|
|
57
|
+
break
|
|
58
|
+
container.override(lop, settings)
|
|
59
|
+
return settings
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def new_fastapi(settings: Settings, **kwargs: Any) -> FastAPI:
|
|
63
|
+
"""`FastAPI(...)` với tiêu đề, phiên bản và đường dẫn docs lấy từ cấu hình.
|
|
64
|
+
|
|
65
|
+
Ở prod, `docs_url`/`redoc_url`/`openapi_url` là None nên trang docs biến
|
|
66
|
+
mất — đừng để lộ sơ đồ API ra ngoài mà không có chủ ý.
|
|
67
|
+
|
|
68
|
+
`kwargs` truyền thẳng cho FastAPI, và ĐÈ được mọi giá trị ở trên.
|
|
69
|
+
"""
|
|
70
|
+
mac_dinh: dict[str, Any] = {
|
|
71
|
+
"title": settings.name,
|
|
72
|
+
"version": settings.version,
|
|
73
|
+
"debug": settings.debug,
|
|
74
|
+
"lifespan": lifespan,
|
|
75
|
+
"docs_url": settings.docs_url,
|
|
76
|
+
"redoc_url": settings.redoc_url,
|
|
77
|
+
"openapi_url": settings.openapi_url,
|
|
78
|
+
}
|
|
79
|
+
return FastAPI(**{**mac_dinh, **kwargs})
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def add_middleware(app: FastAPI, settings: Settings) -> None:
|
|
83
|
+
"""Ba middleware của khung: CORS, access log, request-id.
|
|
84
|
+
|
|
85
|
+
Thứ tự add là NGƯỢC với thứ tự chạy, nên RequestContext (thứ sinh ra
|
|
86
|
+
request-id) được add sau cùng để chạy đầu tiên — mọi log của các lớp sau nó
|
|
87
|
+
mới có id để bám theo.
|
|
88
|
+
|
|
89
|
+
Thêm middleware của bạn thì gọi `app.add_middleware(...)` sau hàm này nếu
|
|
90
|
+
muốn nó chạy TRƯỚC, hoặc trước hàm này nếu muốn nó chạy SAU.
|
|
91
|
+
"""
|
|
92
|
+
app.add_middleware(
|
|
93
|
+
CORSMiddleware,
|
|
94
|
+
allow_origins=settings.cors.allow_origins,
|
|
95
|
+
allow_credentials=settings.cors.allow_credentials,
|
|
96
|
+
allow_methods=settings.cors.allow_methods,
|
|
97
|
+
allow_headers=settings.cors.allow_headers,
|
|
98
|
+
)
|
|
99
|
+
app.add_middleware(AccessLogMiddleware)
|
|
100
|
+
app.add_middleware(RequestContextMiddleware)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def create_app(
|
|
104
|
+
settings: Settings | None = None, *, package: str = DEFAULT_PACKAGE
|
|
105
|
+
) -> FastAPI:
|
|
106
|
+
"""Dựng app theo cách mặc định. `package` là nơi chứa module ứng dụng.
|
|
107
|
+
|
|
108
|
+
Không có gì ở đây mà `src/main.py` của bạn không gọi được từng phần.
|
|
109
|
+
"""
|
|
110
|
+
settings = bind_settings(settings)
|
|
111
|
+
configure_logging(settings.log)
|
|
112
|
+
|
|
113
|
+
app = new_fastapi(settings)
|
|
114
|
+
add_middleware(app, settings)
|
|
115
|
+
register_error_handlers(app, debug=settings.debug)
|
|
116
|
+
register_routes(app, prefix=settings.api_prefix, package=package)
|
|
117
|
+
|
|
118
|
+
@app.get("/", include_in_schema=False)
|
|
119
|
+
async def root() -> dict[str, str]:
|
|
120
|
+
return {
|
|
121
|
+
"service": settings.name,
|
|
122
|
+
"version": settings.version,
|
|
123
|
+
"docs": settings.docs_url or "disabled",
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return app
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Hạ tầng: những thứ nói chuyện với thế giới bên ngoài (database, cache, message bus)."""
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Tầng database: hợp đồng chung, các backend cụ thể, và Repository dùng chung.
|
|
2
|
+
|
|
3
|
+
Import từ đây: from pymodular.infrastructure.database import Repository
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pymodular.infrastructure.database.repository import Database, Repository
|
|
7
|
+
|
|
8
|
+
__all__ = ["Database", "Repository"]
|