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,241 @@
1
+ """Một kết nối của một client.
2
+
3
+ `Socket` bọc quanh `WebSocket` của Starlette và thêm ba thứ mà bản trần không
4
+ có, nhưng thiếu chúng thì không chạy nổi ngoài đời:
5
+
6
+ 1. **Hàng đợi gửi + một task ghi duy nhất.** Starlette KHÔNG cho hai task cùng
7
+ `send_text()` trên một kết nối — làm vậy sẽ hỏng khung tin hoặc ném
8
+ RuntimeError. Mà broadcast thì bản chất là nhiều nơi cùng gửi. Nên mọi lời
9
+ gọi `emit()` chỉ bỏ tin vào hàng đợi; đúng một task lấy ra và ghi.
10
+
11
+ 2. **Chống nghẽn (backpressure).** Hàng đợi có TRẦN. Client đọc chậm (mạng 3G,
12
+ tab bị treo) mà server cứ đẩy thì bộ nhớ server phình tới lúc sập — một
13
+ client hỏng kéo sập cả server. Đầy hàng đợi thì xử theo chính sách đã chọn:
14
+ ngắt kết nối (mặc định) hoặc bỏ tin cũ nhất.
15
+
16
+ 3. **Danh tính và phòng.** `user_id` (một người có thể mở nhiều tab = nhiều
17
+ socket) và tập phòng đang tham gia.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import time
24
+ import uuid
25
+ from collections.abc import Awaitable, Callable
26
+ from typing import TYPE_CHECKING, Any
27
+
28
+ from starlette.websockets import WebSocket, WebSocketState
29
+
30
+ from pymodular.core.compat import TimeoutErrors
31
+ from pymodular.core.logging import get_logger
32
+ from pymodular.core.metrics import ws_messages_out, ws_send_dropped
33
+ from pymodular.core.websocket.protocol import CloseCode, Frame
34
+
35
+ if TYPE_CHECKING:
36
+ from pymodular.core.websocket.namespace import Namespace
37
+
38
+ log = get_logger(__name__)
39
+
40
+ # Bỏ vào hàng đợi để báo task ghi dừng lại.
41
+ _STOP = object()
42
+
43
+
44
+ class Socket:
45
+ """Một client đang kết nối. Do framework tạo, code nghiệp vụ chỉ dùng."""
46
+
47
+ __slots__ = (
48
+ "_close_hooks",
49
+ "_closer",
50
+ "_closing",
51
+ "_overflow",
52
+ "_queue",
53
+ "_writer",
54
+ "connected_at",
55
+ "data",
56
+ "id",
57
+ "namespace",
58
+ "roles",
59
+ "rooms",
60
+ "user_id",
61
+ "ws",
62
+ )
63
+
64
+ def __init__(
65
+ self,
66
+ ws: WebSocket,
67
+ namespace: Namespace,
68
+ *,
69
+ user_id: str | None = None,
70
+ roles: frozenset[str] = frozenset(),
71
+ queue_size: int = 100,
72
+ overflow: str = "close",
73
+ ) -> None:
74
+ self.id = uuid.uuid4().hex[:16]
75
+ self.ws = ws
76
+ self.namespace = namespace
77
+ self.user_id = user_id
78
+ self.roles = roles
79
+ self.rooms: set[str] = set()
80
+ # Chỗ để handler ghi trạng thái riêng của kết nối này (tên hiển thị,
81
+ # bộ lọc đang chọn...). Không đụng tới framework.
82
+ self.data: dict[str, Any] = {}
83
+ self.connected_at = time.time()
84
+
85
+ self._queue: asyncio.Queue[Frame | object] = asyncio.Queue(maxsize=queue_size)
86
+ self._overflow = overflow
87
+ self._writer: asyncio.Task[None] | None = None
88
+ self._closer: asyncio.Task[None] | None = None
89
+ self._closing = False
90
+ self._close_hooks: list[Callable[[Socket], Awaitable[None]]] = []
91
+
92
+ # ------------------------------------------------------------------ gửi
93
+ def emit(
94
+ self,
95
+ event: str,
96
+ data: Any = None,
97
+ *,
98
+ ack: str | None = None,
99
+ meta: dict[str, Any] | None = None,
100
+ ) -> bool:
101
+ """Xếp một tin vào hàng đợi gửi. Trả về False nếu tin bị bỏ.
102
+
103
+ KHÔNG phải coroutine: gọi được từ bất cứ đâu (kể cả handler HTTP) mà
104
+ không phải await, và không bao giờ chặn người gọi vì client chậm.
105
+ """
106
+ return self.send(Frame(event=event, data=data, ack=ack, meta=meta))
107
+
108
+ def send(self, frame: Frame) -> bool:
109
+ if self._closing:
110
+ return False
111
+
112
+ try:
113
+ self._queue.put_nowait(frame)
114
+ except asyncio.QueueFull:
115
+ return self._on_full(frame)
116
+
117
+ ws_messages_out.inc(namespace=self.namespace.path, event=frame.event)
118
+ return True
119
+
120
+ def _on_full(self, frame: Frame) -> bool:
121
+ """Hàng đợi đầy: client không theo kịp tốc độ server đẩy."""
122
+ ws_send_dropped.inc(namespace=self.namespace.path)
123
+
124
+ if self._overflow == "drop_oldest":
125
+ # Hợp với dữ liệu "chỉ cần bản mới nhất" (vị trí, nhiệt độ, tiến độ).
126
+ try:
127
+ self._queue.get_nowait()
128
+ self._queue.task_done()
129
+ self._queue.put_nowait(frame)
130
+ return True
131
+ except (asyncio.QueueEmpty, asyncio.QueueFull):
132
+ return False
133
+
134
+ # Mặc định: ngắt kết nối. Client tự nối lại và tải lại trạng thái, như
135
+ # vậy trung thực hơn là âm thầm nuốt tin khiến client tưởng mình vẫn
136
+ # đang đồng bộ.
137
+ log.warning(
138
+ "ws.slow_consumer",
139
+ socket_id=self.id,
140
+ user_id=self.user_id,
141
+ namespace=self.namespace.path,
142
+ queue_size=self._queue.maxsize,
143
+ )
144
+ self.close_soon(CloseCode.TRY_AGAIN_LATER, "client đọc quá chậm")
145
+ return False
146
+
147
+ # ----------------------------------------------------------------- vòng ghi
148
+ def start_writer(self) -> None:
149
+ self._writer = asyncio.create_task(self._writer_loop(), name=f"ws-writer-{self.id}")
150
+
151
+ async def _writer_loop(self) -> None:
152
+ while True:
153
+ item = await self._queue.get()
154
+ if item is _STOP:
155
+ return
156
+ try:
157
+ await self.ws.send_text(item.to_json()) # type: ignore[union-attr]
158
+ except (RuntimeError, ConnectionError, asyncio.CancelledError):
159
+ # Kết nối đã đứt giữa chừng; vòng nhận sẽ dọn dẹp.
160
+ return
161
+ except Exception as exc: # noqa: BLE001 - lỗi khi ghi socket không được giết vòng nhận
162
+ log.warning("ws.send_failed", socket_id=self.id, error=str(exc))
163
+ return
164
+
165
+ async def stop_writer(self) -> None:
166
+ if self._writer is None:
167
+ return
168
+ # Đợi task ghi xả nốt hàng đợi, đừng cắt ngang giữa lúc đang ghi.
169
+ try:
170
+ self._queue.put_nowait(_STOP)
171
+ except asyncio.QueueFull:
172
+ self._writer.cancel()
173
+ try:
174
+ await asyncio.wait_for(asyncio.shield(self._writer), timeout=2.0)
175
+ except (*TimeoutErrors, asyncio.CancelledError):
176
+ self._writer.cancel()
177
+ self._writer = None
178
+
179
+ # ------------------------------------------------------- dọn khi đóng
180
+ def on_close(self, hook: Callable[[Socket], Awaitable[None]]) -> None:
181
+ """Đăng ký việc cần làm khi kết nối này đóng.
182
+
183
+ Đây là chỗ để những lớp NGOÀI framework gắn phần dọn dẹp của mình mà
184
+ không phải sửa vào lõi WebSocket — ví dụ lớp nhắn tin gỡ các đăng ký
185
+ sự kiện của kết nối. Lõi chỉ biết "có việc phải gọi", không biết đó là
186
+ việc gì.
187
+ """
188
+ self._close_hooks.append(hook)
189
+
190
+ async def run_close_hooks(self) -> None:
191
+ for hook in self._close_hooks:
192
+ try:
193
+ await hook(self)
194
+ except Exception as exc: # noqa: BLE001 - một hook hỏng không được cản phần dọn còn lại
195
+ log.warning("ws.close_hook_failed", socket_id=self.id, error=str(exc))
196
+ self._close_hooks.clear()
197
+
198
+ # ------------------------------------------------------------------ phòng
199
+ def join(self, room: str) -> None:
200
+ """Vào phòng. Gọi lại nhiều lần không sao."""
201
+ self.namespace.join(self, room)
202
+
203
+ def leave(self, room: str) -> None:
204
+ self.namespace.leave(self, room)
205
+
206
+ def in_room(self, room: str) -> bool:
207
+ return room in self.rooms
208
+
209
+ # ------------------------------------------------------------------- đóng
210
+ def close_soon(self, code: int = CloseCode.NORMAL, reason: str = "") -> None:
211
+ """Yêu cầu đóng mà không cần await — dùng được trong hàm đồng bộ."""
212
+ if self._closing:
213
+ return
214
+ self._closing = True
215
+ # Giữ tham chiếu: task không có ai nắm có thể bị GC dọn giữa chừng.
216
+ self._closer = asyncio.create_task(self._close(code, reason), name=f"ws-close-{self.id}")
217
+
218
+ async def close(self, code: int = CloseCode.NORMAL, reason: str = "") -> None:
219
+ if self._closing:
220
+ return
221
+ self._closing = True
222
+ await self._close(code, reason)
223
+
224
+ async def _close(self, code: int, reason: str) -> None:
225
+ try:
226
+ if self.ws.client_state is WebSocketState.CONNECTED:
227
+ await self.ws.close(code=code, reason=reason)
228
+ except (RuntimeError, ConnectionError):
229
+ pass # đối phương đã đóng trước
230
+
231
+ @property
232
+ def closing(self) -> bool:
233
+ return self._closing
234
+
235
+ @property
236
+ def pending(self) -> int:
237
+ """Số tin còn nằm trong hàng đợi — soi được client nào đang chậm."""
238
+ return self._queue.qsize()
239
+
240
+ def __repr__(self) -> str:
241
+ return f"<Socket {self.id} user={self.user_id} rooms={sorted(self.rooms)}>"
pymodular/discovery.py ADDED
@@ -0,0 +1,180 @@
1
+ """Module registry — tự động gom controller của mọi module trong package ứng dụng.
2
+
3
+ Tương đương module scanning của NestJS. Mỗi thư mục con của package ứng dụng là
4
+ một module; hàm ở đây nạp hết submodule bên trong để các decorator @controller
5
+ chạy, rồi dựng router từ những controller vừa đăng ký.
6
+
7
+ Thêm module mới = tạo thư mục, viết @controller. Không sửa file này, và cũng
8
+ không phải export biến `router` nào trong module.
9
+
10
+ Trường hợp cần một APIRouter dựng tay (websocket, router của thư viện ngoài),
11
+ module vẫn có thể export biến `router` — nếu có, khung dùng luôn cái đó.
12
+
13
+ Package ứng dụng mặc định là `src.api`. Dự án xếp khác thì truyền vào:
14
+
15
+ create_app(settings, package="cong_ty.dich_vu")
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import pkgutil
21
+ from importlib import import_module
22
+ from pathlib import Path
23
+
24
+ from fastapi import APIRouter, FastAPI
25
+
26
+ from pymodular.core.controller import build_router, controllers_in
27
+ from pymodular.core.logging import get_logger
28
+ from pymodular.core.websocket import build_ws_router, gateways_in
29
+
30
+ log = get_logger(__name__)
31
+
32
+ DEFAULT_PACKAGE = "src.api"
33
+ """Package chứa các module ứng dụng, khi không truyền gì khác.
34
+
35
+ Khớp với bộ khung `pym init` sinh ra: `src/api/<module>/`. Dự án xếp khác thì
36
+ nói ra một lần trong `src/main.py`:
37
+
38
+ register_routes(app, package="cong_ty.dich_vu")
39
+ """
40
+
41
+
42
+ def _package_dir(package: str) -> Path:
43
+ """Thư mục thật của package — quét file phải dựa vào đây, không phải __file__.
44
+
45
+ Trước đây hàm này lấy `Path(__file__).parent`, đúng khi khung và ứng dụng
46
+ nằm chung một cây thư mục. Cài bằng pip thì khung nằm trong site-packages
47
+ còn ứng dụng nằm ở dự án của người dùng, nên phải hỏi chính package đó.
48
+ """
49
+ module = import_module(package)
50
+ paths = list(getattr(module, "__path__", []))
51
+ if not paths:
52
+ raise RuntimeError(
53
+ f"{package!r} không phải một package (thiếu __init__.py?). "
54
+ "Đây là nơi chứa các module ứng dụng — xem pymodular.discovery."
55
+ )
56
+ return Path(paths[0])
57
+
58
+
59
+ def _import_submodules(package: str, package_dir: Path) -> None:
60
+ """Nạp mọi submodule để decorator @controller/@injectable/@entity chạy."""
61
+ for info in pkgutil.walk_packages([str(package_dir)], prefix=f"{package}."):
62
+ import_module(info.name)
63
+
64
+
65
+ def load_all_modules(package: str = DEFAULT_PACKAGE) -> None:
66
+ """Nạp mọi module trong package ứng dụng để decorator @entity/@injectable chạy.
67
+
68
+ Migration cần hàm này: Alembic phải thấy đủ entity mới sinh được diff, mà
69
+ nó không đi qua đường khởi động app.
70
+ """
71
+ _iter_packages(package)
72
+
73
+
74
+ def _iter_packages(package: str) -> list[str]:
75
+ """Tên các module con, đã nạp xong, theo thứ tự alphabet."""
76
+ goc = _package_dir(package)
77
+ names: list[str] = []
78
+ for info in sorted(pkgutil.iter_modules([str(goc)]), key=lambda i: i.name):
79
+ if not info.ispkg or info.name.startswith("_"):
80
+ continue
81
+ import_module(f"{package}.{info.name}")
82
+ _import_submodules(f"{package}.{info.name}", goc / info.name)
83
+ names.append(info.name)
84
+ return names
85
+
86
+
87
+ def discover_routers(package: str = DEFAULT_PACKAGE) -> list[tuple[str, APIRouter]]:
88
+ """Quét package ứng dụng, trả về [(tên module, router HTTP)] theo alphabet."""
89
+ found: list[tuple[str, APIRouter]] = []
90
+
91
+ for name in _iter_packages(package):
92
+ module_package = f"{package}.{name}"
93
+ module = import_module(module_package)
94
+
95
+ explicit = getattr(module, "router", None)
96
+ if isinstance(explicit, APIRouter):
97
+ found.append((name, explicit))
98
+ continue
99
+
100
+ controllers = controllers_in(module_package)
101
+ if not controllers:
102
+ if gateways_in(module_package):
103
+ # Module chỉ có gateway WebSocket là hợp lệ, không phải thiếu sót.
104
+ continue
105
+ # Im lặng ở đây là nguyên nhân số một của "sao route của tôi 404?".
106
+ log.warning("api.module_without_controller", module=name)
107
+ continue
108
+
109
+ log.debug(
110
+ "api.module_scanned",
111
+ module=name,
112
+ controllers=[c.__name__ for c in controllers],
113
+ )
114
+ found.append((name, build_router(*controllers)))
115
+
116
+ return found
117
+
118
+
119
+ def discover_gateways(package: str = DEFAULT_PACKAGE) -> list[tuple[str, list[type]]]:
120
+ """Quét package ứng dụng, trả về [(tên module, các class gateway)]."""
121
+ return [
122
+ (name, found)
123
+ for name in _iter_packages(package)
124
+ if (found := gateways_in(f"{package}.{name}"))
125
+ ]
126
+
127
+
128
+ def register_routes(
129
+ app: FastAPI, *, prefix: str = "/api", package: str = DEFAULT_PACKAGE
130
+ ) -> None:
131
+ """Gắn toàn bộ module đã phát hiện vào app."""
132
+ api = APIRouter(prefix=prefix)
133
+ names: list[str] = []
134
+
135
+ for name, router in discover_routers(package):
136
+ api.include_router(router)
137
+ names.append(name)
138
+
139
+ app.include_router(api)
140
+ log.info(
141
+ "api.modules_registered",
142
+ package=package,
143
+ prefix=prefix,
144
+ count=len(names),
145
+ modules=names,
146
+ )
147
+
148
+ register_gateways(app, package=package)
149
+
150
+
151
+ def register_gateways(app: FastAPI, *, package: str = DEFAULT_PACKAGE) -> None:
152
+ """Gắn route WebSocket.
153
+
154
+ KHÔNG nằm dưới tiền tố /api: đường dẫn WebSocket do @gateway(path=...) khai
155
+ trọn vẹn, vì WebSocket không phải REST và thường được reverse proxy định
156
+ tuyến riêng (nginx phải bật Upgrade cho đúng những đường dẫn này).
157
+ """
158
+ classes: list[type] = []
159
+ paths: dict[str, str] = {}
160
+
161
+ for module_name, gateways in discover_gateways(package):
162
+ for cls in gateways:
163
+ path = cls.__gateway_meta__.path
164
+ if path in paths:
165
+ raise RuntimeError(
166
+ f"Hai gateway cùng path {path!r}: {paths[path]} và {cls.__name__}"
167
+ )
168
+ paths[path] = cls.__name__
169
+ classes.append(cls)
170
+ log.debug("api.gateways_scanned", module=module_name)
171
+
172
+ if not classes:
173
+ return
174
+
175
+ app.include_router(build_ws_router(*classes))
176
+ log.info(
177
+ "api.gateways_registered",
178
+ count=len(classes),
179
+ gateways=[f"{cls.__name__} {cls.__gateway_meta__.path}" for cls in classes],
180
+ )
pymodular/factory.py ADDED
@@ -0,0 +1,126 @@
1
+ """Các mảnh ghép để dựng app — và `create_app()` gom sẵn chúng lại.
2
+
3
+ `src/main.py` là file CỦA BẠN, không phải của khung. Nên mỗi bước lắp ráp ở đây
4
+ là một hàm công khai, gọi riêng được, chèn thêm được:
5
+
6
+ settings = bind_settings(AppSettings())
7
+ configure_logging(settings.log)
8
+
9
+ app = new_fastapi(settings, lifespan=lifespan)
10
+ add_middleware(app, settings)
11
+ register_error_handlers(app, debug=settings.debug)
12
+ register_routes(app, prefix=settings.api_prefix)
13
+
14
+ Cần thêm middleware của mình, đổi thứ tự, bỏ CORS, gắn router bên thứ ba? Sửa
15
+ thẳng trong main.py của bạn, không phải ngồi tìm cách "ghi đè" cái gì trong
16
+ khung.
17
+
18
+ `create_app()` chạy đúng dãy trên, không hơn. Dùng nó khi chưa cần sửa gì:
19
+
20
+ app = create_app(AppSettings())
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Any
26
+
27
+ from fastapi import FastAPI
28
+ from fastapi.middleware.cors import CORSMiddleware
29
+
30
+ from pymodular.core.config import Settings, get_settings, use_settings
31
+ from pymodular.core.container import container
32
+ from pymodular.core.error_handlers import register_error_handlers
33
+ from pymodular.core.lifespan import lifespan
34
+ from pymodular.core.logging import configure_logging
35
+ from pymodular.discovery import DEFAULT_PACKAGE, register_routes
36
+ from pymodular.middleware.request_context import AccessLogMiddleware, RequestContextMiddleware
37
+
38
+
39
+ def bind_settings(settings: Settings | None = None) -> Settings:
40
+ """Chốt cấu hình cho cả tiến trình và cắm nó vào DI container.
41
+
42
+ Truyền `None` thì đọc từ `.env` bằng lớp `Settings` gốc. Truyền một instance
43
+ lớp con thì lớp đó được ghi nhớ, để những chỗ khung tự dựng cấu hình
44
+ (Alembic, gateway lúc không có request) không ra một bản cấu hình khác.
45
+
46
+ Đăng ký dưới CẢ chuỗi kế thừa: container tra provider theo TÊN lớp, nên khai
47
+ `def __init__(self, settings: AppSettings)` mà chỉ đăng ký "Settings" thì sẽ
48
+ báo không có provider 'AppSettings'.
49
+ """
50
+ if settings is None:
51
+ settings = get_settings()
52
+ else:
53
+ use_settings(type(settings))
54
+
55
+ for lop in type(settings).__mro__:
56
+ if not (isinstance(lop, type) and issubclass(lop, Settings)):
57
+ break
58
+ container.override(lop, settings)
59
+ return settings
60
+
61
+
62
+ def new_fastapi(settings: Settings, **kwargs: Any) -> FastAPI:
63
+ """`FastAPI(...)` với tiêu đề, phiên bản và đường dẫn docs lấy từ cấu hình.
64
+
65
+ Ở prod, `docs_url`/`redoc_url`/`openapi_url` là None nên trang docs biến
66
+ mất — đừng để lộ sơ đồ API ra ngoài mà không có chủ ý.
67
+
68
+ `kwargs` truyền thẳng cho FastAPI, và ĐÈ được mọi giá trị ở trên.
69
+ """
70
+ mac_dinh: dict[str, Any] = {
71
+ "title": settings.name,
72
+ "version": settings.version,
73
+ "debug": settings.debug,
74
+ "lifespan": lifespan,
75
+ "docs_url": settings.docs_url,
76
+ "redoc_url": settings.redoc_url,
77
+ "openapi_url": settings.openapi_url,
78
+ }
79
+ return FastAPI(**{**mac_dinh, **kwargs})
80
+
81
+
82
+ def add_middleware(app: FastAPI, settings: Settings) -> None:
83
+ """Ba middleware của khung: CORS, access log, request-id.
84
+
85
+ Thứ tự add là NGƯỢC với thứ tự chạy, nên RequestContext (thứ sinh ra
86
+ request-id) được add sau cùng để chạy đầu tiên — mọi log của các lớp sau nó
87
+ mới có id để bám theo.
88
+
89
+ Thêm middleware của bạn thì gọi `app.add_middleware(...)` sau hàm này nếu
90
+ muốn nó chạy TRƯỚC, hoặc trước hàm này nếu muốn nó chạy SAU.
91
+ """
92
+ app.add_middleware(
93
+ CORSMiddleware,
94
+ allow_origins=settings.cors.allow_origins,
95
+ allow_credentials=settings.cors.allow_credentials,
96
+ allow_methods=settings.cors.allow_methods,
97
+ allow_headers=settings.cors.allow_headers,
98
+ )
99
+ app.add_middleware(AccessLogMiddleware)
100
+ app.add_middleware(RequestContextMiddleware)
101
+
102
+
103
+ def create_app(
104
+ settings: Settings | None = None, *, package: str = DEFAULT_PACKAGE
105
+ ) -> FastAPI:
106
+ """Dựng app theo cách mặc định. `package` là nơi chứa module ứng dụng.
107
+
108
+ Không có gì ở đây mà `src/main.py` của bạn không gọi được từng phần.
109
+ """
110
+ settings = bind_settings(settings)
111
+ configure_logging(settings.log)
112
+
113
+ app = new_fastapi(settings)
114
+ add_middleware(app, settings)
115
+ register_error_handlers(app, debug=settings.debug)
116
+ register_routes(app, prefix=settings.api_prefix, package=package)
117
+
118
+ @app.get("/", include_in_schema=False)
119
+ async def root() -> dict[str, str]:
120
+ return {
121
+ "service": settings.name,
122
+ "version": settings.version,
123
+ "docs": settings.docs_url or "disabled",
124
+ }
125
+
126
+ return app
@@ -0,0 +1 @@
1
+ """Hạ tầng: những thứ nói chuyện với thế giới bên ngoài (database, cache, message bus)."""
@@ -0,0 +1,8 @@
1
+ """Tầng database: hợp đồng chung, các backend cụ thể, và Repository dùng chung.
2
+
3
+ Import từ đây: from pymodular.infrastructure.database import Repository
4
+ """
5
+
6
+ from pymodular.infrastructure.database.repository import Database, Repository
7
+
8
+ __all__ = ["Database", "Repository"]