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,292 @@
1
+ """Kết nối MQTT: giữ một phiên lâu dài, gửi tin, và nuôi vòng đọc tin về.
2
+
3
+ MQTT là TUỲ CHỌN. `APP_MQTT__ENABLED` mặc định false thì lớp này nằm im.
4
+
5
+ pip install 'fastapi-modular[mqtt]' # cài aiomqtt + ghi sẵn APP_MQTT__* vào .env
6
+
7
+ Về tự nối lại: aiomqtt KHÔNG tự nối lại (khác aio-pika). Nó cố ý để việc đó cho
8
+ người dùng, nên lớp này chạy một vòng
9
+ `connect -> subscribe -> đọc -> đứt -> chờ -> lặp lại` với backoff. Mọi đăng ký
10
+ topic được khai lại sau mỗi lần nối, vì broker chỉ nhớ chúng khi phiên là
11
+ persistent (`clean_session=false` + `client_id` cố định).
12
+
13
+ Gửi tin trong lúc đang đứt thì ném `ServiceUnavailableError`, trừ khi gọi với
14
+ `fire_and_forget=True`. Không có hàng đợi chờ gửi ở phía client: giữ tin trong
15
+ RAM rồi hứa gửi sau là một lời hứa mà tiến trình chết là mất — muốn bảo đảm thì
16
+ đó là việc của QoS 1/2 với phiên persistent, hoặc của RabbitMQ/Kafka.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import contextlib
23
+ import json
24
+ import uuid
25
+ from collections.abc import Awaitable, Callable
26
+ from typing import Any
27
+ from urllib.parse import urlparse
28
+
29
+ from pymodular.core.compat import TimeoutErrors
30
+ from pymodular.core.config import Settings
31
+ from pymodular.core.container import injectable
32
+ from pymodular.core.exceptions import ComponentNotEnabledError, ServiceUnavailableError
33
+ from pymodular.core.logging import get_logger
34
+ from pymodular.infrastructure.mqtt.metrics import mqtt_publish_failed, mqtt_published
35
+ from pymodular.infrastructure.mqtt.patterns import narrow_filters, validate_topic
36
+
37
+ log = get_logger(__name__)
38
+
39
+ DEFAULT_URL = "mqtt://localhost:1883"
40
+
41
+
42
+ def _require_aiomqtt() -> Any:
43
+ try:
44
+ import aiomqtt
45
+ except ModuleNotFoundError as exc:
46
+ raise ComponentNotEnabledError(
47
+ "APP_MQTT__ENABLED=true nhưng chưa cài thư viện aiomqtt. "
48
+ "Chạy `pip install 'fastapi-modular[mqtt]'`, hoặc đặt APP_MQTT__ENABLED=false nếu "
49
+ "dự án này không dùng MQTT."
50
+ ) from exc
51
+ return aiomqtt
52
+
53
+
54
+ def parse_url(url: str) -> dict[str, Any]:
55
+ """Tách mqtt://user:pass@host:port thành tham số cho aiomqtt."""
56
+ parsed = urlparse(url)
57
+ if parsed.scheme not in ("mqtt", "mqtts"):
58
+ raise ValueError(f"APP_MQTT__URL phải bắt đầu bằng mqtt:// hoặc mqtts://, nhận {url!r}")
59
+ return {
60
+ "hostname": parsed.hostname or "localhost",
61
+ "port": parsed.port or (8883 if parsed.scheme == "mqtts" else 1883),
62
+ "username": parsed.username or None,
63
+ "password": parsed.password or None,
64
+ "tls": parsed.scheme == "mqtts",
65
+ }
66
+
67
+
68
+ def safe_url(url: str) -> str:
69
+ parsed = urlparse(url)
70
+ if not parsed.password:
71
+ return url
72
+ cong = f":{parsed.port}" if parsed.port else ""
73
+ return f"{parsed.scheme}://{parsed.username or ''}:***@{parsed.hostname}{cong}"
74
+
75
+
76
+ @injectable
77
+ class MqttClient:
78
+ def __init__(self, settings: Settings) -> None:
79
+ self._config = settings.mqtt
80
+ self._client: Any = None
81
+ self._task: asyncio.Task[None] | None = None
82
+ self._closing = False
83
+ self._connected = asyncio.Event()
84
+ self._dang_ky: dict[str, int] = {} # topic -> qos
85
+ self._router: Callable[[Any], Awaitable[None]] | None = None
86
+ self._lan_dut = 0
87
+
88
+ # ------------------------------------------------------------- vòng đời
89
+ @property
90
+ def enabled(self) -> bool:
91
+ return self._config.enabled
92
+
93
+ @property
94
+ def connected(self) -> bool:
95
+ return self._connected.is_set()
96
+
97
+ @property
98
+ def url(self) -> str:
99
+ return safe_url(self._config.url)
100
+
101
+ @property
102
+ def client_id(self) -> str:
103
+ return self._config.client_id or f"pymodular-{uuid.uuid4().hex[:8]}"
104
+
105
+ def subscribe_topic(self, topic: str, qos: int = 1) -> None:
106
+ """Ghi nhận một topic cần đăng ký. Gọi TRƯỚC startup().
107
+
108
+ Giữ mức QoS cao nhất khi hai handler cùng nghe một topic: đăng ký ở
109
+ QoS 0 rồi lại đòi QoS 1 thì broker giao theo mức đã đăng ký, tin sẽ
110
+ lặng lẽ mất khi mạng chớp.
111
+ """
112
+ self._dang_ky[topic] = max(self._dang_ky.get(topic, 0), qos)
113
+
114
+ def _de_dang_ky(self) -> dict[str, int]:
115
+ """Danh sách thật sự gửi lên broker: đã bỏ bộ lọc bị bộ lọc khác bao trọn.
116
+
117
+ Đăng ký chồng nhau ("thiet-bi/#" và "thiet-bi/+/nhiet-do") thì broker
118
+ giao MỘT tin thành HAI lần — mỗi đăng ký một bản — và mọi handler khớp
119
+ sẽ chạy hai lượt. Đo trên mosquitto: gửi 1 tin, handler chạy 4 lượt.
120
+ Đăng ký cái rộng nhất rồi tự chia tin trong tiến trình thì đúng một lượt.
121
+ """
122
+ gon = narrow_filters(self._dang_ky)
123
+ if bo := sorted(set(self._dang_ky) - set(gon)):
124
+ log.debug("mqtt.subscription_gop", bo_qua=bo, giu=sorted(gon))
125
+ return gon
126
+
127
+ def set_router(self, router: Callable[[Any], Awaitable[None]]) -> None:
128
+ """Ai nhận tin về: `MqttRunner` cắm vào đây."""
129
+ self._router = router
130
+
131
+ async def startup(self) -> None:
132
+ if not self._config.enabled:
133
+ log.debug("mqtt.disabled")
134
+ return
135
+
136
+ _require_aiomqtt()
137
+ parse_url(self._config.url) # sai dạng thì báo ngay, đừng đợi vòng nối lại
138
+ if self._config.url == DEFAULT_URL:
139
+ log.info("mqtt.default_url", url=DEFAULT_URL, hint="chưa đặt APP_MQTT__URL?")
140
+ if not self._config.clean_session and not self._config.client_id:
141
+ log.warning(
142
+ "mqtt.session_khong_ben",
143
+ hint="clean_session=false cần APP_MQTT__CLIENT_ID cố định, "
144
+ "không thì mỗi lần khởi động là một phiên mới và tin giữ lại bị bỏ",
145
+ )
146
+
147
+ self._closing = False
148
+ self._task = asyncio.create_task(self._vong_ket_noi(), name="mqtt-connection")
149
+
150
+ # Chờ một nhịp cho lần nối đầu, để log khởi động nói đúng trạng thái.
151
+ with contextlib.suppress(*TimeoutErrors):
152
+ await asyncio.wait_for(
153
+ self._connected.wait(), self._config.connect_timeout_seconds
154
+ )
155
+ if not self.connected:
156
+ log.warning(
157
+ "mqtt.starting_degraded",
158
+ url=self.url,
159
+ hint="app vẫn chạy; sẽ nối lại ngầm cho tới khi được",
160
+ )
161
+
162
+ async def _vong_ket_noi(self) -> None:
163
+ """Nối, đăng ký, đọc tới khi đứt, chờ rồi làm lại. Không bao giờ bỏ cuộc."""
164
+ aiomqtt = _require_aiomqtt()
165
+ tham_so = parse_url(self._config.url)
166
+ tls = tham_so.pop("tls")
167
+ delay = self._config.reconnect_delay_seconds
168
+ ma_so = self.client_id
169
+
170
+ while not self._closing:
171
+ try:
172
+ async with aiomqtt.Client(
173
+ **tham_so,
174
+ identifier=ma_so,
175
+ keepalive=self._config.keepalive_seconds,
176
+ clean_session=self._config.clean_session,
177
+ tls_context=_tls_context() if tls else None,
178
+ ) as client:
179
+ self._client = client
180
+ dang_ky = self._de_dang_ky()
181
+ for topic, qos in sorted(dang_ky.items()):
182
+ await client.subscribe(topic, qos=qos)
183
+ self._connected.set()
184
+ delay = self._config.reconnect_delay_seconds
185
+ log.info(
186
+ "mqtt.connected",
187
+ url=self.url,
188
+ client_id=ma_so,
189
+ topics=sorted(dang_ky),
190
+ )
191
+ async for message in client.messages:
192
+ if self._router is not None:
193
+ await self._router(message)
194
+ except asyncio.CancelledError:
195
+ raise
196
+ except Exception as exc: # noqa: BLE001 - đứt kiểu gì cũng nối lại
197
+ if self._closing:
198
+ return
199
+ self._lan_dut += 1
200
+ log.warning(
201
+ "mqtt.connection_lost",
202
+ url=self.url,
203
+ error=f"{type(exc).__name__}: {exc}",
204
+ retry=delay,
205
+ )
206
+ finally:
207
+ self._connected.clear()
208
+ self._client = None
209
+
210
+ if self._closing:
211
+ return
212
+ await asyncio.sleep(delay)
213
+ delay = min(delay * 2, self._config.max_reconnect_delay_seconds)
214
+
215
+ async def shutdown(self) -> None:
216
+ self._closing = True
217
+ if self._task is not None:
218
+ self._task.cancel()
219
+ with contextlib.suppress(asyncio.CancelledError):
220
+ await self._task
221
+ self._task = None
222
+ self._connected.clear()
223
+ log.info("mqtt.disconnected")
224
+
225
+ # ---------------------------------------------------------------- gửi
226
+ async def publish(
227
+ self,
228
+ topic: str,
229
+ payload: Any = None,
230
+ *,
231
+ qos: int = 1,
232
+ retain: bool = False,
233
+ fire_and_forget: bool = False,
234
+ ) -> bool:
235
+ """Gửi một tin. Trả về True nếu broker đã nhận.
236
+
237
+ qos=0 gửi rồi thôi, không xác nhận — nhanh nhất, mất cũng không biết
238
+ qos=1 ít nhất một lần: có xác nhận, nhưng CÓ THỂ TRÙNG khi mạng chớp
239
+ qos=2 đúng một lần: chắc nhất, tốn hai vòng bắt tay, chậm nhất
240
+
241
+ `retain=True` bảo broker GIỮ tin này làm giá trị hiện tại của topic:
242
+ client nào đăng ký sau cũng nhận được ngay bản mới nhất mà không phải
243
+ chờ lần cập nhật kế tiếp. Đúng cho trạng thái (nhiệt độ, bật/tắt), sai
244
+ cho sự kiện (nút vừa được bấm) — người nối vào sau sẽ tưởng nút vừa mới
245
+ bấm lần nữa.
246
+ """
247
+ validate_topic(topic)
248
+ if not self._config.enabled:
249
+ raise ComponentNotEnabledError(
250
+ "MQTT đang tắt (APP_MQTT__ENABLED=false) nên không gửi được."
251
+ )
252
+
253
+ client = self._client
254
+ if client is None or not self.connected:
255
+ mqtt_publish_failed.inc(topic=topic)
256
+ if fire_and_forget:
257
+ log.warning("mqtt.publish_skipped", topic=topic, reason="chưa kết nối")
258
+ return False
259
+ raise ServiceUnavailableError("Chưa kết nối được MQTT broker")
260
+
261
+ body = payload if isinstance(payload, (bytes, str)) else json.dumps(
262
+ payload, ensure_ascii=False, default=str
263
+ )
264
+ try:
265
+ await client.publish(topic, payload=body, qos=qos, retain=retain)
266
+ except Exception as exc:
267
+ mqtt_publish_failed.inc(topic=topic)
268
+ if fire_and_forget:
269
+ log.warning("mqtt.publish_failed", topic=topic, error=str(exc))
270
+ return False
271
+ raise ServiceUnavailableError(f"Gửi MQTT thất bại: {exc}") from exc
272
+
273
+ mqtt_published.inc(topic=topic)
274
+ log.debug("mqtt.published", topic=topic, qos=qos, retain=retain)
275
+ return True
276
+
277
+ def stats(self) -> dict[str, Any]:
278
+ return {
279
+ "enabled": self._config.enabled,
280
+ "connected": self.connected,
281
+ "url": self.url if self._config.enabled else None,
282
+ "client_id": self._config.client_id or "(tự sinh)",
283
+ "topics": sorted(self._de_dang_ky()),
284
+ "listeners": sorted(self._dang_ky),
285
+ "disconnects": self._lan_dut,
286
+ }
287
+
288
+
289
+ def _tls_context() -> Any:
290
+ import ssl
291
+
292
+ return ssl.create_default_context()
@@ -0,0 +1,219 @@
1
+ """Nhận tin MQTT — tương đương `@rabbitmq_subscriber` của RabbitMQ, cho thiết bị IoT.
2
+
3
+ @injectable
4
+ class DenListener:
5
+ @mqtt_subscriber("nha/+/den", qos=1)
6
+ async def trang_thai(self, payload: dict, meta: dict) -> None:
7
+ print(meta["topic"], payload)
8
+
9
+ Khác RabbitMQ ở chỗ **không có hàng đợi thử lại và không có DLQ**. MQTT không
10
+ cho client từ chối một tin: nhận là xong, broker coi như đã giao. Nên khi
11
+ handler ném lỗi, khung ghi log và ĐI TIẾP — bịa ra vòng thử lại trong tiến
12
+ trình sẽ chặn mọi tin phía sau của cùng một kết nối.
13
+
14
+ Cần chắc chắn không mất việc thì đẩy sang chỗ có hàng đợi ngay trong handler:
15
+ nhận tin MQTT rồi `broker.publish(...)` vào RabbitMQ, xử lý nặng ở đó.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import inspect
21
+ import json
22
+ from collections.abc import Callable
23
+ from dataclasses import dataclass
24
+ from typing import Any, get_type_hints
25
+
26
+ from pydantic import BaseModel, ValidationError
27
+
28
+ from pymodular.core.config import Settings
29
+ from pymodular.core.container import _REGISTRY, container, injectable, request_scope
30
+ from pymodular.core.context import new_request_id, reset_request_id, set_request_id
31
+ from pymodular.core.logging import get_logger
32
+ from pymodular.infrastructure.mqtt.client import MqttClient
33
+ from pymodular.infrastructure.mqtt.metrics import (
34
+ mqtt_handler_failed,
35
+ mqtt_received,
36
+ mqtt_unrouted,
37
+ )
38
+ from pymodular.infrastructure.mqtt.patterns import matches, validate_topic_filter
39
+
40
+ log = get_logger(__name__)
41
+
42
+ _SPEC_ATTR = "__mqtt_subscriber__"
43
+
44
+
45
+ @dataclass(slots=True)
46
+ class MqttSpec:
47
+ topic: str
48
+ qos: int = 1
49
+ cls: type | None = None
50
+ fn: Callable | None = None
51
+ model: type[BaseModel] | None = None
52
+ wants_meta: bool = False
53
+
54
+ @property
55
+ def label(self) -> str:
56
+ return f"{self.cls.__name__}.{self.fn.__name__}" if self.cls and self.fn else self.topic
57
+
58
+
59
+ def mqtt_subscriber(topic: str, *, qos: int = 1) -> Callable[[Callable], Callable]:
60
+ """Gắn method vào một topic MQTT (có thể chứa `+` và `#`).
61
+
62
+ topic bộ lọc: "nha/bep/den" khớp đúng, "nha/+/den" một tầng bất kỳ,
63
+ "nha/#" mọi tầng phía dưới
64
+ qos mức bảo đảm khi broker GIAO tin cho mình:
65
+ 0 = có thì nhận, mất không biết (số đo dày đặc, mất vài cái
66
+ không sao)
67
+ 1 = ít nhất một lần — mặc định. Handler PHẢI chịu được tin
68
+ trùng, vì mạng chớp là broker giao lại.
69
+ 2 = đúng một lần, chậm hơn hẳn; chỉ dùng khi xử lý trùng thật
70
+ sự gây hại (trừ tiền, mở khoá)
71
+
72
+ Hai handler cùng một topic ở hai mức QoS khác nhau thì lấy mức CAO HƠN cho
73
+ lần đăng ký với broker.
74
+ """
75
+ validate_topic_filter(topic)
76
+ if qos not in (0, 1, 2):
77
+ raise ValueError(f"qos phải là 0, 1 hoặc 2 — nhận {qos!r}")
78
+
79
+ def decorate(fn: Callable) -> Callable:
80
+ if not inspect.iscoroutinefunction(fn):
81
+ raise RuntimeError(f"{fn.__name__} phải là `async def`")
82
+ setattr(fn, _SPEC_ATTR, MqttSpec(topic=topic, qos=qos))
83
+ return fn
84
+
85
+ return decorate
86
+
87
+
88
+ def discover_mqtt_subscribers() -> list[MqttSpec]:
89
+ """Quét mọi provider đã đăng ký để tìm method mang @mqtt_subscriber."""
90
+ found: list[MqttSpec] = []
91
+ for cls in _REGISTRY.values():
92
+ for fn in vars(cls).values():
93
+ spec: MqttSpec | None = getattr(fn, _SPEC_ATTR, None)
94
+ if spec is None:
95
+ continue
96
+
97
+ params = list(inspect.signature(fn).parameters.values())[1:]
98
+ if not params or len(params) > 2:
99
+ raise RuntimeError(
100
+ f"{cls.__name__}.{fn.__name__}: chữ ký phải là "
101
+ "(self, payload) hoặc (self, payload, meta)"
102
+ )
103
+
104
+ hints = get_type_hints(fn)
105
+ annotation = hints.get(params[0].name)
106
+ model = (
107
+ annotation
108
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel)
109
+ else None
110
+ )
111
+ found.append(
112
+ MqttSpec(
113
+ topic=spec.topic,
114
+ qos=spec.qos,
115
+ cls=cls,
116
+ fn=fn,
117
+ model=model,
118
+ wants_meta=len(params) == 2,
119
+ )
120
+ )
121
+ return sorted(found, key=lambda s: s.topic)
122
+
123
+
124
+ @injectable
125
+ class MqttRunner:
126
+ """Đăng ký topic với client và chia tin về đúng handler."""
127
+
128
+ def __init__(self, client: MqttClient, settings: Settings) -> None:
129
+ self._client = client
130
+ self._config = settings.mqtt
131
+ self._specs: list[MqttSpec] = []
132
+
133
+ async def startup(self) -> None:
134
+ if not self._config.enabled:
135
+ return
136
+ self._specs = discover_mqtt_subscribers()
137
+ if not self._specs:
138
+ return
139
+
140
+ # Đăng ký TRƯỚC khi client mở kết nối, để lần nối đầu tiên đã có đủ
141
+ # topic — và mọi lần nối lại sau đó cũng dùng đúng danh sách này.
142
+ for spec in self._specs:
143
+ self._client.subscribe_topic(spec.topic, spec.qos)
144
+ self._client.set_router(self._giao)
145
+ log.info(
146
+ "mqtt.listeners_registered",
147
+ count=len(self._specs),
148
+ topics=sorted({s.topic for s in self._specs}),
149
+ )
150
+
151
+ async def _giao(self, message: Any) -> None:
152
+ topic = str(message.topic)
153
+ mqtt_received.inc(topic=topic)
154
+
155
+ khop = [spec for spec in self._specs if matches(spec.topic, topic)]
156
+ if not khop:
157
+ # Broker giao một topic không ai nghe: gần như luôn là đăng ký thừa
158
+ # hoặc gõ nhầm bộ lọc. Im lặng ở đây thì không cách nào biết.
159
+ mqtt_unrouted.inc(topic=topic)
160
+ log.debug("mqtt.unrouted", topic=topic)
161
+ return
162
+
163
+ for spec in khop:
164
+ await self._chay(spec, topic, message)
165
+
166
+ async def _chay(self, spec: MqttSpec, topic: str, message: Any) -> None:
167
+ token = set_request_id(new_request_id())
168
+ try:
169
+ async with request_scope():
170
+ payload = _doc(message.payload)
171
+ if spec.model is not None:
172
+ try:
173
+ payload = spec.model.model_validate(payload)
174
+ except ValidationError as exc:
175
+ # Không có DLQ để đẩy vào — thiết bị gửi sai khuôn thì
176
+ # ghi log kèm topic để còn lần ra đúng con nào.
177
+ log.error(
178
+ "mqtt.payload_invalid",
179
+ handler=spec.label,
180
+ topic=topic,
181
+ error=str(exc),
182
+ )
183
+ return
184
+
185
+ instance = container.resolve(spec.cls) # type: ignore[arg-type]
186
+ if spec.wants_meta:
187
+ meta = {
188
+ "topic": topic,
189
+ "filter": spec.topic,
190
+ "qos": int(getattr(message, "qos", spec.qos)),
191
+ "retain": bool(getattr(message, "retain", False)),
192
+ }
193
+ await spec.fn(instance, payload, meta) # type: ignore[misc]
194
+ else:
195
+ await spec.fn(instance, payload) # type: ignore[misc]
196
+ except Exception as exc:
197
+ # Ném ra khỏi đây sẽ làm đứt vòng đọc, tức là mọi handler khác im
198
+ # theo cho tới lần nối lại. Một tin hỏng không đáng giá vậy.
199
+ mqtt_handler_failed.inc(topic=spec.topic)
200
+ log.exception("mqtt.handler_failed", handler=spec.label, topic=topic, error=str(exc))
201
+ finally:
202
+ reset_request_id(token)
203
+
204
+ def stats(self) -> dict[str, Any]:
205
+ return {
206
+ "listeners": [
207
+ {"handler": spec.label, "topic": spec.topic, "qos": spec.qos}
208
+ for spec in self._specs
209
+ ]
210
+ }
211
+
212
+
213
+ def _doc(payload: Any) -> Any:
214
+ """Thiết bị hay gửi chuỗi thuần ("ON", "23.5") chứ không phải JSON."""
215
+ raw = payload.decode("utf-8", errors="replace") if isinstance(payload, bytes) else str(payload)
216
+ try:
217
+ return json.loads(raw)
218
+ except (TypeError, ValueError):
219
+ return raw
@@ -0,0 +1,17 @@
1
+ """Số đo của lớp MQTT."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pymodular.core.metrics import Counter, registry
6
+
7
+ mqtt_published = registry.register(Counter("mqtt_published_total", "Số tin đã gửi lên broker"))
8
+ mqtt_publish_failed = registry.register(
9
+ Counter("mqtt_publish_failed_total", "Số lần gửi tin thất bại")
10
+ )
11
+ mqtt_received = registry.register(Counter("mqtt_received_total", "Số tin nhận được"))
12
+ mqtt_handler_failed = registry.register(
13
+ Counter("mqtt_handler_failed_total", "Số tin handler xử lý lỗi")
14
+ )
15
+ mqtt_unrouted = registry.register(
16
+ Counter("mqtt_unrouted_total", "Số tin nhận được mà không handler nào khớp")
17
+ )
@@ -0,0 +1,116 @@
1
+ """Khớp topic MQTT: `+` một tầng, `#` mọi tầng còn lại.
2
+
3
+ Khác hẳn routing key của AMQP dù nhìn na ná:
4
+
5
+ AMQP "alert.*" * = đúng một từ, # = nhiều từ, ngăn bằng dấu chấm
6
+ MQTT "alert/+" + = đúng một tầng, # = nhiều tầng, ngăn bằng dấu /
7
+
8
+ `#` phải là ký tự CUỐI và chiếm trọn một tầng: "nha/#" hợp lệ, "nha/#/den" và
9
+ "nha#" thì không. Broker sẽ từ chối, nhưng nó từ chối lúc chạy còn hàm dưới đây
10
+ từ chối ngay lúc khai báo decorator.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pymodular.core.exceptions import BadRequestError
16
+
17
+
18
+ def validate_topic_filter(topic_filter: str) -> None:
19
+ if not topic_filter:
20
+ raise BadRequestError("Topic MQTT không được rỗng")
21
+ tang = topic_filter.split("/")
22
+ for i, phan in enumerate(tang):
23
+ if "#" in phan:
24
+ if phan != "#":
25
+ raise BadRequestError(
26
+ f"Topic {topic_filter!r} sai: '#' phải chiếm trọn một tầng (vd 'nha/#')"
27
+ )
28
+ if i != len(tang) - 1:
29
+ raise BadRequestError(
30
+ f"Topic {topic_filter!r} sai: '#' phải nằm ở cuối cùng"
31
+ )
32
+ elif "+" in phan and phan != "+":
33
+ raise BadRequestError(
34
+ f"Topic {topic_filter!r} sai: '+' phải chiếm trọn một tầng (vd 'nha/+/den')"
35
+ )
36
+
37
+
38
+ def validate_topic(topic: str) -> None:
39
+ """Topic để GỬI thì không được chứa ký tự đại diện."""
40
+ if not topic:
41
+ raise BadRequestError("Topic MQTT không được rỗng")
42
+ if "+" in topic or "#" in topic:
43
+ raise BadRequestError(
44
+ f"Topic {topic!r} chứa ký tự đại diện — chỉ dùng được khi ĐĂNG KÝ NGHE, "
45
+ "không dùng để gửi"
46
+ )
47
+
48
+
49
+ def matches(topic_filter: str, topic: str) -> bool:
50
+ """Topic cụ thể có khớp bộ lọc không."""
51
+ loc = topic_filter.split("/")
52
+ that = topic.split("/")
53
+
54
+ # Theo chuẩn MQTT, ký tự đại diện ở tầng đầu KHÔNG chạm tới topic hệ thống
55
+ # ($SYS/...). Không có luật này thì một handler nghe "#" sẽ hút cả số liệu
56
+ # nội bộ của broker.
57
+ if that and that[0].startswith("$") and loc[0] in ("+", "#"):
58
+ return False
59
+
60
+ for i, phan in enumerate(loc):
61
+ if phan == "#":
62
+ return True # nuốt mọi tầng còn lại, kể cả không còn tầng nào
63
+ if i >= len(that):
64
+ return False
65
+ if phan != "+" and phan != that[i]:
66
+ return False
67
+ return len(loc) == len(that)
68
+
69
+
70
+ def covers(rong: str, hep: str) -> bool:
71
+ """Bộ lọc `rong` có bao trọn `hep` không — tức mọi topic khớp `hep` đều khớp `rong`.
72
+
73
+ Cần để KHÔNG đăng ký hai bộ lọc chồng nhau lên broker. Đăng ký cả
74
+ "thiet-bi/#" lẫn "thiet-bi/+/nhiet-do" thì mosquitto giao MỘT tin thành HAI
75
+ lần (mỗi đăng ký một bản), và handler chạy gấp đôi. Đo được: gửi 1 tin,
76
+ handler chạy 4 lượt.
77
+
78
+ Cách sửa là chỉ đăng ký bộ lọc rộng nhất rồi tự chia tin ở trong tiến trình.
79
+
80
+ Chú ý không dùng `matches()` để thay hàm này: `matches("a/+", "a/#")` trả về
81
+ True vì nó coi "#" là một tầng chữ thường, trong khi "a/+" hoàn toàn KHÔNG
82
+ bao được "a/#" (thiếu "a/b/c").
83
+ """
84
+ a = rong.split("/")
85
+ b = hep.split("/")
86
+ for i, phan in enumerate(a):
87
+ if phan == "#":
88
+ return True # nuốt trọn phần còn lại của b
89
+ if i >= len(b):
90
+ return False # a còn đòi thêm tầng, b hết
91
+ if b[i] == "#":
92
+ return False # b rộng hơn ở đây (a không phải "#")
93
+ if phan == "+":
94
+ continue # + bao được mọi tầng đơn, kể cả "+"
95
+ if phan != b[i]:
96
+ return False
97
+ return len(a) == len(b)
98
+
99
+
100
+ def narrow_filters(loc: dict[str, int]) -> dict[str, int]:
101
+ """Bỏ bộ lọc bị bộ lọc khác bao trọn; QoS dồn về cái còn lại (lấy mức cao nhất).
102
+
103
+ Vào: {"thiet-bi/#": 0, "thiet-bi/+/nhiet-do": 1}
104
+ Ra: {"thiet-bi/#": 1}
105
+ """
106
+ con: dict[str, int] = {}
107
+ for hep, qos in sorted(loc.items()):
108
+ bao = next(
109
+ (r for r in loc if r != hep and covers(r, hep)),
110
+ None,
111
+ )
112
+ if bao is None:
113
+ con[hep] = max(con.get(hep, 0), qos)
114
+ else:
115
+ con[bao] = max(con.get(bao, 0), qos, loc[bao])
116
+ return con
@@ -0,0 +1,33 @@
1
+ """Lớp RabbitMQ — TUỲ CHỌN, và độc lập với mọi thứ khác.
2
+
3
+ Dự án không dùng RabbitMQ thì không cần cài gì và không phải đụng tới đây:
4
+ `APP_RABBITMQ__ENABLED` mặc định là false, thư viện `aio-pika` chỉ được import khi
5
+ thật sự bật lên.
6
+
7
+ pip install 'fastapi-modular[rabbitmq]' # cài thư viện + ghi sẵn APP_RABBITMQ__* vào .env
8
+
9
+ Lớp này KHÔNG biết gì về HTTP hay WebSocket, và chúng cũng không biết nó. Muốn
10
+ một sự kiện từ hàng đợi hiện lên màn hình người dùng thì đó là việc của code
11
+ ứng dụng — ba dòng trong consumer của bạn, xem docs/rabbitmq.md.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pymodular.infrastructure.rabbitmq.broker import RabbitBroker
17
+ from pymodular.infrastructure.rabbitmq.consumers import (
18
+ PermanentMessageError,
19
+ RabbitmqRunner,
20
+ discover_rabbitmq_subscribers,
21
+ rabbitmq_subscriber,
22
+ )
23
+ from pymodular.infrastructure.rabbitmq.patterns import validate_pattern, validate_routing_key
24
+
25
+ __all__ = [
26
+ "PermanentMessageError",
27
+ "RabbitBroker",
28
+ "RabbitmqRunner",
29
+ "discover_rabbitmq_subscribers",
30
+ "rabbitmq_subscriber",
31
+ "validate_pattern",
32
+ "validate_routing_key",
33
+ ]