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,492 @@
|
|
|
1
|
+
"""Sinh khung một module nghiệp vụ mới.
|
|
2
|
+
|
|
3
|
+
Chạy qua: pym module alerts [--entity Alert] [--gateway] [--consumer]
|
|
4
|
+
Thêm gateway cho module đã có: pym module alerts --gateway-only
|
|
5
|
+
Thêm consumer cho module đã có: pym module alerts --consumer-only
|
|
6
|
+
|
|
7
|
+
Tạo đúng cấu trúc của các module có sẵn — router / service / dto / entities —
|
|
8
|
+
với đầy đủ dây nối DI, decorator route và DTO tương ứng. Thân hàm để trống
|
|
9
|
+
kèm TODO, gọi vào sẽ trả 501 chứ không phải 500, để phân biệt "chưa viết" với
|
|
10
|
+
"có bug".
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import re
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
DEFAULT_ROOT = Path("src/api")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Đuôi kết thúc bằng "s" nhưng KHÔNG phải số nhiều: status, class, analysis...
|
|
23
|
+
_KHONG_PHAI_SO_NHIEU = ("ss", "us", "is", "as", "os")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def singular(plural: str) -> str:
|
|
27
|
+
"""Đoán dạng số ít từ tên thư mục. Đoán sai thì truyền entity= để đè."""
|
|
28
|
+
if plural.endswith("ies"):
|
|
29
|
+
return plural[:-3] + "y"
|
|
30
|
+
if plural.endswith(("ses", "xes", "zes", "ches", "shes")):
|
|
31
|
+
return plural[:-2]
|
|
32
|
+
if plural.endswith("s") and not plural.endswith(_KHONG_PHAI_SO_NHIEU):
|
|
33
|
+
return plural[:-1]
|
|
34
|
+
return plural
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def pascal(name: str) -> str:
|
|
38
|
+
return "".join(part.capitalize() for part in name.split("_") if part)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render(module: str, entity: str) -> dict[str, str]:
|
|
42
|
+
"""Trả về {đường dẫn tương đối: nội dung}."""
|
|
43
|
+
cls = pascal(entity) # Alert
|
|
44
|
+
var = entity # alert
|
|
45
|
+
files: dict[str, str] = {}
|
|
46
|
+
|
|
47
|
+
files["__init__.py"] = f'"""Module {cls}."""\n'
|
|
48
|
+
files["entities/__init__.py"] = ""
|
|
49
|
+
files["dto/__init__.py"] = ""
|
|
50
|
+
|
|
51
|
+
files[f"entities/{var}_model.py"] = f'''"""Entity của module {cls} — biểu diễn nội bộ, không trả thẳng ra HTTP."""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
from dataclasses import dataclass, field
|
|
56
|
+
from datetime import datetime
|
|
57
|
+
|
|
58
|
+
from pymodular.core.clock import utcnow
|
|
59
|
+
from pymodular.core.container import entity
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@entity(
|
|
63
|
+
# TODO: khai báo ràng buộc duy nhất và index cho các trường hay lọc.
|
|
64
|
+
# unique=["ma_dinh_danh", ("owner_id", "name")]
|
|
65
|
+
# indexes=[("owner_id", "created_at"), "status"]
|
|
66
|
+
# Ràng buộc duy nhất PHẢI khai ở đây; kiểm tra trong service là một cuộc đua.
|
|
67
|
+
)
|
|
68
|
+
@dataclass(slots=True)
|
|
69
|
+
class {cls}:
|
|
70
|
+
id: str
|
|
71
|
+
|
|
72
|
+
# TODO: thêm các trường của bạn ở đây. Trường có giá trị mặc định thì bản
|
|
73
|
+
# ghi cũ vẫn đọc được sau khi thêm cột (xem docs/database.md).
|
|
74
|
+
name: str = ""
|
|
75
|
+
|
|
76
|
+
created_at: datetime = field(default_factory=utcnow)
|
|
77
|
+
updated_at: datetime = field(default_factory=utcnow)
|
|
78
|
+
'''
|
|
79
|
+
|
|
80
|
+
files[f"dto/{var}_dto.py"] = f'''"""DTO vào/ra của module {cls}."""
|
|
81
|
+
|
|
82
|
+
from __future__ import annotations
|
|
83
|
+
|
|
84
|
+
from datetime import datetime
|
|
85
|
+
|
|
86
|
+
from pydantic import Field
|
|
87
|
+
|
|
88
|
+
from pymodular.core.schemas import InputSchema, OutputSchema, partial_of
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class {cls}Base(InputSchema):
|
|
92
|
+
"""Trường client được phép ghi, khai báo đúng MỘT lần."""
|
|
93
|
+
|
|
94
|
+
# TODO: thêm trường tương ứng với entity.
|
|
95
|
+
name: str = Field(min_length=1, max_length=100)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class {cls}Create({cls}Base):
|
|
99
|
+
"""POST: mọi trường trong {cls}Base đều bắt buộc.
|
|
100
|
+
|
|
101
|
+
Trường chỉ đặt được lúc tạo (bất biến về sau) thì khai ở ĐÂY, không phải ở
|
|
102
|
+
{cls}Base — như vậy PATCH sẽ tự động từ chối chúng.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class {cls}Update(partial_of({cls}Base)):
|
|
107
|
+
"""PATCH: mọi trường của {cls}Base thành optional, ràng buộc giữ nguyên.
|
|
108
|
+
|
|
109
|
+
Trường chỉ sửa được chứ không đặt lúc tạo thì thêm ở đây.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class {cls}Out(OutputSchema):
|
|
114
|
+
"""Cố ý liệt kê tường minh: entity về sau có thể thêm trường nội bộ mà
|
|
115
|
+
không được lộ ra API."""
|
|
116
|
+
|
|
117
|
+
id: str
|
|
118
|
+
name: str
|
|
119
|
+
created_at: datetime
|
|
120
|
+
updated_at: datetime
|
|
121
|
+
'''
|
|
122
|
+
|
|
123
|
+
files[f"{var}_service.py"] = f'''"""Nghiệp vụ của module {cls} (@Service).
|
|
124
|
+
|
|
125
|
+
Chỉ tầng này chứa business rule. Ném lỗi nghiệp vụ (NotFoundError/ConflictError)
|
|
126
|
+
chứ không biết gì về HTTP status code.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
from __future__ import annotations
|
|
130
|
+
|
|
131
|
+
from pymodular.core.container import injectable
|
|
132
|
+
from pymodular.core.logging import get_logger
|
|
133
|
+
from pymodular.infrastructure.database import Repository
|
|
134
|
+
from src.api.{module}.dto.{var}_dto import {cls}Create, {cls}Update
|
|
135
|
+
from src.api.{module}.entities.{var}_model import {cls}
|
|
136
|
+
|
|
137
|
+
log = get_logger(__name__)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@injectable
|
|
141
|
+
class {cls}Service:
|
|
142
|
+
def __init__(self, repo: Repository[{cls}]) -> None:
|
|
143
|
+
self._repo = repo
|
|
144
|
+
|
|
145
|
+
async def list_{module}(self, *, limit: int, offset: int) -> tuple[list[{cls}], int]:
|
|
146
|
+
# TODO: viết thân hàm. Gợi ý:
|
|
147
|
+
# return (
|
|
148
|
+
# await self._repo.find(limit=limit, offset=offset),
|
|
149
|
+
# await self._repo.count(),
|
|
150
|
+
# )
|
|
151
|
+
raise NotImplementedError("{cls}Service.list_{module} chưa được viết")
|
|
152
|
+
|
|
153
|
+
async def get_{var}(self, {var}_id: str) -> {cls}:
|
|
154
|
+
# TODO: gợi ý:
|
|
155
|
+
# item = await self._repo.get({var}_id)
|
|
156
|
+
# if item is None:
|
|
157
|
+
# raise NotFoundError(f"Không tìm thấy {var} {{{var}_id}}")
|
|
158
|
+
# return item
|
|
159
|
+
raise NotImplementedError("{cls}Service.get_{var} chưa được viết")
|
|
160
|
+
|
|
161
|
+
async def create_{var}(self, payload: {cls}Create) -> {cls}:
|
|
162
|
+
# TODO: gợi ý:
|
|
163
|
+
# return await self._repo.save({cls}(id="", **payload.model_dump()))
|
|
164
|
+
raise NotImplementedError("{cls}Service.create_{var} chưa được viết")
|
|
165
|
+
|
|
166
|
+
async def update_{var}(self, {var}_id: str, payload: {cls}Update) -> {cls}:
|
|
167
|
+
# TODO: gợi ý:
|
|
168
|
+
# item = await self.get_{var}({var}_id)
|
|
169
|
+
# apply_changes(item, payload) # updated_at do repository lo
|
|
170
|
+
# return await self._repo.save(item)
|
|
171
|
+
raise NotImplementedError("{cls}Service.update_{var} chưa được viết")
|
|
172
|
+
|
|
173
|
+
async def delete_{var}(self, {var}_id: str) -> None:
|
|
174
|
+
# TODO: gợi ý:
|
|
175
|
+
# if not await self._repo.delete({var}_id):
|
|
176
|
+
# raise NotFoundError(f"Không tìm thấy {var} {{{var}_id}}")
|
|
177
|
+
raise NotImplementedError("{cls}Service.delete_{var} chưa được viết")
|
|
178
|
+
'''
|
|
179
|
+
|
|
180
|
+
files[f"{var}_controller.py"] = f'''"""HTTP layer của module {cls} (@Controller).
|
|
181
|
+
|
|
182
|
+
Controller chỉ khai báo đường dẫn, validate qua schema, và đổi entity thành DTO.
|
|
183
|
+
Không có business rule ở đây.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
from __future__ import annotations
|
|
187
|
+
|
|
188
|
+
from typing import Annotated
|
|
189
|
+
|
|
190
|
+
from fastapi import Path, Query, status
|
|
191
|
+
|
|
192
|
+
from pymodular.core.controller import controller, delete, get, patch, post
|
|
193
|
+
from pymodular.core.schemas import Page
|
|
194
|
+
from src.api.{module}.{var}_service import {cls}Service
|
|
195
|
+
from src.api.{module}.dto.{var}_dto import {cls}Create, {cls}Out, {cls}Update
|
|
196
|
+
|
|
197
|
+
{cls}Id = Annotated[str, Path(description="ID của {var}")]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@controller(prefix="/{module}", tags=["{module}"])
|
|
201
|
+
class {cls}Controller:
|
|
202
|
+
def __init__(self, service: {cls}Service) -> None:
|
|
203
|
+
self._service = service
|
|
204
|
+
|
|
205
|
+
@get("", response_model=Page[{cls}Out], summary="Danh sách {module}")
|
|
206
|
+
async def list_{module}(
|
|
207
|
+
self,
|
|
208
|
+
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
|
209
|
+
offset: Annotated[int, Query(ge=0)] = 0,
|
|
210
|
+
) -> Page[{cls}Out]:
|
|
211
|
+
items, total = await self._service.list_{module}(limit=limit, offset=offset)
|
|
212
|
+
return Page(
|
|
213
|
+
items=[{cls}Out.model_validate(item) for item in items],
|
|
214
|
+
total=total,
|
|
215
|
+
limit=limit,
|
|
216
|
+
offset=offset,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
@get("/{{{var}_id}}", response_model={cls}Out, summary="Chi tiết {var}")
|
|
220
|
+
async def get_{var}(self, {var}_id: {cls}Id) -> {cls}Out:
|
|
221
|
+
return {cls}Out.model_validate(await self._service.get_{var}({var}_id))
|
|
222
|
+
|
|
223
|
+
@post(
|
|
224
|
+
"",
|
|
225
|
+
response_model={cls}Out,
|
|
226
|
+
status_code=status.HTTP_201_CREATED,
|
|
227
|
+
summary="Tạo {var}",
|
|
228
|
+
)
|
|
229
|
+
async def create_{var}(self, payload: {cls}Create) -> {cls}Out:
|
|
230
|
+
return {cls}Out.model_validate(await self._service.create_{var}(payload))
|
|
231
|
+
|
|
232
|
+
@patch("/{{{var}_id}}", response_model={cls}Out, summary="Cập nhật {var}")
|
|
233
|
+
async def update_{var}(self, {var}_id: {cls}Id, payload: {cls}Update) -> {cls}Out:
|
|
234
|
+
return {cls}Out.model_validate(
|
|
235
|
+
await self._service.update_{var}({var}_id, payload)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
@delete(
|
|
239
|
+
"/{{{var}_id}}",
|
|
240
|
+
status_code=status.HTTP_204_NO_CONTENT,
|
|
241
|
+
summary="Xoá {var}",
|
|
242
|
+
)
|
|
243
|
+
async def delete_{var}(self, {var}_id: {cls}Id) -> None:
|
|
244
|
+
await self._service.delete_{var}({var}_id)
|
|
245
|
+
'''
|
|
246
|
+
return files
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def render_gateway(module: str, entity: str) -> dict[str, str]:
|
|
250
|
+
"""Khung gateway WebSocket cho một module. Trả về {đường dẫn tương đối: nội dung}."""
|
|
251
|
+
cls = pascal(entity)
|
|
252
|
+
var = entity
|
|
253
|
+
files: dict[str, str] = {}
|
|
254
|
+
|
|
255
|
+
files[f"dto/{var}_ws_dto.py"] = f'''"""DTO cho các sự kiện WebSocket của module {cls}.
|
|
256
|
+
|
|
257
|
+
Payload WebSocket được validate bằng chính pydantic như body HTTP: sai thì
|
|
258
|
+
client nhận khung `error` mang code `validation_error`, không phải 500.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
from __future__ import annotations
|
|
262
|
+
|
|
263
|
+
from pydantic import Field
|
|
264
|
+
|
|
265
|
+
from pymodular.core.schemas import InputSchema
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class {cls}Event(InputSchema):
|
|
269
|
+
"""Dữ liệu client gửi lên kèm sự kiện."""
|
|
270
|
+
|
|
271
|
+
# TODO: thêm trường của bạn.
|
|
272
|
+
room: str = Field(min_length=1, max_length=128)
|
|
273
|
+
'''
|
|
274
|
+
|
|
275
|
+
files[f"{var}_gateway.py"] = f'''"""Gateway WebSocket của module {cls} (@WebSocketGateway).
|
|
276
|
+
|
|
277
|
+
Kết nối: ws://localhost:8000/ws/{module}?client_id=an
|
|
278
|
+
|
|
279
|
+
Không phải đăng ký ở đâu cả — app/app.py tự quét và gắn. Xem
|
|
280
|
+
docs/websocket.md để biết khuôn tin nhắn, cách gửi cho phòng / cho một người,
|
|
281
|
+
và ví dụ client Postman + Next.js.
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
from __future__ import annotations
|
|
285
|
+
|
|
286
|
+
from typing import Any
|
|
287
|
+
|
|
288
|
+
from pymodular.core.guards import RequireHeader
|
|
289
|
+
from pymodular.core.logging import get_logger
|
|
290
|
+
from pymodular.core.websocket import Socket, WebSocketServer, gateway, subscribe
|
|
291
|
+
from src.api.{module}.dto.{var}_ws_dto import {cls}Event
|
|
292
|
+
|
|
293
|
+
log = get_logger(__name__)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@gateway(
|
|
297
|
+
path="/ws/{module}",
|
|
298
|
+
guards=[RequireHeader], # TODO: đổi sang guard xác thực thật của bạn
|
|
299
|
+
# client_rooms=True thì client tự gửi được room.join/room.leave. Bật thì
|
|
300
|
+
# NHỚ viết can_join() bên dưới, nếu không ai cũng vào được phòng của người khác.
|
|
301
|
+
client_rooms=False,
|
|
302
|
+
)
|
|
303
|
+
class {cls}Gateway:
|
|
304
|
+
def __init__(self, server: WebSocketServer) -> None:
|
|
305
|
+
# Dùng để đẩy tin: server.to_room(...) / to_user(...) / to_socket(...)
|
|
306
|
+
self._server = server
|
|
307
|
+
|
|
308
|
+
# ------------------------------------------------------------ vòng đời
|
|
309
|
+
async def on_connect(self, socket: Socket) -> None:
|
|
310
|
+
"""Chạy sau khi guard cho qua, trước khi client nhận khung `connected`."""
|
|
311
|
+
# TODO: gợi ý — cho mỗi người một phòng riêng để gửi thông báo cá nhân:
|
|
312
|
+
# if socket.user_id:
|
|
313
|
+
# socket.join(f"user:{{socket.user_id}}")
|
|
314
|
+
log.info("{module}.connected", socket_id=socket.id, user_id=socket.user_id)
|
|
315
|
+
|
|
316
|
+
async def on_disconnect(self, socket: Socket, code: int) -> None:
|
|
317
|
+
"""Chạy khi kết nối đứt, dù vì lý do gì. Sổ phòng đã tự dọn."""
|
|
318
|
+
log.info("{module}.disconnected", socket_id=socket.id, code=code)
|
|
319
|
+
|
|
320
|
+
# def can_join(self, socket: Socket, room: str) -> bool:
|
|
321
|
+
# """Chốt chặn cho room.join do client gửi lên (cần client_rooms=True)."""
|
|
322
|
+
# return room == f"user:{{socket.user_id}}"
|
|
323
|
+
|
|
324
|
+
# -------------------------------------------------------------- sự kiện
|
|
325
|
+
@subscribe("{var}.subscribe")
|
|
326
|
+
async def subscribe_{var}(self, socket: Socket, payload: {cls}Event) -> dict[str, Any]:
|
|
327
|
+
"""Giá trị trả về được gửi lại làm ack khi client có kèm `id`."""
|
|
328
|
+
# TODO: gợi ý:
|
|
329
|
+
# socket.join(payload.room)
|
|
330
|
+
# return {{"room": payload.room, "size": socket.namespace.room_size(payload.room)}}
|
|
331
|
+
raise NotImplementedError("{cls}Gateway.subscribe_{var} chưa được viết")
|
|
332
|
+
|
|
333
|
+
@subscribe("{var}.ping")
|
|
334
|
+
async def ping_{var}(self, socket: Socket) -> dict[str, Any]:
|
|
335
|
+
"""Handler không cần payload thì bỏ luôn tham số thứ hai."""
|
|
336
|
+
# TODO: gợi ý:
|
|
337
|
+
# return {{"socket_id": socket.id, "rooms": sorted(socket.rooms)}}
|
|
338
|
+
raise NotImplementedError("{cls}Gateway.ping_{var} chưa được viết")
|
|
339
|
+
'''
|
|
340
|
+
return files
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _write(target: Path, files: dict[str, str]) -> None:
|
|
344
|
+
for relative, content in files.items():
|
|
345
|
+
path = target / relative
|
|
346
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
347
|
+
path.write_text(content, encoding="utf-8")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def render_consumer(module: str, entity: str) -> dict[str, str]:
|
|
351
|
+
"""Khung consumer RabbitMQ cho một module."""
|
|
352
|
+
cls = pascal(entity)
|
|
353
|
+
var = entity
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
f"{var}_consumer.py": f'''"""Consumer nền của module {cls} (@EventPattern).
|
|
357
|
+
|
|
358
|
+
Hàng đợi BỀN và có TÊN, nên nhiều worker chia nhau xử lý — mỗi tin đúng một
|
|
359
|
+
worker làm. Dùng cho việc phải làm ĐÚNG MỘT LẦN: gửi mail, ghi sổ, gọi dịch vụ
|
|
360
|
+
ngoài. Cần đẩy tin cho MỌI worker (ví dụ xuống WebSocket) thì dùng cầu nối
|
|
361
|
+
`event.subscribe`, không phải chỗ này — xem docs/rabbitmq.md.
|
|
362
|
+
|
|
363
|
+
RabbitMQ tắt (mặc định) thì file này nằm im, không tạo hàng đợi nào.
|
|
364
|
+
"""
|
|
365
|
+
|
|
366
|
+
from __future__ import annotations
|
|
367
|
+
|
|
368
|
+
from typing import Any
|
|
369
|
+
|
|
370
|
+
from pymodular.core.container import injectable
|
|
371
|
+
from pymodular.core.logging import get_logger
|
|
372
|
+
from pymodular.infrastructure.rabbitmq import rabbitmq_subscriber
|
|
373
|
+
from src.api.{module}.{var}_service import {cls}Service
|
|
374
|
+
|
|
375
|
+
log = get_logger(__name__)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@injectable
|
|
379
|
+
class {cls}Consumer:
|
|
380
|
+
def __init__(self, service: {cls}Service) -> None:
|
|
381
|
+
self._service = service
|
|
382
|
+
|
|
383
|
+
# Mọi chính sách của consumer khai ngay ở đây, không phải trong .env:
|
|
384
|
+
# max_retries=5, retry_delay=60, dead_letter=False, durable=False, prefetch=200
|
|
385
|
+
# Mặc định: đúng MỘT hàng đợi. Thêm max_retries=3, dead_letter=True nếu tin
|
|
386
|
+
# này đáng tiền (đơn hàng, thanh toán) — khi đó mới có <queue>.retry/.dlq.
|
|
387
|
+
@rabbitmq_subscriber("events", "{var}.#", queue="{module}-worker")
|
|
388
|
+
async def handle_{var}(self, payload: dict, meta: dict[str, Any]) -> None:
|
|
389
|
+
"""Nhận mọi sự kiện `{var}.*` trên exchange `events`.
|
|
390
|
+
|
|
391
|
+
Tham số `meta` là tuỳ chọn: bỏ đi nếu không cần routing key thật, số
|
|
392
|
+
lần đã thử, hay message id.
|
|
393
|
+
|
|
394
|
+
Ném lỗi thường -> thử lại tối đa `max_retries` lần (mặc định 3) rồi
|
|
395
|
+
vào hàng đợi chết `{module}-worker.dlq`.
|
|
396
|
+
Ném PermanentMessageError -> vào thẳng hàng đợi chết, không thử lại.
|
|
397
|
+
"""
|
|
398
|
+
# TODO: gợi ý:
|
|
399
|
+
# log.info("{var}.received", routing_key=meta["routing_key"])
|
|
400
|
+
# await self._service.get_{var}(payload["id"])
|
|
401
|
+
raise NotImplementedError("{cls}Consumer.handle_{var} chưa được viết")
|
|
402
|
+
'''
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def main(argv: list[str] | None = None) -> int:
|
|
407
|
+
parser = argparse.ArgumentParser(description="Sinh khung module nghiệp vụ")
|
|
408
|
+
parser.add_argument("name", help="tên module, dạng số nhiều viết thường: alerts")
|
|
409
|
+
parser.add_argument("--entity", help="tên entity dạng số ít; mặc định đoán từ name")
|
|
410
|
+
parser.add_argument("--root", type=Path, default=DEFAULT_ROOT)
|
|
411
|
+
parser.add_argument(
|
|
412
|
+
"--gateway", action="store_true", help="tạo kèm gateway WebSocket"
|
|
413
|
+
)
|
|
414
|
+
parser.add_argument(
|
|
415
|
+
"--gateway-only",
|
|
416
|
+
action="store_true",
|
|
417
|
+
help="chỉ thêm gateway WebSocket vào module đã có",
|
|
418
|
+
)
|
|
419
|
+
parser.add_argument(
|
|
420
|
+
"--consumer", action="store_true", help="tạo kèm consumer RabbitMQ"
|
|
421
|
+
)
|
|
422
|
+
parser.add_argument(
|
|
423
|
+
"--consumer-only",
|
|
424
|
+
action="store_true",
|
|
425
|
+
help="chỉ thêm consumer RabbitMQ vào module đã có",
|
|
426
|
+
)
|
|
427
|
+
args = parser.parse_args(argv)
|
|
428
|
+
|
|
429
|
+
module = args.name.strip().lower().replace("-", "_")
|
|
430
|
+
if not re.fullmatch(r"[a-z][a-z0-9_]*", module):
|
|
431
|
+
print(f"Tên module không hợp lệ: {args.name!r}. Chỉ dùng chữ thường, số và _.")
|
|
432
|
+
return 1
|
|
433
|
+
|
|
434
|
+
entity = (args.entity or singular(module)).strip().lower().replace("-", "_")
|
|
435
|
+
|
|
436
|
+
target = args.root / module
|
|
437
|
+
|
|
438
|
+
if args.gateway_only or args.consumer_only:
|
|
439
|
+
if not target.exists():
|
|
440
|
+
print(f"Chưa có module {target}. Tạo trước bằng: pym module {module}")
|
|
441
|
+
return 1
|
|
442
|
+
files = render_gateway(module, entity) if args.gateway_only else render_consumer(module, entity)
|
|
443
|
+
trung = [rel for rel in files if (target / rel).exists()]
|
|
444
|
+
if trung:
|
|
445
|
+
print(f"Đã có sẵn: {', '.join(str(target / rel) for rel in trung)}")
|
|
446
|
+
return 1
|
|
447
|
+
_write(target, files)
|
|
448
|
+
loai = "gateway WebSocket" if args.gateway_only else "consumer RabbitMQ"
|
|
449
|
+
print(f"Đã thêm {loai} vào module '{module}':")
|
|
450
|
+
for relative in sorted(files):
|
|
451
|
+
print(f" {target / relative}")
|
|
452
|
+
print()
|
|
453
|
+
print("Việc tiếp theo:")
|
|
454
|
+
if args.gateway_only:
|
|
455
|
+
print(f" 1. Viết thân các handler trong {entity}_gateway.py")
|
|
456
|
+
print(f" 2. pym dev, rồi nối thử: ws://localhost:8000/ws/{module}?client_id=an")
|
|
457
|
+
print(" 3. Xem docs/websocket.md (có ví dụ Postman và Next.js)")
|
|
458
|
+
else:
|
|
459
|
+
print(f" 1. Viết thân handler trong {entity}_consumer.py")
|
|
460
|
+
print(" 2. pip install \'fastapi-modular[rabbitmq]\' rồi APP_RABBITMQ__ENABLED=true")
|
|
461
|
+
print(" 3. Xem docs/rabbitmq.md")
|
|
462
|
+
return 0
|
|
463
|
+
|
|
464
|
+
if target.exists():
|
|
465
|
+
print(f"Đã có {target} rồi. Xoá đi hoặc chọn tên khác.")
|
|
466
|
+
return 1
|
|
467
|
+
|
|
468
|
+
files = render(module, entity)
|
|
469
|
+
if args.gateway:
|
|
470
|
+
files.update(render_gateway(module, entity))
|
|
471
|
+
if args.consumer:
|
|
472
|
+
files.update(render_consumer(module, entity))
|
|
473
|
+
_write(target, files)
|
|
474
|
+
|
|
475
|
+
print(f"Đã tạo module '{module}' (entity {pascal(entity)}) tại {target}:")
|
|
476
|
+
for relative in sorted(files):
|
|
477
|
+
print(f" {target / relative}")
|
|
478
|
+
print()
|
|
479
|
+
print("Việc tiếp theo:")
|
|
480
|
+
print(f" 1. Thêm trường vào entities/{entity}_model.py và dto/{entity}_dto.py")
|
|
481
|
+
print(" 2. Khai unique/indexes trong @entity nếu cần")
|
|
482
|
+
print(f" 3. Viết thân các hàm trong {entity}_service.py (đang raise NotImplementedError)")
|
|
483
|
+
print(" 4. pym dev — route đã tự xuất hiện, không phải đăng ký ở đâu cả")
|
|
484
|
+
if args.gateway:
|
|
485
|
+
print(f" 5. Viết thân handler trong {entity}_gateway.py (xem docs/websocket.md)")
|
|
486
|
+
if args.consumer:
|
|
487
|
+
print(f" 6. Viết thân handler trong {entity}_consumer.py (xem docs/rabbitmq.md)")
|
|
488
|
+
return 0
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
if __name__ == "__main__":
|
|
492
|
+
raise SystemExit(main())
|