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,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"
|
pymodular/core/guards.py
ADDED
|
@@ -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")
|