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,354 @@
|
|
|
1
|
+
"""Container DI kiểu NestJS: đăng ký provider, tự nối phụ thuộc theo kiểu.
|
|
2
|
+
|
|
3
|
+
Bốn thứ cần biết:
|
|
4
|
+
|
|
5
|
+
1. `@injectable` — đánh dấu một class là provider (tương đương @Injectable()).
|
|
6
|
+
Class được ghi vào sổ đăng ký theo TÊN, nên container tra cứu được mà
|
|
7
|
+
không cần file nào import file nào.
|
|
8
|
+
|
|
9
|
+
2. `container.resolve(X)` — lấy instance của X, tự khởi tạo mọi phụ thuộc mà
|
|
10
|
+
__init__ của nó khai báo.
|
|
11
|
+
|
|
12
|
+
3. `Lazy[X]` — tương đương forwardRef(() => X). Dùng khi hai provider cần
|
|
13
|
+
nhau: phụ thuộc được thay bằng proxy, chỉ resolve thật lúc gọi method đầu
|
|
14
|
+
tiên. Nhờ vậy không có vòng tròn lúc khởi tạo.
|
|
15
|
+
|
|
16
|
+
4. `@injectable(scope=Scope.REQUEST)` — tương đương Scope.REQUEST của Nest.
|
|
17
|
+
Instance sống trong một request, bị dọn khi request kết thúc. Dùng cho
|
|
18
|
+
session/transaction database, thông tin người dùng hiện tại...
|
|
19
|
+
|
|
20
|
+
Vì sao không cần import chéo: dưới `from __future__ import annotations`, mọi
|
|
21
|
+
annotation là chuỗi. `Lazy[DeviceService]` ở module User chỉ là chữ, không
|
|
22
|
+
phải tham chiếu — nên User không phải import Device. Đặt import thật trong
|
|
23
|
+
khối `if TYPE_CHECKING` để IDE và mypy vẫn hiểu kiểu.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import inspect
|
|
29
|
+
from collections.abc import AsyncIterator, Callable, Sequence
|
|
30
|
+
from contextlib import asynccontextmanager
|
|
31
|
+
from contextvars import ContextVar
|
|
32
|
+
from typing import Annotated, Any, TypeVar, get_args, get_origin
|
|
33
|
+
|
|
34
|
+
from pymodular.core.compat import StrEnum
|
|
35
|
+
from pymodular.core.logging import get_logger
|
|
36
|
+
|
|
37
|
+
log = get_logger(__name__)
|
|
38
|
+
|
|
39
|
+
T = TypeVar("T")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Scope(StrEnum):
|
|
43
|
+
SINGLETON = "singleton"
|
|
44
|
+
REQUEST = "request"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Sổ đăng ký toàn cục: tên class -> class. @injectable ghi vào đây lúc import.
|
|
48
|
+
_REGISTRY: dict[str, type] = {}
|
|
49
|
+
_SCOPES: dict[str, Scope] = {}
|
|
50
|
+
|
|
51
|
+
# Entity không phải provider (không tự khởi tạo được) nhưng cần tra theo tên để
|
|
52
|
+
# làm tham số kiểu cho provider generic, ví dụ Repository[User].
|
|
53
|
+
_ENTITIES: dict[str, type] = {}
|
|
54
|
+
|
|
55
|
+
_LAZY_MARKER = "__container_lazy__"
|
|
56
|
+
|
|
57
|
+
# Lazy[X] chỉ là Annotated[X, marker]. Theo PEP 593, type checker và IDE coi
|
|
58
|
+
# Annotated[X, ...] hệt như X — nên `self._devices` vẫn gợi ý được method của
|
|
59
|
+
# DeviceService, trong khi container đọc marker để biết cần resolve muộn.
|
|
60
|
+
# Tương đương forwardRef của NestJS.
|
|
61
|
+
Lazy = Annotated[T, _LAZY_MARKER]
|
|
62
|
+
|
|
63
|
+
# Kho chứa instance request-scoped của request đang chạy. None = ngoài request.
|
|
64
|
+
_request_store: ContextVar[dict[str, Any] | None] = ContextVar(
|
|
65
|
+
"container_request_store", default=None
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def injectable(
|
|
70
|
+
cls: type[T] | None = None, *, scope: Scope = Scope.SINGLETON
|
|
71
|
+
) -> Any:
|
|
72
|
+
"""Đăng ký class làm provider. Dùng được cả `@injectable` lẫn `@injectable(...)`."""
|
|
73
|
+
|
|
74
|
+
def decorate(target: type[T]) -> type[T]:
|
|
75
|
+
name = target.__name__
|
|
76
|
+
existing = _REGISTRY.get(name)
|
|
77
|
+
if existing is not None and existing is not target:
|
|
78
|
+
raise RuntimeError(
|
|
79
|
+
f"Trùng tên provider '{name}': {existing.__module__} và {target.__module__}. "
|
|
80
|
+
"Đổi tên một trong hai — container tra cứu theo tên class."
|
|
81
|
+
)
|
|
82
|
+
_REGISTRY[name] = target
|
|
83
|
+
_SCOPES[name] = scope
|
|
84
|
+
return target
|
|
85
|
+
|
|
86
|
+
return decorate if cls is None else decorate(cls)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _as_column_groups(
|
|
90
|
+
declared: Sequence[str | Sequence[str]],
|
|
91
|
+
) -> tuple[tuple[str, ...], ...]:
|
|
92
|
+
"""Đưa cả cột đơn lẫn cụm cột về cùng một dạng: tuple của tuple."""
|
|
93
|
+
groups: list[tuple[str, ...]] = []
|
|
94
|
+
for item in declared:
|
|
95
|
+
groups.append((item,) if isinstance(item, str) else tuple(item))
|
|
96
|
+
return tuple(groups)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def entity(
|
|
100
|
+
cls: type[T] | None = None,
|
|
101
|
+
*,
|
|
102
|
+
name: str | None = None,
|
|
103
|
+
unique: Sequence[str | Sequence[str]] = (),
|
|
104
|
+
indexes: Sequence[str | Sequence[str]] = (),
|
|
105
|
+
) -> Any:
|
|
106
|
+
"""Đánh dấu class là entity (tương đương @Entity của TypeORM).
|
|
107
|
+
|
|
108
|
+
- `name` : tên bảng/collection; mặc định là tên class viết thường + "s".
|
|
109
|
+
- `unique` : các trường phải duy nhất. Ràng buộc được tạo DƯỚI DATABASE,
|
|
110
|
+
không chỉ kiểm tra trong service — kiểm tra rồi mới ghi là
|
|
111
|
+
một cuộc đua: hai request đồng thời đều thấy "chưa có" rồi
|
|
112
|
+
cùng ghi.
|
|
113
|
+
- `indexes` : các trường hay dùng để lọc, tạo index thường.
|
|
114
|
+
|
|
115
|
+
Mỗi phần tử là MỘT cột (chuỗi) hoặc MỘT CỤM cột (tuple/list):
|
|
116
|
+
|
|
117
|
+
@entity(
|
|
118
|
+
unique=["serial", ("owner_id", "name")], # cụm: duy nhất theo cặp
|
|
119
|
+
indexes=[("owner_id", "status")], # cụm: lọc theo cả hai
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
Với cụm, THỨ TỰ cột rất quan trọng — xem docs/database.md.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def decorate(target: type[T]) -> type[T]:
|
|
126
|
+
_ENTITIES[target.__name__] = target
|
|
127
|
+
target.__storage_name__ = name or f"{target.__name__.lower()}s" # type: ignore[attr-defined]
|
|
128
|
+
target.__storage_unique__ = _as_column_groups(unique) # type: ignore[attr-defined]
|
|
129
|
+
target.__storage_indexes__ = _as_column_groups(indexes) # type: ignore[attr-defined]
|
|
130
|
+
return target
|
|
131
|
+
|
|
132
|
+
return decorate if cls is None else decorate(cls)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _parse_annotation(annotation: Any) -> tuple[str, tuple[str, ...], bool]:
|
|
136
|
+
"""Đọc annotation -> (tên provider, tham số kiểu, có lazy hay không).
|
|
137
|
+
|
|
138
|
+
"Repository[User]" -> ("Repository", ("User",), False)
|
|
139
|
+
"Lazy[DeviceService]" -> ("DeviceService", (), True)
|
|
140
|
+
"UserRepository" -> ("UserRepository", (), False)
|
|
141
|
+
"""
|
|
142
|
+
if not isinstance(annotation, str):
|
|
143
|
+
metadata = getattr(annotation, "__metadata__", ())
|
|
144
|
+
if metadata:
|
|
145
|
+
inner = get_args(annotation)[0]
|
|
146
|
+
name, args, _ = _parse_annotation(inner)
|
|
147
|
+
return name, args, _LAZY_MARKER in metadata
|
|
148
|
+
|
|
149
|
+
origin = get_origin(annotation)
|
|
150
|
+
if origin is not None:
|
|
151
|
+
args = tuple(getattr(a, "__name__", str(a)) for a in get_args(annotation))
|
|
152
|
+
return getattr(origin, "__name__", str(origin)), args, False
|
|
153
|
+
return getattr(annotation, "__name__", str(annotation)), (), False
|
|
154
|
+
|
|
155
|
+
text = annotation.strip().strip("'\"")
|
|
156
|
+
lazy = False
|
|
157
|
+
if text.startswith("Lazy[") and text.endswith("]"):
|
|
158
|
+
text = text[len("Lazy[") : -1].strip().strip("'\"")
|
|
159
|
+
lazy = True
|
|
160
|
+
|
|
161
|
+
if text.endswith("]") and "[" in text:
|
|
162
|
+
base, _, inside = text.partition("[")
|
|
163
|
+
args = tuple(a.strip().strip("'\"") for a in inside[:-1].split(","))
|
|
164
|
+
return base.strip(), args, lazy
|
|
165
|
+
|
|
166
|
+
return text, (), lazy
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class _LazyProxy:
|
|
170
|
+
"""Đứng thay cho provider thật; resolve ở lần truy cập thuộc tính đầu tiên."""
|
|
171
|
+
|
|
172
|
+
__slots__ = ("_container", "_name")
|
|
173
|
+
|
|
174
|
+
def __init__(self, container: Container, name: str) -> None:
|
|
175
|
+
object.__setattr__(self, "_container", container)
|
|
176
|
+
object.__setattr__(self, "_name", name)
|
|
177
|
+
|
|
178
|
+
def __getattr__(self, item: str) -> Any:
|
|
179
|
+
container = object.__getattribute__(self, "_container")
|
|
180
|
+
name = object.__getattribute__(self, "_name")
|
|
181
|
+
return getattr(container.resolve(name), item)
|
|
182
|
+
|
|
183
|
+
def __repr__(self) -> str:
|
|
184
|
+
return f"<Lazy {object.__getattribute__(self, '_name')}>"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class Container:
|
|
188
|
+
def __init__(self) -> None:
|
|
189
|
+
self._instances: dict[str, Any] = {}
|
|
190
|
+
self._building: list[str] = []
|
|
191
|
+
|
|
192
|
+
# ------------------------------------------------------------------ resolve
|
|
193
|
+
def resolve(self, token: type[T] | str, type_args: tuple[str, ...] = ()) -> T:
|
|
194
|
+
name = token if isinstance(token, str) else token.__name__
|
|
195
|
+
key = f"{name}[{','.join(type_args)}]" if type_args else name
|
|
196
|
+
scope = _SCOPES.get(name, Scope.SINGLETON)
|
|
197
|
+
|
|
198
|
+
store = self._store_for(scope, key)
|
|
199
|
+
if key in store:
|
|
200
|
+
return store[key]
|
|
201
|
+
|
|
202
|
+
cls = _REGISTRY.get(name)
|
|
203
|
+
if cls is None:
|
|
204
|
+
raise RuntimeError(
|
|
205
|
+
f"Không có provider '{name}'. Thiếu @injectable, hoặc module chứa nó "
|
|
206
|
+
"chưa được nạp — package ứng dụng phải nằm trong thư mục mà "
|
|
207
|
+
"create_app(package=...) quét tới."
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if key in self._building:
|
|
211
|
+
chain = " -> ".join([*self._building, key])
|
|
212
|
+
raise RuntimeError(
|
|
213
|
+
f"Vòng tròn phụ thuộc lúc khởi tạo: {chain}. "
|
|
214
|
+
f"Đổi một cạnh sang Lazy[...] để cắt vòng."
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Tham số kiểu (Repository[User]) được truyền vào __init__ theo thứ tự,
|
|
218
|
+
# trước các phụ thuộc tự nối.
|
|
219
|
+
positional: list[type] = []
|
|
220
|
+
for arg in type_args:
|
|
221
|
+
resolved = _ENTITIES.get(arg) or _REGISTRY.get(arg)
|
|
222
|
+
if resolved is None:
|
|
223
|
+
raise RuntimeError(
|
|
224
|
+
f"Không biết kiểu '{arg}' trong '{key}'. Thiếu @entity trên class {arg}?"
|
|
225
|
+
)
|
|
226
|
+
positional.append(resolved)
|
|
227
|
+
|
|
228
|
+
self._building.append(key)
|
|
229
|
+
try:
|
|
230
|
+
instance = cls(*positional, **self._build_kwargs(cls, scope, skip=len(positional)))
|
|
231
|
+
finally:
|
|
232
|
+
self._building.pop()
|
|
233
|
+
|
|
234
|
+
store[key] = instance
|
|
235
|
+
log.debug("container.provider_created", provider=key, scope=scope.value)
|
|
236
|
+
return instance
|
|
237
|
+
|
|
238
|
+
def _store_for(self, scope: Scope, key: str) -> dict[str, Any]:
|
|
239
|
+
if scope is Scope.SINGLETON:
|
|
240
|
+
return self._instances
|
|
241
|
+
|
|
242
|
+
store = _request_store.get()
|
|
243
|
+
if store is None:
|
|
244
|
+
raise RuntimeError(
|
|
245
|
+
f"'{key}' là provider request-scoped nhưng không có request scope nào đang mở. "
|
|
246
|
+
"Bọc lời gọi trong `async with request_scope():` (endpoint đã tự làm việc này)."
|
|
247
|
+
)
|
|
248
|
+
return store
|
|
249
|
+
|
|
250
|
+
def _build_kwargs(self, cls: type, scope: Scope, skip: int = 0) -> dict[str, Any]:
|
|
251
|
+
init = getattr(cls, "__init__", None)
|
|
252
|
+
if init is None or init is object.__init__:
|
|
253
|
+
return {}
|
|
254
|
+
|
|
255
|
+
params = list(inspect.signature(init).parameters.values())[1 + skip :]
|
|
256
|
+
kwargs: dict[str, Any] = {}
|
|
257
|
+
for param in params:
|
|
258
|
+
if param.annotation is inspect.Parameter.empty:
|
|
259
|
+
if param.default is inspect.Parameter.empty:
|
|
260
|
+
raise RuntimeError(
|
|
261
|
+
f"{cls.__name__}.__init__ thiếu annotation cho tham số "
|
|
262
|
+
f"'{param.name}' nên container không biết nối gì vào."
|
|
263
|
+
)
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
dep_name, dep_args, lazy = _parse_annotation(param.annotation)
|
|
267
|
+
|
|
268
|
+
# Tham số có giá trị mặc định mà kiểu của nó không phải provider nào
|
|
269
|
+
# (int, str | None, frozenset...) thì đó là giá trị cấu hình thường,
|
|
270
|
+
# không phải phụ thuộc — cứ để nguyên mặc định.
|
|
271
|
+
known = dep_name in _REGISTRY or dep_name in _ENTITIES
|
|
272
|
+
if not known and param.default is not inspect.Parameter.empty:
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
# Singleton giữ tham chiếu tới instance request-scoped sẽ rò rỉ dữ
|
|
276
|
+
# liệu của request này sang request khác. Chặn ngay lúc khởi tạo.
|
|
277
|
+
if (
|
|
278
|
+
scope is Scope.SINGLETON
|
|
279
|
+
and _SCOPES.get(dep_name, Scope.SINGLETON) is Scope.REQUEST
|
|
280
|
+
and not lazy
|
|
281
|
+
):
|
|
282
|
+
raise RuntimeError(
|
|
283
|
+
f"{cls.__name__} là singleton nhưng phụ thuộc '{dep_name}' là "
|
|
284
|
+
"request-scoped. Hoặc cho nó scope=Scope.REQUEST, hoặc gọi "
|
|
285
|
+
"container.resolve() ngay trong method thay vì nhận qua __init__."
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
kwargs[param.name] = (
|
|
289
|
+
_LazyProxy(self, dep_name) if lazy else self.resolve(dep_name, dep_args)
|
|
290
|
+
)
|
|
291
|
+
return kwargs
|
|
292
|
+
|
|
293
|
+
# -------------------------------------------------------------------- tiện ích
|
|
294
|
+
@property
|
|
295
|
+
def registered(self) -> list[str]:
|
|
296
|
+
"""Tên mọi provider đã đăng ký — tiện log lúc boot để soi module nào nạp."""
|
|
297
|
+
return list(_REGISTRY)
|
|
298
|
+
|
|
299
|
+
def scope_of(self, token: type | str) -> Scope:
|
|
300
|
+
name = token if isinstance(token, str) else token.__name__
|
|
301
|
+
return _SCOPES.get(name, Scope.SINGLETON)
|
|
302
|
+
|
|
303
|
+
def override(self, token: type | str, instance: Any) -> None:
|
|
304
|
+
"""Cắm sẵn một instance — dùng cho test (tương đương overrideProvider)."""
|
|
305
|
+
name = token if isinstance(token, str) else token.__name__
|
|
306
|
+
if _SCOPES.get(name, Scope.SINGLETON) is Scope.REQUEST:
|
|
307
|
+
store = _request_store.get()
|
|
308
|
+
if store is not None:
|
|
309
|
+
store[name] = instance
|
|
310
|
+
return
|
|
311
|
+
self._instances[name] = instance
|
|
312
|
+
|
|
313
|
+
def reset(self) -> None:
|
|
314
|
+
self._instances.clear()
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
container = Container()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@asynccontextmanager
|
|
321
|
+
async def request_scope() -> AsyncIterator[dict[str, Any]]:
|
|
322
|
+
"""Mở một vùng đời request cho các provider Scope.REQUEST.
|
|
323
|
+
|
|
324
|
+
Lúc đóng, mọi instance có method `on_request_end(error)` sẽ được gọi theo
|
|
325
|
+
thứ tự ngược với lúc tạo — chỗ để commit hoặc rollback transaction.
|
|
326
|
+
"""
|
|
327
|
+
store: dict[str, Any] = {}
|
|
328
|
+
token = _request_store.set(store)
|
|
329
|
+
error: BaseException | None = None
|
|
330
|
+
try:
|
|
331
|
+
yield store
|
|
332
|
+
except BaseException as exc:
|
|
333
|
+
error = exc
|
|
334
|
+
raise
|
|
335
|
+
finally:
|
|
336
|
+
for instance in reversed(list(store.values())):
|
|
337
|
+
hook: Callable[..., Any] | None = getattr(instance, "on_request_end", None)
|
|
338
|
+
if hook is not None:
|
|
339
|
+
await hook(error)
|
|
340
|
+
_request_store.reset(token)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def Inject(token: type[T] | str) -> Any:
|
|
344
|
+
"""Cầu nối container -> FastAPI Depends.
|
|
345
|
+
|
|
346
|
+
Dùng trong router hàm: service: Annotated[UserService, Inject(UserService)]
|
|
347
|
+
Controller dạng class không cần cái này.
|
|
348
|
+
"""
|
|
349
|
+
from fastapi import Depends
|
|
350
|
+
|
|
351
|
+
def _provide() -> Any:
|
|
352
|
+
return container.resolve(token)
|
|
353
|
+
|
|
354
|
+
return Depends(_provide)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Context của request, truyền ngầm qua contextvars.
|
|
2
|
+
|
|
3
|
+
Nhờ vậy logger/repository ở tầng sâu vẫn lấy được ``request_id`` mà không phải
|
|
4
|
+
truyền tham số xuyên suốt các lớp.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from contextvars import ContextVar, Token
|
|
11
|
+
|
|
12
|
+
_request_id: ContextVar[str | None] = ContextVar("request_id", default=None)
|
|
13
|
+
_user_id: ContextVar[str | None] = ContextVar("user_id", default=None)
|
|
14
|
+
_trace_id: ContextVar[str | None] = ContextVar("trace_id", default=None)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def new_request_id() -> str:
|
|
18
|
+
return uuid.uuid4().hex
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ------------------------------------------------------------------- trace id
|
|
22
|
+
#
|
|
23
|
+
# `request_id` là của riêng dịch vụ này; `trace_id` đi xuyên qua mọi dịch vụ
|
|
24
|
+
# trong một hành trình, theo chuẩn W3C Trace Context. Header `traceparent` có
|
|
25
|
+
# dạng: 00-<trace_id 32 hex>-<span_id 16 hex>-<cờ 2 hex>
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def new_trace_id() -> str:
|
|
29
|
+
return uuid.uuid4().hex # 32 ký tự hex, đúng khuôn W3C
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_traceparent(header: str | None) -> str | None:
|
|
33
|
+
"""Lấy trace_id từ header traceparent; trả None nếu header sai khuôn."""
|
|
34
|
+
if not header:
|
|
35
|
+
return None
|
|
36
|
+
parts = header.split("-")
|
|
37
|
+
if len(parts) < 4 or len(parts[1]) != 32:
|
|
38
|
+
return None
|
|
39
|
+
trace_id = parts[1]
|
|
40
|
+
if not all(c in "0123456789abcdef" for c in trace_id) or trace_id == "0" * 32:
|
|
41
|
+
return None
|
|
42
|
+
return trace_id
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_trace_id() -> str | None:
|
|
46
|
+
return _trace_id.get()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def set_trace_id(value: str) -> Token[str | None]:
|
|
50
|
+
return _trace_id.set(value)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def reset_trace_id(token: Token[str | None]) -> None:
|
|
54
|
+
_trace_id.reset(token)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_request_id() -> str | None:
|
|
58
|
+
return _request_id.get()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def set_request_id(value: str) -> Token[str | None]:
|
|
62
|
+
return _request_id.set(value)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def reset_request_id(token: Token[str | None]) -> None:
|
|
66
|
+
_request_id.reset(token)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_user_id() -> str | None:
|
|
70
|
+
return _user_id.get()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def set_user_id(value: str | None) -> Token[str | None]:
|
|
74
|
+
return _user_id.set(value)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def reset_user_id(token: Token[str | None]) -> None:
|
|
78
|
+
_user_id.reset(token)
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Controller dạng class — tương đương @Controller của NestJS.
|
|
2
|
+
|
|
3
|
+
Thay vì nhét service vào từng handler:
|
|
4
|
+
|
|
5
|
+
async def list_users(service: UserServiceDep, limit: int = 20): ...
|
|
6
|
+
|
|
7
|
+
thì khai báo một lần ở __init__ như Nest:
|
|
8
|
+
|
|
9
|
+
@controller(prefix="/users", tags=["users"])
|
|
10
|
+
class UserController:
|
|
11
|
+
def __init__(self, service: UserService) -> None:
|
|
12
|
+
self._service = service
|
|
13
|
+
|
|
14
|
+
@get("", response_model=Page[UserOut])
|
|
15
|
+
async def list_users(self, limit: int = 20): ...
|
|
16
|
+
|
|
17
|
+
router = build_router(UserController)
|
|
18
|
+
|
|
19
|
+
Cách hoạt động: mỗi method có metadata route được bọc thành một endpoint không
|
|
20
|
+
còn tham số `self`; lúc chạy, `self` được lấy từ container. Nhờ vậy FastAPI
|
|
21
|
+
vẫn thấy đúng chữ ký để sinh OpenAPI và validate, còn phụ thuộc thì do
|
|
22
|
+
container nối — không lẫn hai cơ chế DI vào nhau.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import functools
|
|
28
|
+
import inspect
|
|
29
|
+
from collections.abc import Callable, Sequence
|
|
30
|
+
from typing import Any, TypeVar, get_type_hints
|
|
31
|
+
|
|
32
|
+
from fastapi import APIRouter
|
|
33
|
+
from starlette.requests import Request
|
|
34
|
+
|
|
35
|
+
from pymodular.core.container import container, injectable, request_scope
|
|
36
|
+
from pymodular.core.logging import get_logger
|
|
37
|
+
|
|
38
|
+
log = get_logger(__name__)
|
|
39
|
+
|
|
40
|
+
T = TypeVar("T")
|
|
41
|
+
|
|
42
|
+
_ROUTE_ATTR = "__route_meta__"
|
|
43
|
+
_GUARDS_ATTR = "__guards__"
|
|
44
|
+
_REQUEST_PARAM = "__guard_request"
|
|
45
|
+
_CONTROLLER_ATTR = "__controller_meta__"
|
|
46
|
+
|
|
47
|
+
# Sổ controller theo đúng thứ tự khai báo (= thứ tự import). app.py đọc sổ này
|
|
48
|
+
# để dựng router, nên module không phải tự export biến `router` nào cả.
|
|
49
|
+
_CONTROLLERS: list[type] = []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def controller(
|
|
53
|
+
*,
|
|
54
|
+
prefix: str = "",
|
|
55
|
+
tags: list[str] | None = None,
|
|
56
|
+
guards: Sequence[type] = (),
|
|
57
|
+
**router_kwargs: Any,
|
|
58
|
+
) -> Callable[[type[T]], type[T]]:
|
|
59
|
+
"""Đánh dấu class là controller và đăng ký nó làm provider.
|
|
60
|
+
|
|
61
|
+
`guards` áp cho MỌI route của controller; guard khai báo thêm ở từng route
|
|
62
|
+
sẽ chạy nối tiếp sau.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def decorate(cls: type[T]) -> type[T]:
|
|
66
|
+
setattr(cls, _CONTROLLER_ATTR, {"prefix": prefix, "tags": tags, **router_kwargs})
|
|
67
|
+
setattr(cls, _GUARDS_ATTR, tuple(guards))
|
|
68
|
+
_CONTROLLERS.append(cls)
|
|
69
|
+
return injectable(cls)
|
|
70
|
+
|
|
71
|
+
return decorate
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def controllers_in(package: str) -> list[type]:
|
|
75
|
+
"""Mọi controller được khai báo bên trong một package, theo thứ tự import."""
|
|
76
|
+
return [
|
|
77
|
+
cls
|
|
78
|
+
for cls in _CONTROLLERS
|
|
79
|
+
if cls.__module__ == package or cls.__module__.startswith(f"{package}.")
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def route(
|
|
84
|
+
method: str, path: str, *, guards: Sequence[type] = (), **kwargs: Any
|
|
85
|
+
) -> Callable[[Callable], Callable]:
|
|
86
|
+
"""Gắn metadata route lên method. Dùng qua get/post/put/patch/delete."""
|
|
87
|
+
|
|
88
|
+
def decorate(fn: Callable) -> Callable:
|
|
89
|
+
setattr(fn, _ROUTE_ATTR, {"path": path, "methods": [method], **kwargs})
|
|
90
|
+
setattr(fn, _GUARDS_ATTR, tuple(guards))
|
|
91
|
+
return fn
|
|
92
|
+
|
|
93
|
+
return decorate
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get(path: str = "", **kwargs: Any):
|
|
97
|
+
return route("GET", path, **kwargs)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def post(path: str = "", **kwargs: Any):
|
|
101
|
+
return route("POST", path, **kwargs)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def put(path: str = "", **kwargs: Any):
|
|
105
|
+
return route("PUT", path, **kwargs)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def patch(path: str = "", **kwargs: Any):
|
|
109
|
+
return route("PATCH", path, **kwargs)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def delete(path: str = "", **kwargs: Any):
|
|
113
|
+
return route("DELETE", path, **kwargs)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _make_endpoint(cls: type, fn: Callable, guards: Sequence[type] = ()) -> Callable:
|
|
117
|
+
"""Bọc method thành endpoint FastAPI, bỏ `self` khỏi chữ ký."""
|
|
118
|
+
# Giải annotation NGAY tại đây, bằng globals của module chứa controller.
|
|
119
|
+
# Nếu để nguyên chuỗi, FastAPI sẽ giải bằng globals của file này và không
|
|
120
|
+
# tìm thấy Page/UserOut/... của module kia.
|
|
121
|
+
hints = get_type_hints(fn, include_extras=True)
|
|
122
|
+
signature = inspect.signature(fn)
|
|
123
|
+
|
|
124
|
+
params = [
|
|
125
|
+
p.replace(annotation=hints.get(p.name, p.annotation))
|
|
126
|
+
for p in list(signature.parameters.values())[1:] # bỏ self
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
if guards:
|
|
130
|
+
# Thêm một tham số Request để guard soi được header/đường dẫn. FastAPI
|
|
131
|
+
# điền tham số kiểu Request và KHÔNG đưa nó vào OpenAPI, nên hợp đồng
|
|
132
|
+
# API không đổi. Đặt KEYWORD_ONLY để không phá thứ tự tham số có sẵn.
|
|
133
|
+
params.append(
|
|
134
|
+
inspect.Parameter(
|
|
135
|
+
_REQUEST_PARAM, inspect.Parameter.KEYWORD_ONLY, annotation=Request
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
@functools.wraps(fn)
|
|
140
|
+
async def endpoint(*args: Any, **kwargs: Any) -> Any:
|
|
141
|
+
request = kwargs.pop(_REQUEST_PARAM, None)
|
|
142
|
+
|
|
143
|
+
# Mở request scope quanh handler, KHÔNG đặt ở middleware: dọn dẹp của
|
|
144
|
+
# middleware chạy sau khi response đã gửi đi, nên transaction sẽ commit
|
|
145
|
+
# muộn hơn lúc client nhận kết quả (xem ghi chú ở middleware/request_context.py).
|
|
146
|
+
# Ở đây commit xảy ra trước khi FastAPI serialize và gửi response.
|
|
147
|
+
async with request_scope():
|
|
148
|
+
# Guard chạy TRONG request scope để chúng dùng được provider
|
|
149
|
+
# request-scoped (ví dụ điền Principal cho request này).
|
|
150
|
+
for guard_cls in guards:
|
|
151
|
+
await container.resolve(guard_cls).check(request)
|
|
152
|
+
return await fn(container.resolve(cls), *args, **kwargs)
|
|
153
|
+
|
|
154
|
+
# get_type_hints đổi `-> None` thành NoneType; FastAPI cần đúng None, nếu
|
|
155
|
+
# không nó coi NoneType là response_model và chặn các route 204.
|
|
156
|
+
returns = hints.get("return", signature.return_annotation)
|
|
157
|
+
if returns is type(None):
|
|
158
|
+
returns = None
|
|
159
|
+
|
|
160
|
+
# __signature__ được đặt sau wraps nên inspect.signature dừng ở đây,
|
|
161
|
+
# không lần ngược về method gốc (vốn vẫn còn `self`).
|
|
162
|
+
endpoint.__signature__ = signature.replace( # type: ignore[attr-defined]
|
|
163
|
+
parameters=params,
|
|
164
|
+
return_annotation=returns,
|
|
165
|
+
)
|
|
166
|
+
return endpoint
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def build_router(*controllers: type) -> APIRouter:
|
|
170
|
+
"""Dựng một APIRouter từ các class controller, theo đúng thứ tự khai báo."""
|
|
171
|
+
root = APIRouter()
|
|
172
|
+
|
|
173
|
+
for cls in controllers:
|
|
174
|
+
meta = getattr(cls, _CONTROLLER_ATTR, None)
|
|
175
|
+
if meta is None:
|
|
176
|
+
raise RuntimeError(f"{cls.__name__} thiếu @controller(...)")
|
|
177
|
+
|
|
178
|
+
sub = APIRouter(**{k: v for k, v in meta.items() if v is not None})
|
|
179
|
+
|
|
180
|
+
# vars() giữ nguyên thứ tự khai báo trong class — quan trọng vì route
|
|
181
|
+
# khớp theo thứ tự đăng ký (/users/me phải đứng trước /users/{id}).
|
|
182
|
+
count = 0
|
|
183
|
+
for fn in vars(cls).values():
|
|
184
|
+
route_meta = getattr(fn, _ROUTE_ATTR, None)
|
|
185
|
+
if route_meta is None:
|
|
186
|
+
continue
|
|
187
|
+
# Guard của controller chạy trước, rồi tới guard của riêng route.
|
|
188
|
+
guards = (*getattr(cls, _GUARDS_ATTR, ()), *getattr(fn, _GUARDS_ATTR, ()))
|
|
189
|
+
sub.add_api_route(
|
|
190
|
+
route_meta["path"],
|
|
191
|
+
_make_endpoint(cls, fn, guards),
|
|
192
|
+
**{k: v for k, v in route_meta.items() if k != "path"},
|
|
193
|
+
)
|
|
194
|
+
count += 1
|
|
195
|
+
|
|
196
|
+
if count == 0:
|
|
197
|
+
# Controller không có method nào mang @get/@post/... thì không sinh
|
|
198
|
+
# route nào cả. Phải kêu, nếu không sẽ là 404 không rõ nguyên nhân.
|
|
199
|
+
log.warning(
|
|
200
|
+
"controller.no_routes",
|
|
201
|
+
controller=cls.__name__,
|
|
202
|
+
module=cls.__module__,
|
|
203
|
+
hint="thiếu @get/@post/@patch/@delete trên method?",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
root.include_router(sub)
|
|
207
|
+
|
|
208
|
+
return root
|