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,735 @@
|
|
|
1
|
+
"""Gateway — tương đương @WebSocketGateway + @SubscribeMessage của NestJS.
|
|
2
|
+
|
|
3
|
+
@gateway(path="/ws/chat", guards=[WsIdentity], client_rooms=True)
|
|
4
|
+
class ChatGateway:
|
|
5
|
+
def __init__(self, service: ChatService) -> None:
|
|
6
|
+
self._service = service
|
|
7
|
+
|
|
8
|
+
async def on_connect(self, socket: Socket) -> None: ...
|
|
9
|
+
async def on_disconnect(self, socket: Socket, code: int) -> None: ...
|
|
10
|
+
|
|
11
|
+
@subscribe("message.send")
|
|
12
|
+
async def send(self, socket: Socket, payload: MessageIn) -> dict:
|
|
13
|
+
...
|
|
14
|
+
return {"ok": True} # giá trị trả về được gửi lại làm ack
|
|
15
|
+
|
|
16
|
+
Không phải khai báo ở đâu khác: `app/app.py` quét thư mục và tự gắn.
|
|
17
|
+
|
|
18
|
+
Mọi thứ khác trong khung — DI, guard, Principal, request scope, DTO pydantic,
|
|
19
|
+
cây exception — dùng lại y nguyên của phía HTTP. Một service viết cho REST
|
|
20
|
+
gọi được từ gateway mà không sửa gì.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import contextlib
|
|
27
|
+
import inspect
|
|
28
|
+
import time
|
|
29
|
+
from collections.abc import Callable, Sequence
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Any, TypeVar, get_type_hints
|
|
32
|
+
|
|
33
|
+
from fastapi import APIRouter
|
|
34
|
+
from pydantic import BaseModel, ValidationError
|
|
35
|
+
from starlette.websockets import WebSocket, WebSocketDisconnect
|
|
36
|
+
|
|
37
|
+
from pymodular.core.compat import TimeoutErrors
|
|
38
|
+
from pymodular.core.config import Settings, WebSocketSettings, get_settings
|
|
39
|
+
from pymodular.core.container import container, injectable, request_scope
|
|
40
|
+
from pymodular.core.context import (
|
|
41
|
+
new_request_id,
|
|
42
|
+
reset_request_id,
|
|
43
|
+
reset_user_id,
|
|
44
|
+
set_request_id,
|
|
45
|
+
set_user_id,
|
|
46
|
+
)
|
|
47
|
+
from pymodular.core.exceptions import (
|
|
48
|
+
AppError,
|
|
49
|
+
BadRequestError,
|
|
50
|
+
ForbiddenError,
|
|
51
|
+
NotImplementedYetError,
|
|
52
|
+
TooManyRequestsError,
|
|
53
|
+
)
|
|
54
|
+
from pymodular.core.guards import Principal
|
|
55
|
+
from pymodular.core.logging import get_logger
|
|
56
|
+
from pymodular.core.metrics import ws_connections, ws_connections_total, ws_messages_in
|
|
57
|
+
from pymodular.core.websocket.protocol import (
|
|
58
|
+
EVENT_CONNECTED,
|
|
59
|
+
EVENT_PING,
|
|
60
|
+
EVENT_PONG,
|
|
61
|
+
EVENT_ROOM_JOIN,
|
|
62
|
+
EVENT_ROOM_LEAVE,
|
|
63
|
+
RESERVED_EVENTS,
|
|
64
|
+
CloseCode,
|
|
65
|
+
Frame,
|
|
66
|
+
ProtocolError,
|
|
67
|
+
close_code_for,
|
|
68
|
+
error_frame,
|
|
69
|
+
parse_frame,
|
|
70
|
+
)
|
|
71
|
+
from pymodular.core.websocket.server import WebSocketServer
|
|
72
|
+
from pymodular.core.websocket.socket import Socket
|
|
73
|
+
|
|
74
|
+
log = get_logger(__name__)
|
|
75
|
+
|
|
76
|
+
T = TypeVar("T")
|
|
77
|
+
|
|
78
|
+
_GATEWAY_ATTR = "__gateway_meta__"
|
|
79
|
+
_EVENT_ATTR = "__ws_event__"
|
|
80
|
+
|
|
81
|
+
# Sổ gateway theo thứ tự khai báo (= thứ tự import), giống _CONTROLLERS.
|
|
82
|
+
_GATEWAYS: list[type] = []
|
|
83
|
+
|
|
84
|
+
MAX_ROOM_NAME = 128
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(slots=True)
|
|
88
|
+
class GatewayMeta:
|
|
89
|
+
path: str
|
|
90
|
+
guards: tuple[type, ...]
|
|
91
|
+
client_rooms: bool
|
|
92
|
+
name: str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def gateway(
|
|
96
|
+
*,
|
|
97
|
+
path: str,
|
|
98
|
+
guards: Sequence[type] = (),
|
|
99
|
+
client_rooms: bool = False,
|
|
100
|
+
name: str | None = None,
|
|
101
|
+
) -> Callable[[type[T]], type[T]]:
|
|
102
|
+
"""Đánh dấu class là gateway WebSocket và đăng ký làm provider.
|
|
103
|
+
|
|
104
|
+
- `path` : đường dẫn kết nối, ví dụ "/ws/chat". KHÔNG nằm dưới
|
|
105
|
+
tiền tố /api — WebSocket không phải REST.
|
|
106
|
+
- `guards` : chạy MỘT LẦN lúc bắt tay. Từ chối thì đóng kết nối kèm
|
|
107
|
+
mã lý do (4401/4403...). Dùng chung lớp Guard với HTTP.
|
|
108
|
+
- `client_rooms` : cho phép client tự gửi `room.join` / `room.leave`.
|
|
109
|
+
Mặc định TẮT, và đây là chủ ý: mở ra nghĩa là ai cũng
|
|
110
|
+
vào được phòng bất kỳ, kể cả phòng riêng của người khác.
|
|
111
|
+
Bật thì nên kèm hook `can_join()` để kiểm tra quyền.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def decorate(cls: type[T]) -> type[T]:
|
|
115
|
+
if not path.startswith("/"):
|
|
116
|
+
raise RuntimeError(f"{cls.__name__}: path phải bắt đầu bằng '/', đang là {path!r}")
|
|
117
|
+
setattr(
|
|
118
|
+
cls,
|
|
119
|
+
_GATEWAY_ATTR,
|
|
120
|
+
GatewayMeta(
|
|
121
|
+
path=path,
|
|
122
|
+
guards=tuple(guards),
|
|
123
|
+
client_rooms=client_rooms,
|
|
124
|
+
name=name or cls.__name__,
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
_GATEWAYS.append(cls)
|
|
128
|
+
return injectable(cls)
|
|
129
|
+
|
|
130
|
+
return decorate
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def subscribe(event: str) -> Callable[[Callable], Callable]:
|
|
134
|
+
"""Gắn method vào một tên sự kiện (tương đương @SubscribeMessage)."""
|
|
135
|
+
if event in RESERVED_EVENTS:
|
|
136
|
+
raise RuntimeError(
|
|
137
|
+
f"'{event}' là sự kiện của framework, không đăng ký đè được. "
|
|
138
|
+
"Với room.join/room.leave, dùng client_rooms=True và hook can_join()."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def decorate(fn: Callable) -> Callable:
|
|
142
|
+
setattr(fn, _EVENT_ATTR, event)
|
|
143
|
+
return fn
|
|
144
|
+
|
|
145
|
+
return decorate
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def gateways_in(package: str) -> list[type]:
|
|
149
|
+
return [
|
|
150
|
+
cls
|
|
151
|
+
for cls in _GATEWAYS
|
|
152
|
+
if cls.__module__ == package or cls.__module__.startswith(f"{package}.")
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --------------------------------------------------------------------- handler
|
|
157
|
+
@dataclass(slots=True)
|
|
158
|
+
class _Handler:
|
|
159
|
+
event: str
|
|
160
|
+
fn: Callable
|
|
161
|
+
model: type[BaseModel] | None
|
|
162
|
+
wants_payload: bool
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _collect_handlers(cls: type) -> dict[str, _Handler]:
|
|
166
|
+
"""Đọc chữ ký các method @subscribe MỘT LẦN lúc dựng router.
|
|
167
|
+
|
|
168
|
+
Làm ở đây chứ không phải mỗi lần có tin: inspect + get_type_hints là việc
|
|
169
|
+
đắt, còn tin nhắn thì có thể tới hàng nghìn lần một giây.
|
|
170
|
+
|
|
171
|
+
Duyệt ngược MRO nên handler KẾ THỪA cũng được nhận, và lớp con ghi đè được
|
|
172
|
+
lớp cha. Nhờ vậy một bộ sự kiện dùng chung đóng gói được thành lớp trộn
|
|
173
|
+
(mixin) ở ngoài lõi — đó là cách lớp nhắn tin gắn `event.subscribe` vào mà
|
|
174
|
+
không phải sửa một dòng nào trong file này.
|
|
175
|
+
"""
|
|
176
|
+
handlers: dict[str, _Handler] = {}
|
|
177
|
+
owners: dict[str, type] = {}
|
|
178
|
+
|
|
179
|
+
for klass, fn in _own_and_inherited(cls):
|
|
180
|
+
event = getattr(fn, _EVENT_ATTR, None)
|
|
181
|
+
if event is None:
|
|
182
|
+
continue
|
|
183
|
+
if not inspect.iscoroutinefunction(fn):
|
|
184
|
+
raise RuntimeError(f"{klass.__name__}.{fn.__name__} phải là `async def`")
|
|
185
|
+
|
|
186
|
+
params = list(inspect.signature(fn).parameters.values())[1:] # bỏ self
|
|
187
|
+
if not params:
|
|
188
|
+
raise RuntimeError(
|
|
189
|
+
f"{cls.__name__}.{fn.__name__} thiếu tham số socket. "
|
|
190
|
+
f"Chữ ký đúng: async def {fn.__name__}(self, socket: Socket, payload: ...)"
|
|
191
|
+
)
|
|
192
|
+
if len(params) > 2:
|
|
193
|
+
raise RuntimeError(
|
|
194
|
+
f"{cls.__name__}.{fn.__name__} có quá nhiều tham số. "
|
|
195
|
+
"Gateway chỉ nhận (self, socket) hoặc (self, socket, payload)."
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
model: type[BaseModel] | None = None
|
|
199
|
+
wants_payload = len(params) == 2
|
|
200
|
+
if wants_payload:
|
|
201
|
+
hints = get_type_hints(fn)
|
|
202
|
+
annotation = hints.get(params[1].name)
|
|
203
|
+
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
|
204
|
+
model = annotation
|
|
205
|
+
|
|
206
|
+
# Cùng một class mà hai method cùng đăng ký một sự kiện là lỗi gõ nhầm.
|
|
207
|
+
# Còn lớp con đăng ký đè sự kiện của lớp cha là CHỦ Ý — đó là cách ghi
|
|
208
|
+
# đè hành vi mặc định của một mixin.
|
|
209
|
+
da_co = owners.get(event)
|
|
210
|
+
if da_co is klass:
|
|
211
|
+
raise RuntimeError(f"{klass.__name__} đăng ký trùng sự kiện '{event}'")
|
|
212
|
+
owners[event] = klass
|
|
213
|
+
handlers[event] = _Handler(event, fn, model, wants_payload)
|
|
214
|
+
|
|
215
|
+
return handlers
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _own_and_inherited(cls: type) -> list[tuple[type, Callable]]:
|
|
219
|
+
"""Method mang @subscribe của class và của mọi lớp cha — cha trước, con sau.
|
|
220
|
+
|
|
221
|
+
Thứ tự đó khiến lớp con ghi đè lớp cha một cách tự nhiên.
|
|
222
|
+
"""
|
|
223
|
+
found: list[tuple[type, Callable]] = []
|
|
224
|
+
for klass in reversed(cls.__mro__):
|
|
225
|
+
found.extend(
|
|
226
|
+
(klass, fn) for fn in vars(klass).values() if hasattr(fn, _EVENT_ATTR)
|
|
227
|
+
)
|
|
228
|
+
return found
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _optional_hook(cls: type, name: str) -> Callable | None:
|
|
232
|
+
"""Lấy hook tuỳ chọn của gateway (on_connect, on_disconnect, can_*).
|
|
233
|
+
|
|
234
|
+
Hook `can_*` trả lời có/không, thường chỉ so vài chuỗi nên viết `def`
|
|
235
|
+
thường cũng được. Hook vòng đời thì bắt buộc `async def` vì gần như chắc
|
|
236
|
+
chắn sẽ có I/O trong đó.
|
|
237
|
+
"""
|
|
238
|
+
hook = getattr(cls, name, None)
|
|
239
|
+
if hook is None:
|
|
240
|
+
return None
|
|
241
|
+
if not inspect.iscoroutinefunction(hook) and not name.startswith("can_"):
|
|
242
|
+
raise RuntimeError(f"{cls.__name__}.{name} phải là `async def`")
|
|
243
|
+
return hook
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ------------------------------------------------------------------ kết nối
|
|
247
|
+
class _Connection:
|
|
248
|
+
"""Vòng đời của MỘT kết nối. Mỗi client được một instance riêng."""
|
|
249
|
+
|
|
250
|
+
def __init__(
|
|
251
|
+
self,
|
|
252
|
+
cls: type,
|
|
253
|
+
meta: GatewayMeta,
|
|
254
|
+
handlers: dict[str, _Handler],
|
|
255
|
+
ws: WebSocket,
|
|
256
|
+
config: WebSocketSettings,
|
|
257
|
+
*,
|
|
258
|
+
debug: bool,
|
|
259
|
+
) -> None:
|
|
260
|
+
self.cls = cls
|
|
261
|
+
self.meta = meta
|
|
262
|
+
self.handlers = handlers
|
|
263
|
+
self.ws = ws
|
|
264
|
+
self.config = config
|
|
265
|
+
self.debug = debug
|
|
266
|
+
self.server: WebSocketServer = container.resolve(WebSocketServer)
|
|
267
|
+
self.namespace = self.server.namespace(meta.path)
|
|
268
|
+
self.socket: Socket | None = None
|
|
269
|
+
self._tokens = float(config.burst_messages)
|
|
270
|
+
self._refilled = time.monotonic()
|
|
271
|
+
|
|
272
|
+
# ------------------------------------------------------------- vào cuộc
|
|
273
|
+
async def run(self) -> None:
|
|
274
|
+
if len(self.namespace) >= self.config.max_connections:
|
|
275
|
+
# Chưa accept: từ chối ở tầng bắt tay, rẻ hơn nhiều so với nhận rồi đóng.
|
|
276
|
+
await self.ws.close(CloseCode.TOO_MANY, "server đã đủ kết nối")
|
|
277
|
+
log.warning("ws.rejected_full", namespace=self.meta.path)
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
# Accept TRƯỚC khi chạy guard, dù nghe ngược đời. Đóng khi chưa accept
|
|
281
|
+
# thì trình duyệt chỉ thấy "handshake failed" và KHÔNG đọc được mã lý
|
|
282
|
+
# do; accept rồi mới đóng thì client nhận đúng 4401/4403 và biết phải
|
|
283
|
+
# xin token mới thay vì nối lại vô hạn.
|
|
284
|
+
await self.ws.accept()
|
|
285
|
+
|
|
286
|
+
identity = await self._authenticate()
|
|
287
|
+
if identity is None:
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
user_id, roles = identity
|
|
291
|
+
if not await self._within_user_quota(user_id):
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
self.socket = Socket(
|
|
295
|
+
self.ws,
|
|
296
|
+
self.namespace,
|
|
297
|
+
user_id=user_id,
|
|
298
|
+
roles=roles,
|
|
299
|
+
queue_size=self.config.send_queue_size,
|
|
300
|
+
overflow=self.config.overflow,
|
|
301
|
+
)
|
|
302
|
+
self.socket.start_writer()
|
|
303
|
+
self.namespace.add(self.socket)
|
|
304
|
+
ws_connections.inc_gauge(1, namespace=self.meta.path)
|
|
305
|
+
ws_connections_total.inc(namespace=self.meta.path)
|
|
306
|
+
|
|
307
|
+
code = CloseCode.NORMAL
|
|
308
|
+
heartbeat: asyncio.Task[None] | None = None
|
|
309
|
+
try:
|
|
310
|
+
if not await self._call_on_connect():
|
|
311
|
+
return
|
|
312
|
+
self.socket.emit(
|
|
313
|
+
EVENT_CONNECTED,
|
|
314
|
+
{
|
|
315
|
+
"socket_id": self.socket.id,
|
|
316
|
+
"user_id": user_id,
|
|
317
|
+
"namespace": self.meta.path,
|
|
318
|
+
"heartbeat_seconds": self.config.heartbeat_seconds,
|
|
319
|
+
},
|
|
320
|
+
)
|
|
321
|
+
heartbeat = asyncio.create_task(self._heartbeat(), name=f"ws-hb-{self.socket.id}")
|
|
322
|
+
code = await self._receive_loop()
|
|
323
|
+
finally:
|
|
324
|
+
if heartbeat is not None:
|
|
325
|
+
heartbeat.cancel()
|
|
326
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
327
|
+
await heartbeat
|
|
328
|
+
await self._teardown(code)
|
|
329
|
+
|
|
330
|
+
async def _authenticate(self) -> tuple[str | None, frozenset[str]] | None:
|
|
331
|
+
"""Chạy guard trong một request scope, lấy danh tính ra khỏi Principal.
|
|
332
|
+
|
|
333
|
+
Danh tính được xác lập ĐÚNG MỘT LẦN lúc bắt tay rồi lưu trên Socket.
|
|
334
|
+
Chạy lại guard cho từng tin sẽ đắt vô ích, mà cũng vô nghĩa: kết nối
|
|
335
|
+
đã mở thì bên kia vẫn là bên kia.
|
|
336
|
+
"""
|
|
337
|
+
if not self.meta.guards:
|
|
338
|
+
return None, frozenset()
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
async with request_scope():
|
|
342
|
+
for guard_cls in self.meta.guards:
|
|
343
|
+
await container.resolve(guard_cls).check(self.ws)
|
|
344
|
+
principal = container.resolve(Principal)
|
|
345
|
+
return principal.id, principal.roles
|
|
346
|
+
except AppError as exc:
|
|
347
|
+
await self._reject(exc)
|
|
348
|
+
return None
|
|
349
|
+
except Exception as exc:
|
|
350
|
+
# Guard lỗi bất ngờ vẫn phải đóng kết nối gọn ghẽ, không để treo.
|
|
351
|
+
log.exception("ws.guard_failed", namespace=self.meta.path, error=str(exc))
|
|
352
|
+
await self._reject(exc)
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
async def _reject(self, exc: BaseException) -> None:
|
|
356
|
+
"""Gửi khung lỗi rồi mới đóng, để client biết vì sao."""
|
|
357
|
+
with contextlib.suppress(RuntimeError, ConnectionError):
|
|
358
|
+
await self.ws.send_text(error_frame(exc, debug=self.debug).to_json())
|
|
359
|
+
await self.ws.close(close_code_for(exc), getattr(exc, "message", "")[:120])
|
|
360
|
+
log.info(
|
|
361
|
+
"ws.rejected",
|
|
362
|
+
namespace=self.meta.path,
|
|
363
|
+
reason=getattr(exc, "error_code", type(exc).__name__),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
async def _within_user_quota(self, user_id: str | None) -> bool:
|
|
367
|
+
limit = self.config.max_connections_per_user
|
|
368
|
+
if not user_id or limit <= 0:
|
|
369
|
+
return True
|
|
370
|
+
if len(self.namespace.sockets_of(user_id)) < limit:
|
|
371
|
+
return True
|
|
372
|
+
await self._reject(
|
|
373
|
+
TooManyRequestsError(f"Vượt quá {limit} kết nối đồng thời cho một tài khoản")
|
|
374
|
+
)
|
|
375
|
+
return False
|
|
376
|
+
|
|
377
|
+
async def _call_on_connect(self) -> bool:
|
|
378
|
+
hook = _optional_hook(self.cls, "on_connect")
|
|
379
|
+
if hook is None:
|
|
380
|
+
return True
|
|
381
|
+
assert self.socket is not None
|
|
382
|
+
try:
|
|
383
|
+
async with self._message_scope():
|
|
384
|
+
await hook(container.resolve(self.cls), self.socket)
|
|
385
|
+
except AppError as exc:
|
|
386
|
+
await self.socket.close(close_code_for(exc), exc.message[:120])
|
|
387
|
+
return False
|
|
388
|
+
except Exception as exc:
|
|
389
|
+
# Lỗi trong on_connect không được lộ traceback ra client.
|
|
390
|
+
log.exception("ws.on_connect_failed", namespace=self.meta.path, error=str(exc))
|
|
391
|
+
await self.socket.close(CloseCode.INTERNAL, "lỗi khi mở kết nối")
|
|
392
|
+
return False
|
|
393
|
+
return True
|
|
394
|
+
|
|
395
|
+
# -------------------------------------------------------------- vòng nhận
|
|
396
|
+
async def _receive_loop(self) -> int:
|
|
397
|
+
assert self.socket is not None
|
|
398
|
+
idle = self.config.idle_timeout_seconds
|
|
399
|
+
|
|
400
|
+
while True:
|
|
401
|
+
try:
|
|
402
|
+
message = await asyncio.wait_for(self.ws.receive(), timeout=idle)
|
|
403
|
+
except TimeoutErrors:
|
|
404
|
+
# Không nhận được gì kể cả pong: kết nối đã chết mà TCP chưa
|
|
405
|
+
# biết (half-open). Không dọn thì socket "ma" tích lại mãi.
|
|
406
|
+
log.info("ws.idle_timeout", socket_id=self.socket.id, seconds=idle)
|
|
407
|
+
await self.socket.close(CloseCode.IDLE_TIMEOUT, "im lặng quá lâu")
|
|
408
|
+
return CloseCode.IDLE_TIMEOUT
|
|
409
|
+
except (WebSocketDisconnect, RuntimeError):
|
|
410
|
+
return CloseCode.NORMAL
|
|
411
|
+
|
|
412
|
+
if message["type"] == "websocket.disconnect":
|
|
413
|
+
return int(message.get("code", CloseCode.NORMAL))
|
|
414
|
+
|
|
415
|
+
raw = message.get("text")
|
|
416
|
+
if raw is None:
|
|
417
|
+
self.socket.send(
|
|
418
|
+
error_frame(ProtocolError("Chỉ nhận khung dạng text JSON"), debug=self.debug)
|
|
419
|
+
)
|
|
420
|
+
continue
|
|
421
|
+
|
|
422
|
+
if len(raw) > self.config.max_message_bytes:
|
|
423
|
+
self.socket.send(
|
|
424
|
+
error_frame(
|
|
425
|
+
ProtocolError(
|
|
426
|
+
f"Khung tin dài {len(raw)} byte, vượt ngưỡng "
|
|
427
|
+
f"{self.config.max_message_bytes}"
|
|
428
|
+
),
|
|
429
|
+
debug=self.debug,
|
|
430
|
+
)
|
|
431
|
+
)
|
|
432
|
+
continue
|
|
433
|
+
|
|
434
|
+
if not self._take_token():
|
|
435
|
+
self.socket.send(
|
|
436
|
+
error_frame(
|
|
437
|
+
TooManyRequestsError(
|
|
438
|
+
f"Gửi quá nhanh (trần {self.config.max_messages_per_second}/giây)"
|
|
439
|
+
),
|
|
440
|
+
debug=self.debug,
|
|
441
|
+
)
|
|
442
|
+
)
|
|
443
|
+
continue
|
|
444
|
+
|
|
445
|
+
if self.socket.closing:
|
|
446
|
+
return CloseCode.NORMAL
|
|
447
|
+
|
|
448
|
+
await self._handle_raw(raw)
|
|
449
|
+
|
|
450
|
+
def _take_token(self) -> bool:
|
|
451
|
+
"""Gáo token: cho phép bùng ngắn hạn, nhưng chặn client gửi tràn.
|
|
452
|
+
|
|
453
|
+
Không có nó thì một client (hoặc một vòng lặp viết sai ở phía client)
|
|
454
|
+
đủ sức làm nghẽn event loop của cả worker.
|
|
455
|
+
"""
|
|
456
|
+
rate = self.config.max_messages_per_second
|
|
457
|
+
if rate <= 0:
|
|
458
|
+
return True
|
|
459
|
+
now = time.monotonic()
|
|
460
|
+
self._tokens = min(
|
|
461
|
+
float(self.config.burst_messages), self._tokens + (now - self._refilled) * rate
|
|
462
|
+
)
|
|
463
|
+
self._refilled = now
|
|
464
|
+
if self._tokens < 1.0:
|
|
465
|
+
return False
|
|
466
|
+
self._tokens -= 1.0
|
|
467
|
+
return True
|
|
468
|
+
|
|
469
|
+
async def _handle_raw(self, raw: str) -> None:
|
|
470
|
+
assert self.socket is not None
|
|
471
|
+
try:
|
|
472
|
+
frame = parse_frame(raw)
|
|
473
|
+
except ProtocolError as exc:
|
|
474
|
+
self.socket.send(error_frame(exc, debug=self.debug))
|
|
475
|
+
return
|
|
476
|
+
|
|
477
|
+
ws_messages_in.inc(namespace=self.meta.path, event=frame.event)
|
|
478
|
+
|
|
479
|
+
try:
|
|
480
|
+
async with self._message_scope():
|
|
481
|
+
await self._dispatch(frame)
|
|
482
|
+
except AppError as exc:
|
|
483
|
+
self.socket.send(error_frame(exc, ack=frame.id, debug=self.debug))
|
|
484
|
+
except NotImplementedError as exc:
|
|
485
|
+
# Khung do `make gateway` sinh ra raise NotImplementedError. Trả mã
|
|
486
|
+
# riêng "chưa viết" thay vì internal_error, giống 501 bên HTTP.
|
|
487
|
+
self.socket.send(
|
|
488
|
+
error_frame(
|
|
489
|
+
NotImplementedYetError(str(exc) or None), ack=frame.id, debug=self.debug
|
|
490
|
+
)
|
|
491
|
+
)
|
|
492
|
+
except Exception as exc:
|
|
493
|
+
# Một tin lỗi không được giết cả kết nối: client vẫn dùng tiếp được.
|
|
494
|
+
log.exception(
|
|
495
|
+
"ws.handler_failed",
|
|
496
|
+
namespace=self.meta.path,
|
|
497
|
+
# KHÔNG đặt tên khoá là `event`: structlog đã dùng tên đó cho
|
|
498
|
+
# chính dòng log, truyền vào sẽ ném TypeError ngay trong tay
|
|
499
|
+
# xử lý lỗi — che mất lỗi thật.
|
|
500
|
+
ws_event=frame.event,
|
|
501
|
+
socket_id=self.socket.id,
|
|
502
|
+
error=str(exc),
|
|
503
|
+
)
|
|
504
|
+
self.socket.send(error_frame(exc, ack=frame.id, debug=self.debug))
|
|
505
|
+
|
|
506
|
+
def _message_scope(self):
|
|
507
|
+
"""Mỗi tin nhắn là một "request" thu nhỏ: có request_id riêng, có
|
|
508
|
+
request scope riêng, và Principal đã được điền sẵn.
|
|
509
|
+
|
|
510
|
+
Nhờ vậy service dùng provider request-scoped (transaction database)
|
|
511
|
+
chạy y hệt như khi được gọi từ HTTP, và mỗi tin commit gọn một lần.
|
|
512
|
+
"""
|
|
513
|
+
socket = self.socket
|
|
514
|
+
assert socket is not None
|
|
515
|
+
|
|
516
|
+
class _Scope:
|
|
517
|
+
async def __aenter__(_self) -> None:
|
|
518
|
+
_self._request_token = set_request_id(new_request_id())
|
|
519
|
+
_self._user_token = set_user_id(socket.user_id)
|
|
520
|
+
_self._scope = request_scope()
|
|
521
|
+
await _self._scope.__aenter__()
|
|
522
|
+
if socket.user_id:
|
|
523
|
+
container.resolve(Principal).assume(id=socket.user_id, roles=socket.roles)
|
|
524
|
+
|
|
525
|
+
async def __aexit__(_self, *exc: Any) -> bool:
|
|
526
|
+
try:
|
|
527
|
+
return await _self._scope.__aexit__(*exc)
|
|
528
|
+
finally:
|
|
529
|
+
reset_user_id(_self._user_token)
|
|
530
|
+
reset_request_id(_self._request_token)
|
|
531
|
+
|
|
532
|
+
return _Scope()
|
|
533
|
+
|
|
534
|
+
# ------------------------------------------------------------ điều phối
|
|
535
|
+
async def _dispatch(self, frame: Frame) -> None:
|
|
536
|
+
assert self.socket is not None
|
|
537
|
+
|
|
538
|
+
handler = self.handlers.get(frame.event)
|
|
539
|
+
if handler is not None:
|
|
540
|
+
result = await self._invoke(handler, frame)
|
|
541
|
+
# Trả về giá trị khác None và client có gửi `id` thì tự động gửi
|
|
542
|
+
# ack — không phải viết emit thủ công trong từng handler.
|
|
543
|
+
if result is not None and frame.id is not None:
|
|
544
|
+
self.socket.send(Frame(event=frame.event, data=result, ack=frame.id))
|
|
545
|
+
return
|
|
546
|
+
|
|
547
|
+
if frame.event == EVENT_PING:
|
|
548
|
+
self.socket.send(Frame(event=EVENT_PONG, data=frame.data, ack=frame.id))
|
|
549
|
+
return
|
|
550
|
+
|
|
551
|
+
if frame.event == EVENT_PONG:
|
|
552
|
+
# Client trả lời ping của server. Không cần làm gì: chỉ riêng việc
|
|
553
|
+
# nhận được khung này đã làm mới đồng hồ idle. Phải bắt ở đây, nếu
|
|
554
|
+
# không client làm đúng lại nhận về lỗi "unknown_event".
|
|
555
|
+
return
|
|
556
|
+
|
|
557
|
+
if frame.event in (EVENT_ROOM_JOIN, EVENT_ROOM_LEAVE):
|
|
558
|
+
await self._handle_room(frame)
|
|
559
|
+
return
|
|
560
|
+
|
|
561
|
+
known = [*self.handlers, EVENT_PING]
|
|
562
|
+
if self.meta.client_rooms:
|
|
563
|
+
known += [EVENT_ROOM_JOIN, EVENT_ROOM_LEAVE]
|
|
564
|
+
raise BadRequestError(
|
|
565
|
+
f"Không có handler cho sự kiện '{frame.event}'",
|
|
566
|
+
error_code="unknown_event",
|
|
567
|
+
details={"known": sorted(known)},
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
async def _invoke(self, handler: _Handler, frame: Frame) -> Any:
|
|
571
|
+
assert self.socket is not None
|
|
572
|
+
instance = container.resolve(self.cls)
|
|
573
|
+
|
|
574
|
+
if not handler.wants_payload:
|
|
575
|
+
return await handler.fn(instance, self.socket)
|
|
576
|
+
|
|
577
|
+
payload: Any = frame.data
|
|
578
|
+
if handler.model is not None:
|
|
579
|
+
try:
|
|
580
|
+
payload = handler.model.model_validate(frame.data or {})
|
|
581
|
+
except ValidationError as exc:
|
|
582
|
+
# Cùng hình dạng lỗi validate với phía HTTP, để client dùng
|
|
583
|
+
# chung một nhánh xử lý.
|
|
584
|
+
raise BadRequestError(
|
|
585
|
+
"Dữ liệu không hợp lệ",
|
|
586
|
+
error_code="validation_error",
|
|
587
|
+
details=[
|
|
588
|
+
{
|
|
589
|
+
"field": ".".join(str(p) for p in err["loc"]),
|
|
590
|
+
"message": err["msg"],
|
|
591
|
+
"type": err["type"],
|
|
592
|
+
}
|
|
593
|
+
for err in exc.errors()
|
|
594
|
+
],
|
|
595
|
+
) from exc
|
|
596
|
+
return await handler.fn(instance, self.socket, payload)
|
|
597
|
+
|
|
598
|
+
async def _handle_room(self, frame: Frame) -> None:
|
|
599
|
+
assert self.socket is not None
|
|
600
|
+
if not self.meta.client_rooms:
|
|
601
|
+
raise ForbiddenError(
|
|
602
|
+
"Gateway này không cho client tự vào phòng. Bật bằng "
|
|
603
|
+
"@gateway(..., client_rooms=True) hoặc viết handler riêng."
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
room = (frame.data or {}).get("room") if isinstance(frame.data, dict) else None
|
|
607
|
+
if not isinstance(room, str) or not room.strip():
|
|
608
|
+
raise BadRequestError("Thiếu 'room' trong data")
|
|
609
|
+
room = room.strip()
|
|
610
|
+
if len(room) > MAX_ROOM_NAME:
|
|
611
|
+
raise BadRequestError(f"Tên phòng dài quá {MAX_ROOM_NAME} ký tự")
|
|
612
|
+
|
|
613
|
+
if frame.event == EVENT_ROOM_LEAVE:
|
|
614
|
+
self.socket.leave(room)
|
|
615
|
+
self.socket.send(
|
|
616
|
+
Frame(EVENT_ROOM_LEAVE, {"room": room, "rooms": sorted(self.socket.rooms)}, ack=frame.id)
|
|
617
|
+
)
|
|
618
|
+
return
|
|
619
|
+
|
|
620
|
+
if len(self.socket.rooms) >= self.config.max_rooms_per_socket:
|
|
621
|
+
raise TooManyRequestsError(
|
|
622
|
+
f"Một kết nối chỉ vào tối đa {self.config.max_rooms_per_socket} phòng"
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
can_join = _optional_hook(self.cls, "can_join")
|
|
626
|
+
if can_join is not None:
|
|
627
|
+
allowed = can_join(container.resolve(self.cls), self.socket, room)
|
|
628
|
+
if inspect.isawaitable(allowed):
|
|
629
|
+
allowed = await allowed
|
|
630
|
+
if not allowed:
|
|
631
|
+
raise ForbiddenError(f"Không được vào phòng '{room}'")
|
|
632
|
+
|
|
633
|
+
self.socket.join(room)
|
|
634
|
+
log.debug("ws.room_joined", socket_id=self.socket.id, room=room)
|
|
635
|
+
self.socket.send(
|
|
636
|
+
Frame(
|
|
637
|
+
EVENT_ROOM_JOIN,
|
|
638
|
+
{"room": room, "size": self.namespace.room_size(room), "rooms": sorted(self.socket.rooms)},
|
|
639
|
+
ack=frame.id,
|
|
640
|
+
)
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
# ------------------------------------------------------------------ dọn
|
|
644
|
+
async def _teardown(self, code: int) -> None:
|
|
645
|
+
socket = self.socket
|
|
646
|
+
if socket is None:
|
|
647
|
+
return
|
|
648
|
+
|
|
649
|
+
hook = _optional_hook(self.cls, "on_disconnect")
|
|
650
|
+
if hook is not None:
|
|
651
|
+
try:
|
|
652
|
+
async with self._message_scope():
|
|
653
|
+
if len(inspect.signature(hook).parameters) >= 3:
|
|
654
|
+
await hook(container.resolve(self.cls), socket, code)
|
|
655
|
+
else:
|
|
656
|
+
await hook(container.resolve(self.cls), socket)
|
|
657
|
+
except Exception as exc:
|
|
658
|
+
# Hook lỗi cũng không được cản việc gỡ socket khỏi sổ bên dưới.
|
|
659
|
+
log.exception("ws.on_disconnect_failed", socket_id=socket.id, error=str(exc))
|
|
660
|
+
|
|
661
|
+
# Chạy phần dọn dẹp mà các lớp NGOÀI đã gắn vào kết nối này (ví dụ lớp
|
|
662
|
+
# nhắn tin gỡ đăng ký sự kiện). Lõi không biết đó là những việc gì.
|
|
663
|
+
await socket.run_close_hooks()
|
|
664
|
+
|
|
665
|
+
self.namespace.remove(socket)
|
|
666
|
+
await socket.stop_writer()
|
|
667
|
+
with contextlib.suppress(RuntimeError, ConnectionError):
|
|
668
|
+
await socket.close(code if code >= 4000 else CloseCode.NORMAL)
|
|
669
|
+
ws_connections.inc_gauge(-1, namespace=self.meta.path)
|
|
670
|
+
|
|
671
|
+
log.info(
|
|
672
|
+
"ws.disconnected",
|
|
673
|
+
namespace=self.meta.path,
|
|
674
|
+
socket_id=socket.id,
|
|
675
|
+
user_id=socket.user_id,
|
|
676
|
+
code=code,
|
|
677
|
+
seconds=round(time.time() - socket.connected_at, 1),
|
|
678
|
+
)
|
|
679
|
+
self.socket = None
|
|
680
|
+
|
|
681
|
+
async def _heartbeat(self) -> None:
|
|
682
|
+
"""Đều đặn đẩy `ping` để (a) giữ kết nối qua proxy hay cắt phiên nhàn
|
|
683
|
+
rỗi, (b) buộc client trả lời, nhờ đó phát hiện được client đã chết."""
|
|
684
|
+
assert self.socket is not None
|
|
685
|
+
interval = self.config.heartbeat_seconds
|
|
686
|
+
if interval <= 0:
|
|
687
|
+
return
|
|
688
|
+
while not self.socket.closing:
|
|
689
|
+
await asyncio.sleep(interval)
|
|
690
|
+
if not self.socket.emit(EVENT_PING, {"t": round(time.time(), 3)}):
|
|
691
|
+
return
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
# ------------------------------------------------------------------- dựng route
|
|
695
|
+
def _ws_config() -> tuple[WebSocketSettings, bool]:
|
|
696
|
+
try:
|
|
697
|
+
settings = container.resolve(Settings)
|
|
698
|
+
except RuntimeError:
|
|
699
|
+
settings = get_settings()
|
|
700
|
+
return settings.ws, settings.debug
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def build_ws_router(*gateways: type) -> APIRouter:
|
|
704
|
+
"""Dựng APIRouter chứa route WebSocket của các gateway đã cho."""
|
|
705
|
+
router = APIRouter()
|
|
706
|
+
|
|
707
|
+
for cls in gateways:
|
|
708
|
+
meta: GatewayMeta | None = getattr(cls, _GATEWAY_ATTR, None)
|
|
709
|
+
if meta is None:
|
|
710
|
+
raise RuntimeError(f"{cls.__name__} thiếu @gateway(...)")
|
|
711
|
+
|
|
712
|
+
handlers = _collect_handlers(cls)
|
|
713
|
+
if not handlers and _optional_hook(cls, "on_connect") is None:
|
|
714
|
+
log.warning(
|
|
715
|
+
"gateway.no_handlers",
|
|
716
|
+
gateway=cls.__name__,
|
|
717
|
+
hint="thiếu @subscribe trên method?",
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
def endpoint_factory(cls: type = cls, meta: GatewayMeta = meta, handlers: dict[str, _Handler] = handlers):
|
|
721
|
+
async def endpoint(websocket: WebSocket) -> None:
|
|
722
|
+
config, debug = _ws_config()
|
|
723
|
+
await _Connection(cls, meta, handlers, websocket, config, debug=debug).run()
|
|
724
|
+
|
|
725
|
+
return endpoint
|
|
726
|
+
|
|
727
|
+
router.add_api_websocket_route(meta.path, endpoint_factory(), name=meta.name)
|
|
728
|
+
log.debug(
|
|
729
|
+
"gateway.registered",
|
|
730
|
+
gateway=cls.__name__,
|
|
731
|
+
path=meta.path,
|
|
732
|
+
events=sorted(handlers),
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
return router
|