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,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