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,88 @@
|
|
|
1
|
+
"""Cấu hình structlog + stdlib logging thành một đường ống duy nhất."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
|
|
11
|
+
from pymodular.core.config import LogSettings
|
|
12
|
+
from pymodular.core.context import get_request_id, get_trace_id, get_user_id
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _inject_context(
|
|
16
|
+
_logger: Any, _method: str, event_dict: structlog.types.EventDict
|
|
17
|
+
) -> structlog.types.EventDict:
|
|
18
|
+
"""Gắn request_id / trace_id / user_id từ contextvars vào mọi dòng log."""
|
|
19
|
+
request_id = get_request_id()
|
|
20
|
+
if request_id:
|
|
21
|
+
event_dict.setdefault("request_id", request_id)
|
|
22
|
+
trace_id = get_trace_id()
|
|
23
|
+
if trace_id:
|
|
24
|
+
# trace_id nối log của dịch vụ này với log của các dịch vụ khác trong
|
|
25
|
+
# cùng một hành trình; request_id chỉ có ý nghĩa trong dịch vụ này.
|
|
26
|
+
event_dict.setdefault("trace_id", trace_id)
|
|
27
|
+
user_id = get_user_id()
|
|
28
|
+
if user_id:
|
|
29
|
+
event_dict.setdefault("user_id", user_id)
|
|
30
|
+
return event_dict
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def configure_logging(settings: LogSettings) -> None:
|
|
34
|
+
level = getattr(logging, settings.level.upper(), logging.INFO)
|
|
35
|
+
|
|
36
|
+
shared_processors: list[structlog.types.Processor] = [
|
|
37
|
+
structlog.contextvars.merge_contextvars,
|
|
38
|
+
_inject_context,
|
|
39
|
+
structlog.processors.add_log_level,
|
|
40
|
+
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
|
41
|
+
structlog.processors.StackInfoRenderer(),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
renderer: structlog.types.Processor = (
|
|
45
|
+
structlog.processors.JSONRenderer()
|
|
46
|
+
if settings.json_format
|
|
47
|
+
else structlog.dev.ConsoleRenderer(colors=sys.stderr.isatty())
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
structlog.configure(
|
|
51
|
+
processors=[
|
|
52
|
+
*shared_processors,
|
|
53
|
+
structlog.processors.format_exc_info,
|
|
54
|
+
renderer,
|
|
55
|
+
],
|
|
56
|
+
wrapper_class=structlog.make_filtering_bound_logger(level),
|
|
57
|
+
logger_factory=structlog.PrintLoggerFactory(file=sys.stdout),
|
|
58
|
+
cache_logger_on_first_use=True,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Đưa log của thư viện bên thứ ba (uvicorn, sqlalchemy, ...) qua structlog.
|
|
62
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
63
|
+
handler.setFormatter(
|
|
64
|
+
structlog.stdlib.ProcessorFormatter(
|
|
65
|
+
foreign_pre_chain=shared_processors,
|
|
66
|
+
processors=[
|
|
67
|
+
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
|
68
|
+
renderer,
|
|
69
|
+
],
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
root = logging.getLogger()
|
|
74
|
+
root.handlers = [handler]
|
|
75
|
+
root.setLevel(level)
|
|
76
|
+
|
|
77
|
+
for noisy in ("uvicorn", "uvicorn.error", "uvicorn.access"):
|
|
78
|
+
lg = logging.getLogger(noisy)
|
|
79
|
+
lg.handlers = []
|
|
80
|
+
lg.propagate = True
|
|
81
|
+
|
|
82
|
+
# uvicorn.access bị tắt vì middleware AccessLog đã ghi log request đầy đủ hơn.
|
|
83
|
+
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
|
84
|
+
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger:
|
|
88
|
+
return structlog.get_logger(name)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Số đo dạng Prometheus, viết tay để không phải thêm thư viện.
|
|
2
|
+
|
|
3
|
+
Chỉ có ba loại cần dùng: Counter (chỉ tăng), Gauge (lên xuống), Histogram
|
|
4
|
+
(phân bố độ trễ). Đủ để trả lời bốn câu hỏi vận hành quan trọng nhất: bao nhiêu
|
|
5
|
+
request, bao nhiêu lỗi, chậm cỡ nào, và tài nguyên còn bao nhiêu.
|
|
6
|
+
|
|
7
|
+
Một cạm bẫy phải tránh: KHÔNG lấy đường dẫn thật làm nhãn. `/api/users/abc123`
|
|
8
|
+
và `/api/users/def456` là hai nhãn khác nhau, mỗi user tạo một chuỗi số đo mới
|
|
9
|
+
và làm nổ bộ nhớ Prometheus. Phải dùng khuôn đường dẫn `/api/users/{user_id}`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from collections import defaultdict
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
Labels = tuple[tuple[str, str], ...]
|
|
20
|
+
|
|
21
|
+
# Mốc chia histogram, tính bằng giây. Chọn quanh ngưỡng người dùng cảm nhận
|
|
22
|
+
# được: dưới 100ms là nhanh, trên 1s là chậm, trên 5s coi như hỏng.
|
|
23
|
+
DEFAULT_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _labels(**kwargs: Any) -> Labels:
|
|
27
|
+
return tuple(sorted((k, str(v)) for k, v in kwargs.items()))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _render_labels(labels: Labels) -> str:
|
|
31
|
+
if not labels:
|
|
32
|
+
return ""
|
|
33
|
+
inner = ",".join(f'{k}="{_escape(v)}"' for k, v in labels)
|
|
34
|
+
return "{" + inner + "}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _escape(value: str) -> str:
|
|
38
|
+
return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Counter:
|
|
42
|
+
def __init__(self, name: str, help_text: str) -> None:
|
|
43
|
+
self.name = name
|
|
44
|
+
self.help = help_text
|
|
45
|
+
self._values: dict[Labels, float] = defaultdict(float)
|
|
46
|
+
self._lock = threading.Lock()
|
|
47
|
+
|
|
48
|
+
def inc(self, amount: float = 1.0, **labels: Any) -> None:
|
|
49
|
+
with self._lock:
|
|
50
|
+
self._values[_labels(**labels)] += amount
|
|
51
|
+
|
|
52
|
+
def render(self) -> list[str]:
|
|
53
|
+
lines = [f"# HELP {self.name} {self.help}", f"# TYPE {self.name} counter"]
|
|
54
|
+
with self._lock:
|
|
55
|
+
for labels, value in sorted(self._values.items()):
|
|
56
|
+
lines.append(f"{self.name}{_render_labels(labels)} {value:g}")
|
|
57
|
+
return lines
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Gauge:
|
|
61
|
+
def __init__(self, name: str, help_text: str) -> None:
|
|
62
|
+
self.name = name
|
|
63
|
+
self.help = help_text
|
|
64
|
+
self._values: dict[Labels, float] = defaultdict(float)
|
|
65
|
+
self._lock = threading.Lock()
|
|
66
|
+
|
|
67
|
+
def set(self, value: float, **labels: Any) -> None:
|
|
68
|
+
with self._lock:
|
|
69
|
+
self._values[_labels(**labels)] = value
|
|
70
|
+
|
|
71
|
+
def inc_gauge(self, amount: float = 1.0, **labels: Any) -> None:
|
|
72
|
+
with self._lock:
|
|
73
|
+
self._values[_labels(**labels)] += amount
|
|
74
|
+
|
|
75
|
+
def render(self) -> list[str]:
|
|
76
|
+
lines = [f"# HELP {self.name} {self.help}", f"# TYPE {self.name} gauge"]
|
|
77
|
+
with self._lock:
|
|
78
|
+
for labels, value in sorted(self._values.items()):
|
|
79
|
+
lines.append(f"{self.name}{_render_labels(labels)} {value:g}")
|
|
80
|
+
return lines
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Histogram:
|
|
84
|
+
def __init__(
|
|
85
|
+
self, name: str, help_text: str, buckets: tuple[float, ...] = DEFAULT_BUCKETS
|
|
86
|
+
) -> None:
|
|
87
|
+
self.name = name
|
|
88
|
+
self.help = help_text
|
|
89
|
+
self.buckets = buckets
|
|
90
|
+
self._counts: dict[Labels, list[int]] = defaultdict(lambda: [0] * len(buckets))
|
|
91
|
+
self._sums: dict[Labels, float] = defaultdict(float)
|
|
92
|
+
self._totals: dict[Labels, int] = defaultdict(int)
|
|
93
|
+
self._lock = threading.Lock()
|
|
94
|
+
|
|
95
|
+
def observe(self, value: float, **labels: Any) -> None:
|
|
96
|
+
key = _labels(**labels)
|
|
97
|
+
with self._lock:
|
|
98
|
+
counts = self._counts[key]
|
|
99
|
+
for index, edge in enumerate(self.buckets):
|
|
100
|
+
if value <= edge:
|
|
101
|
+
counts[index] += 1
|
|
102
|
+
self._sums[key] += value
|
|
103
|
+
self._totals[key] += 1
|
|
104
|
+
|
|
105
|
+
def render(self) -> list[str]:
|
|
106
|
+
lines = [f"# HELP {self.name} {self.help}", f"# TYPE {self.name} histogram"]
|
|
107
|
+
with self._lock:
|
|
108
|
+
for key in sorted(self._totals):
|
|
109
|
+
counts = self._counts[key]
|
|
110
|
+
for edge, count in zip(self.buckets, counts, strict=True):
|
|
111
|
+
labels = _render_labels((*key, ("le", str(edge))))
|
|
112
|
+
lines.append(f"{self.name}_bucket{labels} {count}")
|
|
113
|
+
total = self._totals[key]
|
|
114
|
+
lines.append(f"{self.name}_bucket{_render_labels((*key, ('le', '+Inf')))} {total}")
|
|
115
|
+
lines.append(f"{self.name}_sum{_render_labels(key)} {self._sums[key]:g}")
|
|
116
|
+
lines.append(f"{self.name}_count{_render_labels(key)} {total}")
|
|
117
|
+
return lines
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Registry:
|
|
121
|
+
def __init__(self) -> None:
|
|
122
|
+
self._metrics: list[Counter | Gauge | Histogram] = []
|
|
123
|
+
self._callbacks: list[Any] = []
|
|
124
|
+
|
|
125
|
+
def register(self, metric: Counter | Gauge | Histogram):
|
|
126
|
+
self._metrics.append(metric)
|
|
127
|
+
return metric
|
|
128
|
+
|
|
129
|
+
def on_scrape(self, callback: Any) -> None:
|
|
130
|
+
"""Hàm được gọi ngay trước khi xuất số đo, để cập nhật gauge tức thời."""
|
|
131
|
+
self._callbacks.append(callback)
|
|
132
|
+
|
|
133
|
+
def render(self) -> str:
|
|
134
|
+
for callback in self._callbacks:
|
|
135
|
+
callback()
|
|
136
|
+
lines: list[str] = []
|
|
137
|
+
for metric in self._metrics:
|
|
138
|
+
lines.extend(metric.render())
|
|
139
|
+
return "\n".join(lines) + "\n"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
registry = Registry()
|
|
143
|
+
|
|
144
|
+
http_requests = registry.register(
|
|
145
|
+
Counter("http_requests_total", "Số HTTP request đã xử lý")
|
|
146
|
+
)
|
|
147
|
+
http_duration = registry.register(
|
|
148
|
+
Histogram("http_request_duration_seconds", "Thời gian xử lý HTTP request")
|
|
149
|
+
)
|
|
150
|
+
http_in_flight = registry.register(
|
|
151
|
+
Gauge("http_requests_in_flight", "Số request đang xử lý")
|
|
152
|
+
)
|
|
153
|
+
app_info = registry.register(Gauge("app_info", "Thông tin phiên bản ứng dụng"))
|
|
154
|
+
db_circuit_state = registry.register(
|
|
155
|
+
Gauge("db_circuit_state", "Trạng thái ngắt mạch database (0=đóng 1=nửa mở 2=ngắt)")
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# ---- WebSocket ----------------------------------------------------------
|
|
159
|
+
# Nhãn chỉ có `namespace` và `event`, cả hai đều là tập hữu hạn do code khai
|
|
160
|
+
# báo. Tuyệt đối không lấy socket_id hay user_id làm nhãn: mỗi người dùng sẽ
|
|
161
|
+
# đẻ ra một chuỗi số đo mới và làm nổ Prometheus.
|
|
162
|
+
ws_connections = registry.register(
|
|
163
|
+
Gauge("ws_connections", "Số kết nối WebSocket đang mở")
|
|
164
|
+
)
|
|
165
|
+
ws_connections_total = registry.register(
|
|
166
|
+
Counter("ws_connections_total", "Tổng số kết nối WebSocket đã mở")
|
|
167
|
+
)
|
|
168
|
+
ws_messages_in = registry.register(
|
|
169
|
+
Counter("ws_messages_in_total", "Số khung tin nhận từ client")
|
|
170
|
+
)
|
|
171
|
+
ws_messages_out = registry.register(
|
|
172
|
+
Counter("ws_messages_out_total", "Số khung tin đã xếp hàng gửi cho client")
|
|
173
|
+
)
|
|
174
|
+
ws_send_dropped = registry.register(
|
|
175
|
+
Counter("ws_send_dropped_total", "Số khung tin bị bỏ vì hàng đợi gửi đầy")
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class Timer:
|
|
180
|
+
"""Đo thời gian một khối lệnh bằng đồng hồ đơn điệu."""
|
|
181
|
+
|
|
182
|
+
__slots__ = ("_started", "elapsed")
|
|
183
|
+
|
|
184
|
+
def __enter__(self) -> Timer:
|
|
185
|
+
self._started = time.perf_counter()
|
|
186
|
+
self.elapsed = 0.0
|
|
187
|
+
return self
|
|
188
|
+
|
|
189
|
+
def __exit__(self, *exc: object) -> None:
|
|
190
|
+
self.elapsed = time.perf_counter() - self._started
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Lớp nền cho DTO và vài tiện ích dùng chung cho mọi module.
|
|
2
|
+
|
|
3
|
+
Ba thứ ở đây thay cho việc lặp `model_config` trong từng file DTO:
|
|
4
|
+
|
|
5
|
+
- `InputSchema` — nền cho DTO đi vào (body request)
|
|
6
|
+
- `OutputSchema` — nền cho DTO đi ra (response)
|
|
7
|
+
- `partial_of()` — sinh biến thể "mọi field optional" cho PATCH, giữ nguyên
|
|
8
|
+
ràng buộc validate, thay cho việc chép tay lần thứ hai
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import copy
|
|
14
|
+
from typing import Any, Generic, TypeVar
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, create_model
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
ModelT = TypeVar("ModelT", bound=BaseModel)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InputSchema(BaseModel):
|
|
23
|
+
"""Nền cho mọi DTO nhận từ client.
|
|
24
|
+
|
|
25
|
+
- `extra="forbid"`: gửi thừa field sẽ bị 422 thay vì âm thầm bỏ qua, nhờ
|
|
26
|
+
vậy client gõ sai tên field biết ngay.
|
|
27
|
+
- `str_strip_whitespace`: cắt khoảng trắng thừa hai đầu chuỗi, tránh cảnh
|
|
28
|
+
`"An "` và `"An"` thành hai giá trị khác nhau trong database.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class OutputSchema(BaseModel):
|
|
35
|
+
"""Nền cho mọi DTO trả về client.
|
|
36
|
+
|
|
37
|
+
`from_attributes` cho phép `model_validate()` đọc thẳng từ dataclass entity
|
|
38
|
+
hay object ORM, không phải chuyển sang dict trước.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(from_attributes=True)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def partial_of(model: type[ModelT], *, name: str | None = None) -> type[BaseModel]:
|
|
45
|
+
"""Sinh bản sao của `model` với mọi field thành optional (mặc định None).
|
|
46
|
+
|
|
47
|
+
Dùng cho PATCH: chỉ field nào client gửi lên mới bị ghi đè. Ràng buộc
|
|
48
|
+
validate (pattern, min_length, ge/le...) được giữ nguyên, nên không phải
|
|
49
|
+
chép lại lần thứ hai và không sợ hai bản lệch nhau khi sửa.
|
|
50
|
+
|
|
51
|
+
Cách dùng thường gặp — kế thừa để thêm field chỉ có ở PATCH:
|
|
52
|
+
|
|
53
|
+
class UserUpdate(partial_of(UserBase)):
|
|
54
|
+
is_active: bool | None = None
|
|
55
|
+
|
|
56
|
+
`name` là TUỲ CHỌN và hầu như không cần. Nó chỉ đặt tên cho class được sinh
|
|
57
|
+
ra, mà class đó là LỚP CHA ẩn khi bạn kế thừa — tên hiện trong OpenAPI là
|
|
58
|
+
tên lớp con của bạn. Chỉ truyền `name` khi dùng thẳng kết quả, vì lúc đó
|
|
59
|
+
tên sinh ra chính là tên schema client nhìn thấy:
|
|
60
|
+
|
|
61
|
+
DeviceFilter = partial_of(DeviceBase, name="DeviceFilter")
|
|
62
|
+
# không có name thì schema sẽ mang tên "DeviceBasePartial"
|
|
63
|
+
|
|
64
|
+
Đừng đặt `name` trùng tên lớp con — sẽ có hai class cùng tên, và nếu cả hai
|
|
65
|
+
cùng xuất hiện trong OpenAPI thì FastAPI phải tự thêm tiền tố để phân biệt.
|
|
66
|
+
"""
|
|
67
|
+
fields: dict[str, Any] = {}
|
|
68
|
+
|
|
69
|
+
for field_name, field in model.model_fields.items():
|
|
70
|
+
optional = copy.deepcopy(field)
|
|
71
|
+
optional.default = None
|
|
72
|
+
optional.default_factory = None
|
|
73
|
+
fields[field_name] = (field.annotation | None, optional)
|
|
74
|
+
|
|
75
|
+
# Kế thừa chính `model` chứ không phải lớp cha của nó: nhờ vậy field
|
|
76
|
+
# validator, model validator và model_config tuỳ chỉnh đều được giữ. Field
|
|
77
|
+
# bắt buộc bị ghi đè thành optional ngay bên dưới nên không mâu thuẫn.
|
|
78
|
+
return create_model( # type: ignore[call-overload]
|
|
79
|
+
name or f"{model.__name__}Partial",
|
|
80
|
+
__base__=model,
|
|
81
|
+
__doc__=f"Bản PATCH của {model.__name__}: mọi field optional.",
|
|
82
|
+
**fields,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def apply_changes(entity: Any, payload: BaseModel) -> list[str]:
|
|
87
|
+
"""Chép các field client THỰC SỰ gửi lên vào entity. Trả về tên field đã đổi.
|
|
88
|
+
|
|
89
|
+
`exclude_unset=True` là mấu chốt: nó phân biệt "không gửi field này" với
|
|
90
|
+
"gửi field này = null". Thiếu nó thì PATCH một field sẽ xoá trắng các field
|
|
91
|
+
còn lại.
|
|
92
|
+
"""
|
|
93
|
+
changes = payload.model_dump(exclude_unset=True)
|
|
94
|
+
for field_name, value in changes.items():
|
|
95
|
+
setattr(entity, field_name, value)
|
|
96
|
+
return sorted(changes)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Page(BaseModel, Generic[T]):
|
|
100
|
+
"""Bao ngoài cho danh sách có phân trang."""
|
|
101
|
+
|
|
102
|
+
items: list[T]
|
|
103
|
+
total: int
|
|
104
|
+
limit: int
|
|
105
|
+
offset: int
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Thư viện WebSocket của khung: gateway, phòng, gửi thẳng cho một client.
|
|
2
|
+
|
|
3
|
+
Đọc `docs/websocket.md` để có hướng dẫn đầy đủ (kèm ví dụ Postman và Next.js).
|
|
4
|
+
|
|
5
|
+
from pymodular.core.websocket import Socket, WebSocketServer, gateway, subscribe
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pymodular.core.websocket.adapter import BroadcastAdapter, LocalAdapter, RedisAdapter
|
|
11
|
+
from pymodular.core.websocket.gateway import build_ws_router, gateway, gateways_in, subscribe
|
|
12
|
+
from pymodular.core.websocket.namespace import Namespace
|
|
13
|
+
from pymodular.core.websocket.protocol import CloseCode, Frame, ProtocolError
|
|
14
|
+
from pymodular.core.websocket.server import WebSocketServer
|
|
15
|
+
from pymodular.core.websocket.socket import Socket
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"BroadcastAdapter",
|
|
19
|
+
"CloseCode",
|
|
20
|
+
"Frame",
|
|
21
|
+
"LocalAdapter",
|
|
22
|
+
"Namespace",
|
|
23
|
+
"ProtocolError",
|
|
24
|
+
"RedisAdapter",
|
|
25
|
+
"Socket",
|
|
26
|
+
"WebSocketServer",
|
|
27
|
+
"build_ws_router",
|
|
28
|
+
"gateway",
|
|
29
|
+
"gateways_in",
|
|
30
|
+
"subscribe",
|
|
31
|
+
]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Cầu nối giữa các tiến trình worker.
|
|
2
|
+
|
|
3
|
+
Vấn đề: sổ kết nối nằm trong BỘ NHỚ của một tiến trình. `pym run --workers 4` chạy 4
|
|
4
|
+
worker, mỗi worker là một tiến trình riêng. Client A nối vào worker 1, client B
|
|
5
|
+
nối vào worker 3; A gửi tin cho phòng "alerts" thì worker 1 chỉ thấy các kết
|
|
6
|
+
nối của chính nó — B không nhận được gì. Bệnh này chỉ lộ ra khi lên nhiều
|
|
7
|
+
worker/nhiều máy, nên rất hay bị phát hiện muộn.
|
|
8
|
+
|
|
9
|
+
Cách chữa: mỗi lần phát tin, ngoài việc gửi cho kết nối tại chỗ thì còn đăng
|
|
10
|
+
tin lên một kênh chung; các worker khác nghe kênh đó và gửi tiếp cho kết nối
|
|
11
|
+
của mình. Đây đúng là vai trò của Redis adapter trong NestJS.
|
|
12
|
+
|
|
13
|
+
Hai lựa chọn:
|
|
14
|
+
|
|
15
|
+
- `local` (mặc định): không có kênh chung. Đúng khi chạy MỘT worker. Nhanh
|
|
16
|
+
nhất, không phụ thuộc gì thêm.
|
|
17
|
+
- `redis`: dùng Redis pub/sub. Bật bằng `APP_WS__ADAPTER=redis`, cài bằng
|
|
18
|
+
`pip install 'fastapi-modular[redis]'`. Thư viện redis chỉ được import khi thật sự chọn nó.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import contextlib
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import uuid
|
|
28
|
+
from collections.abc import Callable, Iterable
|
|
29
|
+
from typing import Any, Protocol
|
|
30
|
+
|
|
31
|
+
from pymodular.core.exceptions import ComponentNotEnabledError
|
|
32
|
+
from pymodular.core.logging import get_logger
|
|
33
|
+
|
|
34
|
+
log = get_logger(__name__)
|
|
35
|
+
|
|
36
|
+
OnMessage = Callable[[dict[str, Any]], None]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def new_origin_id() -> str:
|
|
40
|
+
"""Mã nhận dạng worker này — để không nhận lại chính tin mình vừa đăng."""
|
|
41
|
+
return f"{os.getpid()}-{uuid.uuid4().hex[:8]}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def envelope(
|
|
45
|
+
origin: str,
|
|
46
|
+
namespace: str,
|
|
47
|
+
event: str,
|
|
48
|
+
data: Any,
|
|
49
|
+
*,
|
|
50
|
+
room: str | None = None,
|
|
51
|
+
user: str | None = None,
|
|
52
|
+
socket: str | None = None,
|
|
53
|
+
exclude: Iterable[str] = (),
|
|
54
|
+
) -> dict[str, Any]:
|
|
55
|
+
return {
|
|
56
|
+
"origin": origin,
|
|
57
|
+
"ns": namespace,
|
|
58
|
+
"event": event,
|
|
59
|
+
"data": data,
|
|
60
|
+
"room": room,
|
|
61
|
+
"user": user,
|
|
62
|
+
"socket": socket,
|
|
63
|
+
"exclude": list(exclude),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BroadcastAdapter(Protocol):
|
|
68
|
+
name: str
|
|
69
|
+
|
|
70
|
+
async def start(self, on_message: OnMessage) -> None: ...
|
|
71
|
+
async def publish(self, payload: dict[str, Any]) -> None: ...
|
|
72
|
+
async def stop(self) -> None: ...
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class LocalAdapter:
|
|
76
|
+
"""Không làm gì cả — mọi kết nối đều nằm trong tiến trình này."""
|
|
77
|
+
|
|
78
|
+
name = "local"
|
|
79
|
+
|
|
80
|
+
async def start(self, on_message: OnMessage) -> None:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
async def publish(self, payload: dict[str, Any]) -> None:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
async def stop(self) -> None:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class RedisAdapter:
|
|
91
|
+
"""Phát tin xuyên worker qua Redis pub/sub.
|
|
92
|
+
|
|
93
|
+
Cố ý dùng pub/sub chứ không phải stream: tin nhắn realtime hết giá trị sau
|
|
94
|
+
vài giây, không đáng để lưu lại. Worker vừa khởi động sẽ không nhận được
|
|
95
|
+
tin phát lúc nó chưa lên — đúng như mong đợi.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
name = "redis"
|
|
99
|
+
|
|
100
|
+
def __init__(self, url: str, channel: str, *, origin: str | None = None) -> None:
|
|
101
|
+
self.url = url
|
|
102
|
+
self.channel = channel
|
|
103
|
+
self.origin = origin or new_origin_id()
|
|
104
|
+
self._client: Any = None
|
|
105
|
+
self._pubsub: Any = None
|
|
106
|
+
self._task: asyncio.Task[None] | None = None
|
|
107
|
+
self._on_message: OnMessage | None = None
|
|
108
|
+
self._stopping = False
|
|
109
|
+
|
|
110
|
+
async def start(self, on_message: OnMessage) -> None:
|
|
111
|
+
try:
|
|
112
|
+
import redis.asyncio as redis
|
|
113
|
+
except ModuleNotFoundError as exc:
|
|
114
|
+
raise ComponentNotEnabledError(
|
|
115
|
+
"APP_WS__ADAPTER=redis nhưng chưa cài thư viện redis. "
|
|
116
|
+
"Chạy `pip install 'fastapi-modular[redis]'`, hoặc đổi về APP_WS__ADAPTER=local nếu "
|
|
117
|
+
"chỉ chạy một worker."
|
|
118
|
+
) from exc
|
|
119
|
+
|
|
120
|
+
self._on_message = on_message
|
|
121
|
+
self._client = redis.from_url(self.url, decode_responses=True)
|
|
122
|
+
await self._client.ping() # hỏng cấu hình thì báo ngay lúc boot
|
|
123
|
+
self._task = asyncio.create_task(self._listen(), name="ws-redis-rabbitmq_subscriber")
|
|
124
|
+
log.info("ws.adapter_started", adapter=self.name, channel=self.channel, origin=self.origin)
|
|
125
|
+
|
|
126
|
+
async def publish(self, payload: dict[str, Any]) -> None:
|
|
127
|
+
if self._client is None:
|
|
128
|
+
return
|
|
129
|
+
try:
|
|
130
|
+
await self._client.publish(self.channel, json.dumps(payload, default=str))
|
|
131
|
+
except Exception as exc: # noqa: BLE001 - Redis hỏng không được làm hỏng request
|
|
132
|
+
# Tin đã tới được các kết nối tại chỗ; mất phần xuyên worker thôi.
|
|
133
|
+
log.warning("ws.publish_failed", error=f"{type(exc).__name__}: {exc}")
|
|
134
|
+
|
|
135
|
+
async def _listen(self) -> None:
|
|
136
|
+
delay = 0.5
|
|
137
|
+
while not self._stopping:
|
|
138
|
+
try:
|
|
139
|
+
self._pubsub = self._client.pubsub()
|
|
140
|
+
await self._pubsub.subscribe(self.channel)
|
|
141
|
+
delay = 0.5
|
|
142
|
+
async for message in self._pubsub.listen():
|
|
143
|
+
if message.get("type") != "message":
|
|
144
|
+
continue
|
|
145
|
+
self._dispatch(message.get("data"))
|
|
146
|
+
except asyncio.CancelledError:
|
|
147
|
+
raise
|
|
148
|
+
except Exception as exc: # noqa: BLE001 - mất Redis phải tự nối lại, không được chết task
|
|
149
|
+
if self._stopping:
|
|
150
|
+
return
|
|
151
|
+
log.warning(
|
|
152
|
+
"ws.adapter_reconnecting",
|
|
153
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
154
|
+
retry_in=delay,
|
|
155
|
+
)
|
|
156
|
+
await asyncio.sleep(delay)
|
|
157
|
+
delay = min(delay * 2, 10.0)
|
|
158
|
+
|
|
159
|
+
def _dispatch(self, raw: Any) -> None:
|
|
160
|
+
try:
|
|
161
|
+
payload = json.loads(raw)
|
|
162
|
+
except (TypeError, ValueError):
|
|
163
|
+
log.warning("ws.adapter_bad_payload")
|
|
164
|
+
return
|
|
165
|
+
if payload.get("origin") == self.origin:
|
|
166
|
+
return # tin của chính mình, đã gửi tại chỗ rồi
|
|
167
|
+
if self._on_message is not None:
|
|
168
|
+
self._on_message(payload)
|
|
169
|
+
|
|
170
|
+
async def stop(self) -> None:
|
|
171
|
+
# Đang tắt tiến trình: mọi lỗi dọn dẹp đều nuốt, vì báo lỗi lúc này
|
|
172
|
+
# chẳng ai xử lý được mà lại che mất phần shutdown còn lại.
|
|
173
|
+
self._stopping = True
|
|
174
|
+
if self._task is not None:
|
|
175
|
+
self._task.cancel()
|
|
176
|
+
with contextlib.suppress(BaseException):
|
|
177
|
+
await self._task
|
|
178
|
+
self._task = None
|
|
179
|
+
if self._pubsub is not None:
|
|
180
|
+
with contextlib.suppress(Exception):
|
|
181
|
+
await self._pubsub.aclose()
|
|
182
|
+
self._pubsub = None
|
|
183
|
+
if self._client is not None:
|
|
184
|
+
with contextlib.suppress(Exception):
|
|
185
|
+
await self._client.aclose()
|
|
186
|
+
self._client = None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def build_adapter(kind: str, *, url: str, channel: str, origin: str) -> BroadcastAdapter:
|
|
190
|
+
if kind == "redis":
|
|
191
|
+
return RedisAdapter(url, channel, origin=origin)
|
|
192
|
+
return LocalAdapter()
|