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.
Files changed (69) hide show
  1. fastapi_modular-0.1.0.dist-info/METADATA +377 -0
  2. fastapi_modular-0.1.0.dist-info/RECORD +69 -0
  3. fastapi_modular-0.1.0.dist-info/WHEEL +4 -0
  4. fastapi_modular-0.1.0.dist-info/entry_points.txt +3 -0
  5. fastapi_modular-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. pymodular/__init__.py +74 -0
  7. pymodular/cli/__init__.py +0 -0
  8. pymodular/cli/clean.py +39 -0
  9. pymodular/cli/configure_env.py +569 -0
  10. pymodular/cli/cong_cu.py +111 -0
  11. pymodular/cli/info.py +62 -0
  12. pymodular/cli/install.py +83 -0
  13. pymodular/cli/main.py +247 -0
  14. pymodular/cli/new_module.py +492 -0
  15. pymodular/cli/new_project.py +471 -0
  16. pymodular/cli/serve.py +59 -0
  17. pymodular/core/__init__.py +0 -0
  18. pymodular/core/clock.py +15 -0
  19. pymodular/core/compat.py +39 -0
  20. pymodular/core/config.py +495 -0
  21. pymodular/core/container.py +354 -0
  22. pymodular/core/context.py +78 -0
  23. pymodular/core/controller.py +208 -0
  24. pymodular/core/error_handlers.py +272 -0
  25. pymodular/core/exceptions.py +104 -0
  26. pymodular/core/guards.py +117 -0
  27. pymodular/core/lifespan.py +150 -0
  28. pymodular/core/logging.py +88 -0
  29. pymodular/core/metrics.py +190 -0
  30. pymodular/core/schemas.py +105 -0
  31. pymodular/core/websocket/__init__.py +31 -0
  32. pymodular/core/websocket/adapter.py +192 -0
  33. pymodular/core/websocket/gateway.py +735 -0
  34. pymodular/core/websocket/namespace.py +148 -0
  35. pymodular/core/websocket/protocol.py +157 -0
  36. pymodular/core/websocket/server.py +175 -0
  37. pymodular/core/websocket/socket.py +241 -0
  38. pymodular/discovery.py +180 -0
  39. pymodular/factory.py +126 -0
  40. pymodular/infrastructure/__init__.py +1 -0
  41. pymodular/infrastructure/database/__init__.py +8 -0
  42. pymodular/infrastructure/database/base.py +228 -0
  43. pymodular/infrastructure/database/circuit.py +207 -0
  44. pymodular/infrastructure/database/factory.py +88 -0
  45. pymodular/infrastructure/database/memory.py +112 -0
  46. pymodular/infrastructure/database/mongo.py +186 -0
  47. pymodular/infrastructure/database/repository.py +188 -0
  48. pymodular/infrastructure/database/sql.py +520 -0
  49. pymodular/infrastructure/kafka/__init__.py +26 -0
  50. pymodular/infrastructure/kafka/broker.py +231 -0
  51. pymodular/infrastructure/kafka/consumers.py +371 -0
  52. pymodular/infrastructure/kafka/metrics.py +17 -0
  53. pymodular/infrastructure/mqtt/__init__.py +35 -0
  54. pymodular/infrastructure/mqtt/client.py +292 -0
  55. pymodular/infrastructure/mqtt/consumers.py +219 -0
  56. pymodular/infrastructure/mqtt/metrics.py +17 -0
  57. pymodular/infrastructure/mqtt/patterns.py +116 -0
  58. pymodular/infrastructure/rabbitmq/__init__.py +33 -0
  59. pymodular/infrastructure/rabbitmq/broker.py +616 -0
  60. pymodular/infrastructure/rabbitmq/consumers.py +450 -0
  61. pymodular/infrastructure/rabbitmq/metrics.py +34 -0
  62. pymodular/infrastructure/rabbitmq/patterns.py +64 -0
  63. pymodular/infrastructure/redis/__init__.py +31 -0
  64. pymodular/infrastructure/redis/client.py +362 -0
  65. pymodular/infrastructure/redis/metrics.py +20 -0
  66. pymodular/infrastructure/redis/pubsub.py +262 -0
  67. pymodular/middleware/__init__.py +0 -0
  68. pymodular/middleware/request_context.py +164 -0
  69. 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