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,272 @@
1
+ """Chuẩn hoá mọi lỗi HTTP về cùng một hình dạng JSON.
2
+
3
+ {"code": "...", "message": "...", "details": ..., "request_id": "..."}
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from fastapi import FastAPI, Request
9
+ from fastapi.exceptions import RequestValidationError
10
+ from fastapi.responses import JSONResponse
11
+ from starlette.exceptions import HTTPException as StarletteHTTPException
12
+
13
+ from pymodular.core.context import get_request_id, get_trace_id
14
+ from pymodular.core.exceptions import AppError
15
+ from pymodular.core.logging import get_logger
16
+
17
+ log = get_logger(__name__)
18
+
19
+
20
+ def _response(status_code: int, payload: dict[str, object]) -> JSONResponse:
21
+ payload["request_id"] = get_request_id()
22
+ # Trả cả trace_id để người dùng báo lỗi kèm mã này là tra được toàn bộ
23
+ # hành trình qua các dịch vụ, không chỉ log của riêng dịch vụ này.
24
+ trace_id = get_trace_id()
25
+ if trace_id:
26
+ payload["trace_id"] = trace_id
27
+ return JSONResponse(status_code=status_code, content=payload)
28
+
29
+
30
+ def _unavailable(exc: Exception, *, debug: bool) -> JSONResponse:
31
+ """Database không với tới được — lỗi vận hành, không phải bug.
32
+
33
+ Trả 503 chứ không phải 500: load balancer và client biết đây là tình trạng
34
+ tạm thời và nên thử lại, còn 500 nghĩa là "gửi lại cũng vô ích".
35
+ """
36
+ log.warning("db.unavailable", error=f"{type(exc).__name__}: {exc}")
37
+ return _response(
38
+ 503,
39
+ {
40
+ "code": "database_unavailable",
41
+ "message": "Không kết nối được cơ sở dữ liệu, vui lòng thử lại",
42
+ **({"details": f"{type(exc).__name__}: {exc}"} if debug else {}),
43
+ },
44
+ )
45
+
46
+
47
+ def _register_circuit_handler(app: FastAPI, *, debug: bool) -> None:
48
+ """Mạch đang ngắt -> 503 kèm Retry-After, không chạm database."""
49
+ from pymodular.infrastructure.database.circuit import CircuitOpenError
50
+
51
+ @app.exception_handler(CircuitOpenError)
52
+ async def _circuit_open(_: Request, exc: CircuitOpenError) -> JSONResponse:
53
+ log.warning("db.circuit_rejected", backend=exc.backend, retry_after=exc.retry_after)
54
+ response = _response(
55
+ 503,
56
+ {
57
+ "code": "database_unavailable",
58
+ "message": "Không kết nối được cơ sở dữ liệu, vui lòng thử lại",
59
+ **({"details": str(exc)} if debug else {}),
60
+ },
61
+ )
62
+ response.headers["Retry-After"] = str(max(1, int(exc.retry_after)))
63
+ return response
64
+
65
+
66
+ def _register_duplicate_handler(app: FastAPI, *, debug: bool) -> None:
67
+ """Trùng khoá do backend memory phát hiện -> 409, giống SQL và Mongo."""
68
+ from pymodular.infrastructure.database.base import DuplicateKeyViolation
69
+
70
+ @app.exception_handler(DuplicateKeyViolation)
71
+ async def _duplicate(_: Request, exc: DuplicateKeyViolation) -> JSONResponse:
72
+ log.warning("db.duplicate_key", storage=exc.storage, columns=list(exc.columns))
73
+ return _response(
74
+ 409,
75
+ {
76
+ "code": "integrity_error",
77
+ "message": "Thao tác vi phạm ràng buộc dữ liệu",
78
+ **({"details": str(exc)} if debug else {}),
79
+ },
80
+ )
81
+
82
+
83
+ def _register_connection_handlers(app: FastAPI, *, debug: bool) -> None:
84
+ """Lỗi socket thuần (ConnectionRefused/Reset/Aborted) -> 503."""
85
+
86
+ @app.exception_handler(ConnectionError)
87
+ async def _connection_error(_: Request, exc: ConnectionError) -> JSONResponse:
88
+ return _unavailable(exc, debug=debug)
89
+
90
+ @app.exception_handler(TimeoutError)
91
+ async def _timeout_error(_: Request, exc: TimeoutError) -> JSONResponse:
92
+ # Quá hạn khi gọi database cũng là "tạm thời không phục vụ được".
93
+ return _unavailable(exc, debug=debug)
94
+
95
+
96
+ def _register_mongo_handlers(app: FastAPI, *, debug: bool) -> None:
97
+ """Handler cho lỗi MongoDB — chỉ khi project thực sự cài driver."""
98
+ try:
99
+ from pymongo.errors import ConnectionFailure, DuplicateKeyError, PyMongoError
100
+ except ModuleNotFoundError:
101
+ log.debug("error_handlers.pymongo_missing")
102
+ return
103
+
104
+ @app.exception_handler(ConnectionFailure)
105
+ async def _mongo_unavailable(_: Request, exc: Exception) -> JSONResponse:
106
+ # ConnectionFailure là cha của AutoReconnect, NetworkTimeout,
107
+ # ServerSelectionTimeoutError — tức mọi tình huống "Mongo không với tới được".
108
+ return _unavailable(exc, debug=debug)
109
+
110
+ @app.exception_handler(DuplicateKeyError)
111
+ async def _mongo_duplicate(_: Request, exc: Exception) -> JSONResponse:
112
+ log.warning("db.duplicate_key", error=str(exc))
113
+ return _response(
114
+ 409,
115
+ {
116
+ "code": "integrity_error",
117
+ "message": "Thao tác vi phạm ràng buộc dữ liệu",
118
+ **({"details": str(exc)} if debug else {}),
119
+ },
120
+ )
121
+
122
+ @app.exception_handler(PyMongoError)
123
+ async def _mongo_error(_: Request, exc: Exception) -> JSONResponse:
124
+ log.exception("db.error", error=str(exc))
125
+ return _response(
126
+ 500,
127
+ {
128
+ "code": "database_error",
129
+ "message": "Lỗi truy cập cơ sở dữ liệu",
130
+ **({"details": str(exc)} if debug else {}),
131
+ },
132
+ )
133
+
134
+
135
+ def _register_amqp_handlers(app: FastAPI, *, debug: bool) -> None:
136
+ """Handler cho lỗi RabbitMQ — chỉ khi project thực sự cài aio-pika."""
137
+ try:
138
+ from aiormq.exceptions import AMQPError, ChannelInvalidStateError
139
+ except ModuleNotFoundError:
140
+ log.debug("error_handlers.aio_pika_missing")
141
+ return
142
+
143
+ # ChannelInvalidStateError KHÔNG kế thừa AMQPError mà kế thừa RuntimeError
144
+ # — đăng ký thiếu nó thì "gửi tin lúc broker vừa chết" rơi vào handler 500
145
+ # chung thay vì 503. Đây là lỗi tôi gặp khi thử tắt broker giữa chừng.
146
+ @app.exception_handler(AMQPError)
147
+ @app.exception_handler(ChannelInvalidStateError)
148
+ async def _amqp_error(_: Request, exc: Exception) -> JSONResponse:
149
+ # Mọi lỗi AMQP đều là "hạ tầng nhắn tin đang trục trặc" dưới góc nhìn
150
+ # client: kết nối rớt, kênh bị đóng, broker từ chối. Trả 503 để client
151
+ # biết thử lại sau, thay vì 500 nghĩa là gửi lại cũng vô ích.
152
+ log.warning("mq.unavailable", error=f"{type(exc).__name__}: {exc}")
153
+ return _response(
154
+ 503,
155
+ {
156
+ "code": "rabbitmq_unavailable",
157
+ "message": "RabbitMQ không sẵn sàng, vui lòng thử lại",
158
+ **({"details": f"{type(exc).__name__}: {exc}"} if debug else {}),
159
+ },
160
+ )
161
+
162
+
163
+ def _register_db_handlers(app: FastAPI, *, debug: bool) -> None:
164
+ """Đăng ký handler cho lỗi SQLAlchemy — chỉ khi project thực sự cài SQLAlchemy.
165
+
166
+ Template này không bắt buộc dùng DB; khi chưa cài (chưa dùng Postgres/SQLite)
167
+ thì bỏ qua, các lỗi khác vẫn rơi vào handler Exception chung.
168
+ """
169
+ try:
170
+ from sqlalchemy.exc import (
171
+ IntegrityError,
172
+ InterfaceError,
173
+ OperationalError,
174
+ SQLAlchemyError,
175
+ )
176
+ except ModuleNotFoundError:
177
+ log.debug("error_handlers.sqlalchemy_missing")
178
+ return
179
+
180
+ # Starlette chọn handler khớp nhất theo MRO, nên hai handler dưới đây thắng
181
+ # handler SQLAlchemyError chung ở cuối hàm.
182
+ @app.exception_handler(OperationalError)
183
+ @app.exception_handler(InterfaceError)
184
+ async def _sql_unavailable(_: Request, exc: Exception) -> JSONResponse:
185
+ return _unavailable(exc, debug=debug)
186
+
187
+ @app.exception_handler(IntegrityError)
188
+ async def _integrity_error(_: Request, exc: IntegrityError) -> JSONResponse:
189
+ log.warning("db.integrity_error", error=str(exc.orig))
190
+ return _response(
191
+ 409,
192
+ {
193
+ "code": "integrity_error",
194
+ "message": "Thao tác vi phạm ràng buộc dữ liệu",
195
+ **({"details": str(exc.orig)} if debug else {}),
196
+ },
197
+ )
198
+
199
+ @app.exception_handler(SQLAlchemyError)
200
+ async def _db_error(_: Request, exc: SQLAlchemyError) -> JSONResponse:
201
+ log.exception("db.error", error=str(exc))
202
+ return _response(
203
+ 500,
204
+ {
205
+ "code": "database_error",
206
+ "message": "Lỗi truy cập cơ sở dữ liệu",
207
+ **({"details": str(exc)} if debug else {}),
208
+ },
209
+ )
210
+
211
+
212
+ def register_error_handlers(app: FastAPI, *, debug: bool = False) -> None:
213
+ @app.exception_handler(AppError)
214
+ async def _app_error(_: Request, exc: AppError) -> JSONResponse:
215
+ if exc.status_code >= 500:
216
+ log.error("app.error", code=exc.error_code, message=exc.message)
217
+ return _response(exc.status_code, exc.to_dict())
218
+
219
+ @app.exception_handler(RequestValidationError)
220
+ async def _validation_error(_: Request, exc: RequestValidationError) -> JSONResponse:
221
+ return _response(
222
+ 422,
223
+ {
224
+ "code": "validation_error",
225
+ "message": "Dữ liệu đầu vào không hợp lệ",
226
+ "details": [
227
+ {
228
+ "field": ".".join(str(p) for p in err["loc"][1:]) or str(err["loc"][0]),
229
+ "message": err["msg"],
230
+ "type": err["type"],
231
+ }
232
+ for err in exc.errors()
233
+ ],
234
+ },
235
+ )
236
+
237
+ @app.exception_handler(NotImplementedError)
238
+ async def _not_implemented(_: Request, exc: NotImplementedError) -> JSONResponse:
239
+ # Khung do `make module` sinh ra dùng `raise NotImplementedError(...)`.
240
+ return _response(
241
+ 501,
242
+ {
243
+ "code": "not_implemented",
244
+ "message": str(exc) or "Chức năng chưa được cài đặt",
245
+ },
246
+ )
247
+
248
+ @app.exception_handler(StarletteHTTPException)
249
+ async def _http_error(_: Request, exc: StarletteHTTPException) -> JSONResponse:
250
+ return _response(
251
+ exc.status_code,
252
+ {"code": f"http_{exc.status_code}", "message": str(exc.detail)},
253
+ )
254
+
255
+ _register_circuit_handler(app, debug=debug)
256
+ _register_duplicate_handler(app, debug=debug)
257
+ _register_connection_handlers(app, debug=debug)
258
+ _register_db_handlers(app, debug=debug)
259
+ _register_mongo_handlers(app, debug=debug)
260
+ _register_amqp_handlers(app, debug=debug)
261
+
262
+ @app.exception_handler(Exception)
263
+ async def _unhandled(_: Request, exc: Exception) -> JSONResponse:
264
+ log.exception("app.unhandled_error", error=str(exc))
265
+ return _response(
266
+ 500,
267
+ {
268
+ "code": "internal_error",
269
+ "message": "Internal server error",
270
+ **({"details": f"{type(exc).__name__}: {exc}"} if debug else {}),
271
+ },
272
+ )
@@ -0,0 +1,104 @@
1
+ """Cây exception của tầng ứng dụng.
2
+
3
+ Service/repository chỉ ném các lỗi này; việc dịch sang HTTP status hay gRPC
4
+ status code do tầng ngoài (error_handlers / interceptor) đảm nhiệm. Nhờ vậy
5
+ domain không phụ thuộc vào giao thức.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+
13
+ class AppError(Exception):
14
+ """Lỗi nghiệp vụ gốc."""
15
+
16
+ status_code: int = 500
17
+ error_code: str = "internal_error"
18
+ message: str = "Internal server error"
19
+
20
+ def __init__(
21
+ self,
22
+ message: str | None = None,
23
+ *,
24
+ error_code: str | None = None,
25
+ details: Any = None,
26
+ ) -> None:
27
+ self.message = message or self.message
28
+ self.error_code = error_code or self.error_code
29
+ self.details = details
30
+ super().__init__(self.message)
31
+
32
+ def to_dict(self) -> dict[str, Any]:
33
+ payload: dict[str, Any] = {"code": self.error_code, "message": self.message}
34
+ if self.details is not None:
35
+ payload["details"] = self.details
36
+ return payload
37
+
38
+
39
+ class BadRequestError(AppError):
40
+ status_code = 400
41
+ error_code = "bad_request"
42
+ message = "Bad request"
43
+
44
+
45
+ class UnauthorizedError(AppError):
46
+ status_code = 401
47
+ error_code = "unauthorized"
48
+ message = "Not authenticated"
49
+
50
+
51
+ class ForbiddenError(AppError):
52
+ status_code = 403
53
+ error_code = "forbidden"
54
+ message = "Permission denied"
55
+
56
+
57
+ class NotFoundError(AppError):
58
+ status_code = 404
59
+ error_code = "not_found"
60
+ message = "Resource not found"
61
+
62
+
63
+ class ConflictError(AppError):
64
+ status_code = 409
65
+ error_code = "conflict"
66
+ message = "Resource conflict"
67
+
68
+
69
+ class ValidationError(AppError):
70
+ status_code = 422
71
+ error_code = "validation_error"
72
+ message = "Validation failed"
73
+
74
+
75
+ class TooManyRequestsError(AppError):
76
+ status_code = 429
77
+ error_code = "too_many_requests"
78
+ message = "Too many requests"
79
+
80
+
81
+ class NotImplementedYetError(AppError):
82
+ """Hàm mới được sinh khung, chưa viết thân.
83
+
84
+ Trả 501 chứ không phải 500: 501 nói "chức năng này chưa tồn tại", còn 500
85
+ nói "có bug". Nhờ vậy khung do `pym module` sinh ra không bị nhầm là hỏng.
86
+ """
87
+
88
+ status_code = 501
89
+ error_code = "not_implemented"
90
+ message = "Chức năng chưa được cài đặt"
91
+
92
+
93
+ class ServiceUnavailableError(AppError):
94
+ status_code = 503
95
+ error_code = "service_unavailable"
96
+ message = "Service temporarily unavailable"
97
+
98
+
99
+ class ComponentNotEnabledError(AppError):
100
+ """Code cố dùng một hạ tầng đang bị tắt trong config."""
101
+
102
+ status_code = 503
103
+ error_code = "component_not_enabled"
104
+ message = "Required infrastructure component is not enabled"
@@ -0,0 +1,117 @@
1
+ """Guard — chặn request trước khi vào handler (tương đương @UseGuards của Nest).
2
+
3
+ Guard KHÔNG phải nơi chứa nghiệp vụ; nó chỉ trả lời một câu: request này có
4
+ được đi tiếp không. Muốn từ chối thì ném lỗi nghiệp vụ (UnauthorizedError /
5
+ ForbiddenError) — error_handlers sẽ dịch sang HTTP.
6
+
7
+ Gắn ở cấp controller (áp cho mọi route) hoặc cấp từng route; hai nơi cộng dồn:
8
+
9
+ @controller(prefix="/devices", guards=[RequireHeader])
10
+ class DeviceController:
11
+ @delete("/{device_id}", guards=[AdminOnly])
12
+ async def remove(self, device_id: str) -> None: ...
13
+
14
+ Guard là provider bình thường nên nhận được phụ thuộc qua __init__.
15
+
16
+ Template CHƯA có xác thực thật. Chỗ để cắm vào: viết một guard đọc header/token
17
+ rồi gọi `principal.assume(...)`, phần còn lại của hệ thống dùng ngay được.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass, field
23
+ from typing import Protocol, runtime_checkable
24
+
25
+ from starlette.requests import HTTPConnection
26
+
27
+ from pymodular.core.container import Scope, container, injectable
28
+ from pymodular.core.exceptions import ForbiddenError, UnauthorizedError
29
+ from pymodular.core.logging import get_logger
30
+
31
+ log = get_logger(__name__)
32
+
33
+
34
+ @runtime_checkable
35
+ class Guard(Protocol):
36
+ """Ném lỗi để chặn; trả về bình thường để cho đi tiếp.
37
+
38
+ Tham số là `HTTPConnection` — lớp cha chung của `Request` (HTTP) và
39
+ `WebSocket`. Nhờ vậy MỘT guard dùng được cho cả hai phía, miễn là nó chỉ
40
+ đọc những thứ có ở cả hai: headers, query_params, cookies, client.
41
+
42
+ Lưu ý khi viết guard cho WebSocket: trình duyệt KHÔNG cho đặt header tuỳ
43
+ ý trên kết nối WebSocket, nên token thường đi qua query (?token=...) hoặc
44
+ qua Sec-WebSocket-Protocol. Guard nào bắt buộc header sẽ không dùng được
45
+ từ trình duyệt.
46
+ """
47
+
48
+ async def check(self, connection: HTTPConnection) -> None: ...
49
+
50
+
51
+ @injectable(scope=Scope.REQUEST)
52
+ @dataclass
53
+ class Principal:
54
+ """Ai đang gọi request này.
55
+
56
+ Mặc định là ẩn danh. Guard xác thực gọi `assume()` để điền vào; phần còn
57
+ lại của ứng dụng đọc qua `current_principal()`.
58
+
59
+ Vòng đời theo request nên không có chuyện dữ liệu của người này rò sang
60
+ người khác — container chặn sẵn việc provider singleton giữ nó.
61
+ """
62
+
63
+ id: str | None = None
64
+ roles: frozenset[str] = field(default_factory=frozenset)
65
+
66
+ @property
67
+ def is_authenticated(self) -> bool:
68
+ return self.id is not None
69
+
70
+ def assume(self, *, id: str, roles: frozenset[str] | set[str] | None = None) -> None:
71
+ self.id = id
72
+ self.roles = frozenset(roles or ())
73
+
74
+ def require_authenticated(self) -> None:
75
+ if not self.is_authenticated:
76
+ raise UnauthorizedError()
77
+
78
+ def require_role(self, *required: str) -> None:
79
+ self.require_authenticated()
80
+ if not self.roles.intersection(required):
81
+ raise ForbiddenError(
82
+ f"Cần một trong các vai trò: {', '.join(sorted(required))}"
83
+ )
84
+
85
+
86
+ def current_principal() -> Principal:
87
+ """Lấy Principal của request đang chạy.
88
+
89
+ Gọi trong thân method chứ không nhận qua __init__: service là singleton,
90
+ còn Principal theo request — container sẽ chặn nếu bạn cố inject thẳng.
91
+ """
92
+ return container.resolve(Principal)
93
+
94
+
95
+ # --------------------------------------------------------------------- guard mẫu
96
+ @injectable
97
+ class RequireHeader:
98
+ """Guard mẫu: bắt buộc có header `X-Client-Id`.
99
+
100
+ Cố ý chọn ví dụ không phải xác thực, để bạn thấy khung guard mà không bị
101
+ nhầm là template đã có bảo mật. Guard xác thực thật viết cùng khuôn: đọc
102
+ request, quyết định, ném lỗi hoặc gọi `principal.assume(...)`.
103
+ """
104
+
105
+ HEADER = "x-client-id"
106
+ QUERY = "client_id"
107
+
108
+ async def check(self, connection: HTTPConnection) -> None:
109
+ # Nhận cả qua query để dùng được từ WebSocket trong trình duyệt (nơi
110
+ # không đặt được header).
111
+ client_id = connection.headers.get(self.HEADER) or connection.query_params.get(self.QUERY)
112
+ if not client_id:
113
+ raise UnauthorizedError(f"Thiếu header {self.HEADER} (hoặc ?{self.QUERY}=)")
114
+
115
+ # Guard xác thực thật sẽ kiểm tra chữ ký/token ở đây rồi mới assume.
116
+ current_principal().assume(id=client_id, roles={"client"})
117
+ log.debug("guard.client_identified", client_id=client_id)
@@ -0,0 +1,150 @@
1
+ """Vòng đời hạ tầng: mở kết nối khi boot, dọn dẹp khi tắt.
2
+
3
+ Đây là phần của KHUNG — database, WebSocket, và những lớp hạ tầng đang bật.
4
+ Việc riêng của ứng dụng thì đừng sửa vào đây; BỌC nó lại trong dự án của bạn:
5
+
6
+ # src/core/lifespan.py
7
+ from pymodular import lifespan as framework_lifespan
8
+
9
+ @asynccontextmanager
10
+ async def lifespan(app):
11
+ async with framework_lifespan(app): # database, hàng đợi... sẵn sàng
12
+ await warm_cache() # việc riêng lúc khởi động
13
+ try:
14
+ yield
15
+ finally:
16
+ await flush_ledger() # việc riêng lúc tắt, TRƯỚC khi khung đóng
17
+
18
+ # src/main.py
19
+ app = new_fastapi(settings, lifespan=lifespan)
20
+
21
+ `pym init` sinh sẵn file đó, chỉ việc điền vào.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import AsyncIterator
27
+ from contextlib import asynccontextmanager
28
+
29
+ from fastapi import FastAPI
30
+
31
+ from pymodular.core.config import Settings, check_deprecated_env, get_settings
32
+ from pymodular.core.container import _ENTITIES, container
33
+ from pymodular.core.logging import get_logger
34
+ from pymodular.core.websocket import WebSocketServer
35
+
36
+ log = get_logger(__name__)
37
+
38
+
39
+ @asynccontextmanager
40
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
41
+ # Lấy từ container: create_app() đã nạp vào đó, nên Settings truyền tay khi
42
+ # test được tôn trọng thay vì đọc lại .env.
43
+ try:
44
+ settings = container.resolve(Settings)
45
+ except RuntimeError:
46
+ settings = get_settings()
47
+ app.state.container = container
48
+
49
+ for problem in settings.check_production_safety():
50
+ log.warning("config.unsafe_for_production", problem=problem)
51
+
52
+ # Biến môi trường tên cũ bị pydantic bỏ qua trong im lặng: app chạy với giá
53
+ # trị mặc định mà không ai biết. Gom vào MỘT dòng — mười ba dòng cảnh báo
54
+ # rời rạc thì người ta cuộn qua, một dòng có số đếm thì đọc.
55
+ if deprecated := check_deprecated_env():
56
+ log.warning(
57
+ "config.deprecated_env",
58
+ count=len(deprecated),
59
+ problems=deprecated,
60
+ hint="những biến này đang BỊ BỎ QUA, app dùng giá trị mặc định thay thế",
61
+ )
62
+
63
+ # Import muộn: Database kéo theo factory driver, mà factory chỉ được chạm
64
+ # tới sau khi mọi module đã nạp xong (entity phải đăng ký trước create_schema).
65
+ from pymodular.infrastructure.database import Database
66
+ from pymodular.infrastructure.kafka import KafkaBroker, KafkaRunner
67
+ from pymodular.infrastructure.mqtt import MqttClient, MqttRunner
68
+ from pymodular.infrastructure.rabbitmq import RabbitBroker, RabbitmqRunner
69
+ from pymodular.infrastructure.redis import RedisClient, RedisRunner
70
+
71
+ database = container.resolve(Database)
72
+
73
+ log.info("app.starting", env=settings.env, version=settings.version)
74
+ await database.startup(*_ENTITIES.values())
75
+ app.state.database = database
76
+
77
+ # Lớp WebSocket: mở kênh phát tin xuyên worker (nếu dùng adapter redis).
78
+ websockets = container.resolve(WebSocketServer)
79
+ await websockets.startup()
80
+ app.state.websockets = websockets
81
+
82
+ # RabbitMQ (tuỳ chọn). Tắt thì hai lời gọi dưới đây đều không làm gì.
83
+ # Bật mà broker chưa lên thì app VẪN CHẠY và nối lại ngầm.
84
+ broker = container.resolve(RabbitBroker)
85
+ await broker.startup()
86
+ consumers = container.resolve(RabbitmqRunner)
87
+ await consumers.startup()
88
+ app.state.broker = broker
89
+
90
+ # Ba lớp dưới đây cũng TUỲ CHỌN và độc lập nhau. Tắt (mặc định) thì mỗi lời
91
+ # gọi startup() là một lệnh `return` — không import thư viện, không mở kết
92
+ # nối. Bật mà server chưa lên thì app VẪN CHẠY và nối lại ngầm.
93
+ redis = container.resolve(RedisClient)
94
+ await redis.startup()
95
+ channels = container.resolve(RedisRunner)
96
+ await channels.startup()
97
+ app.state.redis = redis
98
+
99
+ # MqttRunner phải chạy TRƯỚC client: nó là chỗ khai danh sách topic, mà
100
+ # client đăng ký topic ngay trong lần bắt tay đầu tiên.
101
+ mqtt_runner = container.resolve(MqttRunner)
102
+ await mqtt_runner.startup()
103
+ mqtt = container.resolve(MqttClient)
104
+ await mqtt.startup()
105
+ app.state.mqtt = mqtt
106
+
107
+ kafka = container.resolve(KafkaBroker)
108
+ await kafka.startup()
109
+ kafka_consumers = container.resolve(KafkaRunner)
110
+ await kafka_consumers.startup()
111
+ app.state.kafka = kafka
112
+
113
+ log.info(
114
+ "app.started",
115
+ driver=database.driver,
116
+ ws_adapter=websockets.adapter_name,
117
+ mq=broker.stats(),
118
+ redis=redis.stats(),
119
+ mqtt=mqtt.stats(),
120
+ kafka=kafka.stats(),
121
+ entities=sorted(_ENTITIES),
122
+ providers=sorted(container.registered),
123
+ )
124
+
125
+ try:
126
+ yield
127
+ finally:
128
+ log.info("app.stopping")
129
+ # Thứ tự tắt là ngược lại thứ tự bật, và có lý do cho từng bước:
130
+ # consumer trước — chúng còn đang truy vấn database
131
+ # WebSocket — client nhận mã 1001 để nối lại ngay
132
+ # broker, rồi database — hai thứ mọi tầng trên đều dựa vào
133
+ await kafka_consumers.shutdown()
134
+ await kafka.shutdown()
135
+ await mqtt.shutdown()
136
+ await channels.shutdown()
137
+ await consumers.shutdown()
138
+ await websockets.shutdown()
139
+ await broker.shutdown()
140
+ await redis.shutdown()
141
+ await database.shutdown()
142
+ container.reset()
143
+ app.state.container = None
144
+ app.state.database = None
145
+ app.state.websockets = None
146
+ app.state.broker = None
147
+ app.state.redis = None
148
+ app.state.mqtt = None
149
+ app.state.kafka = None
150
+ log.info("app.stopped")