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,495 @@
1
+
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from functools import lru_cache
6
+ from pathlib import Path
7
+ from typing import ClassVar, Literal
8
+
9
+ from pydantic import BaseModel, Field
10
+ from pydantic_settings import BaseSettings, SettingsConfigDict
11
+
12
+ Environment = Literal["local", "dev", "staging", "prod"]
13
+
14
+ class LogSettings(BaseModel):
15
+ level: str = "INFO"
16
+ json_format: bool = False
17
+ """Bật để log dạng JSON (production); tắt để log màu, dễ đọc (local)."""
18
+
19
+ class CorsSettings(BaseModel):
20
+ """Mặc định mở cho local. Prod bắt buộc liệt kê origin cụ thể — xem
21
+ `Settings.check_production_safety()`; `["*"]` cộng allow_credentials=True
22
+ khiến Starlette phản chiếu mọi Origin, tức bất kỳ website nào cũng gửi
23
+ được request kèm cookie của người dùng."""
24
+
25
+ allow_origins: list[str] = Field(default_factory=lambda: ["*"])
26
+ allow_methods: list[str] = Field(default_factory=lambda: ["*"])
27
+ allow_headers: list[str] = Field(default_factory=lambda: ["*"])
28
+ allow_credentials: bool = True
29
+
30
+ @property
31
+ def allows_any_origin(self) -> bool:
32
+ return "*" in self.allow_origins
33
+
34
+
35
+ Driver = Literal["memory", "sqlite", "postgres", "mongodb"]
36
+ SchemaMode = Literal["off", "create", "sync"]
37
+
38
+
39
+ class DatabaseSettings(BaseModel):
40
+ """Cấu hình database. Chỉ MỘT driver được dùng tại một thời điểm.
41
+
42
+ Đặt qua biến môi trường, ví dụ:
43
+ APP_DB__DRIVER=postgres
44
+ APP_DB__DSN=postgresql+asyncpg://user:pass@localhost:5432/app
45
+ """
46
+
47
+ driver: Driver = "memory"
48
+ dsn: str | None = None
49
+ name: str = "app"
50
+ echo: bool = False
51
+
52
+ schema_mode: SchemaMode = "create"
53
+ """Mức độ tự chỉnh schema lúc khởi động (tương đương `synchronize` của TypeORM).
54
+
55
+ - "off" : không đụng gì. Dùng cho production, đi kèm Alembic.
56
+ - "create" : chỉ tạo bảng còn thiếu. Thêm/xoá trường trong entity KHÔNG có
57
+ tác dụng lên bảng đã tồn tại.
58
+ - "sync" : thêm cột mới, và báo cột thừa / cột lệch kiểu. Chỉ dùng ở dev.
59
+ """
60
+
61
+ drop_columns: bool = False
62
+ """Cho phép schema_mode="sync" XOÁ cột không còn trong entity.
63
+
64
+ Mặc định tắt vì xoá cột là mất dữ liệu vĩnh viễn. Bật khi bạn thật sự muốn
65
+ dọn cột thừa trong lúc phát triển.
66
+ """
67
+
68
+ # ---- kết nối & phục hồi khi database rớt --------------------------------
69
+ pool_pre_ping: bool = True
70
+ """Kiểm tra connection còn sống trước khi giao cho request (chỉ SQL).
71
+
72
+ Không bật thì sau mỗi lần database restart hoặc firewall cắt kết nối nhàn
73
+ rỗi, request đầu tiên sẽ lỗi 500 vì nhận phải connection đã chết. Bật thì
74
+ pool âm thầm thay bằng connection mới, client không thấy gì.
75
+ """
76
+
77
+ pool_size: int = 5
78
+ max_overflow: int = 10
79
+ """Trần connection là (pool_size + max_overflow) x SỐ WORKER, không phải
80
+ tổng của cả app. Mặc định 15 mỗi worker; Postgres mặc định max_connections
81
+ là 100, nên từ 6 worker trở lên phải giảm số này hoặc nâng max_connections."""
82
+ pool_recycle_seconds: int = 1800
83
+ """Đóng và mở lại connection cũ hơn ngưỡng này. Nhiều proxy/firewall cắt
84
+ kết nối nhàn rỗi sau 30–60 phút mà không báo cho hai đầu."""
85
+
86
+ connect_timeout_seconds: float = 10.0
87
+ """Chờ tối đa bấy nhiêu giây khi MỞ kết nối. Với MongoDB đây cũng là hạn
88
+ chọn server — để mặc định 30s của driver thì request sẽ treo rất lâu."""
89
+
90
+ query_timeout_seconds: float = 15.0
91
+ """Chờ tối đa bấy nhiêu giây cho MỘT câu truy vấn đã gửi đi.
92
+
93
+ Khác connect_timeout: nếu database treo giữa lúc đang chạy câu lệnh (mất
94
+ điện, đóng băng, khoá bảng), connection vẫn "mở" nên connect_timeout không
95
+ cứu được — request sẽ treo cho tới khi client bỏ cuộc."""
96
+
97
+ startup_retries: int = 10
98
+ startup_retry_delay_seconds: float = 1.0
99
+
100
+ circuit_breaker: bool = True
101
+ """Ngắt mạch khi database hỏng liên tiếp, thay vì để mọi request cùng chờ
102
+ hết timeout. Không áp dụng cho driver 'memory'."""
103
+
104
+ circuit_failure_threshold: int = 5
105
+ circuit_reset_seconds: float = 10.0
106
+ """Thử lại khi khởi động mà database chưa sẵn sàng (hay gặp với
107
+ docker compose: app lên trước database)."""
108
+
109
+ _DEFAULT_DSN: ClassVar[dict[str, str]] = {
110
+ "sqlite": "sqlite+aiosqlite:///./data/{name}.db",
111
+ "postgres": "postgresql+asyncpg://postgres:postgres@localhost:5432/{name}",
112
+ "mongodb": "mongodb://localhost:27017",
113
+ }
114
+
115
+ @property
116
+ def resolved_dsn(self) -> str:
117
+ if self.dsn:
118
+ return self.dsn
119
+ template = self._DEFAULT_DSN.get(self.driver, "")
120
+ return template.format(name=self.name)
121
+
122
+ class WebSocketSettings(BaseModel):
123
+ """Cấu hình lớp WebSocket. Đặt qua APP_WS__*, ví dụ APP_WS__ADAPTER=redis."""
124
+
125
+ send_queue_size: int = 100
126
+ """Trần số tin CHỜ GỬI cho mỗi kết nối.
127
+
128
+ Client đọc chậm mà server cứ đẩy thì hàng đợi phình cho tới khi hết RAM —
129
+ một client hỏng kéo sập cả worker. Có trần thì hỏng cục bộ ở đúng client đó.
130
+ """
131
+
132
+ overflow: Literal["close", "drop_oldest"] = "close"
133
+ """Xử lý khi hàng đợi đầy.
134
+
135
+ - "close" : ngắt kết nối (mã 1013). Client nối lại và tải lại trạng
136
+ thái — trung thực hơn là âm thầm mất tin.
137
+ - "drop_oldest": bỏ tin cũ nhất. Hợp với dữ liệu chỉ cần bản mới nhất
138
+ (vị trí, nhiệt độ, tiến độ).
139
+ """
140
+
141
+ heartbeat_seconds: float = 25.0
142
+ """Chu kỳ server đẩy `ping`. Dưới 30s vì nhiều proxy (nginx, ALB, Cloudflare)
143
+ cắt kết nối WebSocket nhàn rỗi khoảng 60s mà không báo hai đầu."""
144
+
145
+ idle_timeout_seconds: float = 70.0
146
+ """Không nhận được KHUNG TIN NÀO trong bấy nhiêu giây thì đóng.
147
+
148
+ Phải lớn hơn heartbeat vài lần: client trả lời `pong` sẽ làm mới đồng hồ
149
+ này, nên chỉ kết nối thật sự chết mới bị cắt. Thiếu nó thì socket "ma"
150
+ (mất mạng đột ngột, TCP chưa kịp biết) tích lại tới hết bộ nhớ."""
151
+
152
+ max_message_bytes: int = 64 * 1024
153
+ max_messages_per_second: float = 50.0
154
+ """Trần tần suất mỗi kết nối; 0 để tắt."""
155
+
156
+ burst_messages: int = 100
157
+ """Số tin cho phép bùng tức thời trước khi bị siết theo tần suất trên."""
158
+
159
+ max_connections: int = 5000
160
+ """Trần kết nối MỖI WORKER."""
161
+
162
+ max_connections_per_user: int = 10
163
+ """Trần kết nối đồng thời của một tài khoản (nhiều tab, nhiều thiết bị);
164
+ 0 để tắt. Chỉ có tác dụng khi gateway có guard xác thực."""
165
+
166
+ max_rooms_per_socket: int = 64
167
+
168
+ adapter: Literal["local", "redis"] = "local"
169
+ """`local` chỉ đúng khi chạy MỘT worker: sổ kết nối nằm trong RAM tiến
170
+ trình, worker này không thấy kết nối của worker kia nên broadcast sẽ thiếu
171
+ người nhận. Nhiều worker/nhiều máy thì phải dùng `redis`."""
172
+
173
+ redis_url: str = "redis://localhost:6379/0"
174
+ channel: str = "ws:broadcast"
175
+
176
+
177
+ class RabbitSettings(BaseModel):
178
+ """Cấu hình RabbitMQ. Đặt qua APP_RABBITMQ__*, ví dụ APP_RABBITMQ__ENABLED=true.
179
+
180
+ MẶC ĐỊNH TẮT. Dự án không dùng RabbitMQ thì không phải cài `aio-pika`,
181
+ không phải đụng gì tới file này, và mọi thứ chạy y như cũ — giống hệt cách
182
+ driver database được tách riêng.
183
+ """
184
+
185
+ enabled: bool = False
186
+ url: str = "amqp://guest:guest@localhost:5672/"
187
+ """Mặc định trỏ localhost. Bật RabbitMQ mà quên đặt biến này thì lúc khởi
188
+ động sẽ có cảnh báo `rabbitmq.default_url` — đọc kỹ nó trước khi đi tìm
189
+ xem broker có chết không."""
190
+
191
+ publish_timeout_seconds: float = 5.0
192
+ """Chờ broker xác nhận đã nhận tin. Đây là thuộc tính của KẾT NỐI (mạng
193
+ chậm, broker quá tải), không phải quyết định nghiệp vụ — nên nằm ở đây.
194
+ Lời gọi nào cần khác thì truyền `publish(..., timeout=...)`."""
195
+ connect_timeout_seconds: float = 10.0
196
+
197
+ # ---- tự nối lại ---------------------------------------------------------
198
+ heartbeat_seconds: int = 30
199
+ """Nhịp tim AMQP. Mất điện đột ngột hay rút cáp thì KHÔNG có gói FIN nào
200
+ cả — hai đầu vẫn tưởng kết nối còn sống. Nhịp tim là thứ duy nhất phát hiện
201
+ được: quá hai nhịp không thấy hồi âm thì client coi như đứt và nối lại.
202
+ Ngưỡng phát hiện vì vậy là khoảng 2 x giá trị này. Hạ xuống thì phát hiện
203
+ nhanh hơn nhưng dễ báo đứt oan khi mạng chớp."""
204
+
205
+ reconnect_delay_seconds: float = 2.0
206
+ """Chờ bao lâu trước lần thử nối lại đầu tiên. Các lần sau tăng gấp đôi cho
207
+ tới `max_reconnect_delay_seconds`, để broker vừa hồi sinh không bị hàng
208
+ trăm tiến trình đập vào cùng lúc."""
209
+
210
+ max_reconnect_delay_seconds: float = 30.0
211
+
212
+
213
+ @property
214
+ def dead_letter_exchange(self) -> str:
215
+ return "dlx"
216
+
217
+
218
+ class RedisSettings(BaseModel):
219
+ """Cấu hình Redis. Đặt qua APP_REDIS__*, ví dụ APP_REDIS__ENABLED=true.
220
+
221
+ MẶC ĐỊNH TẮT, và độc lập với `APP_WS__REDIS_URL`. Hai thứ đó cố ý tách
222
+ nhau: adapter WebSocket phải chạy được ở dự án không hề bật lớp Redis này,
223
+ và ngược lại. Trỏ cả hai vào cùng một server thì hoàn toàn bình thường.
224
+ """
225
+
226
+ enabled: bool = False
227
+ url: str = "redis://localhost:6379/0"
228
+ """Dạng redis://[:MẬT_KHẨU@]HOST:CỔNG/SỐ_DB, hoặc rediss:// nếu có TLS."""
229
+
230
+ key_prefix: str = ""
231
+ """Tiền tố ghép vào MỌI khoá. Nhiều ứng dụng dùng chung một Redis thì đặt
232
+ khác nhau ("don-hang:", "chat:") để không ai ghi đè khoá của ai."""
233
+
234
+ connect_timeout_seconds: float = 5.0
235
+ command_timeout_seconds: float = 5.0
236
+ """Trần thời gian cho MỘT lệnh. Redis chậm còn tệ hơn Redis chết: không có
237
+ trần thì mọi request đang chờ cache sẽ treo theo."""
238
+
239
+ reconnect_delay_seconds: float = 1.0
240
+ max_reconnect_delay_seconds: float = 30.0
241
+
242
+
243
+ class MqttSettings(BaseModel):
244
+ """Cấu hình MQTT. Đặt qua APP_MQTT__*, ví dụ APP_MQTT__ENABLED=true.
245
+
246
+ MẶC ĐỊNH TẮT. Dùng cho thiết bị IoT: nhẹ, giữ kết nối lâu, chịu được mạng
247
+ chập chờn.
248
+ """
249
+
250
+ enabled: bool = False
251
+ url: str = "mqtt://localhost:1883"
252
+ """Dạng mqtt://[NGƯỜI_DÙNG:MẬT_KHẨU@]HOST:CỔNG, hoặc mqtts:// nếu có TLS."""
253
+
254
+ client_id: str = ""
255
+ """Danh tính phiên trên broker. Để trống thì sinh ngẫu nhiên mỗi lần chạy —
256
+ tiện cho dev, nhưng KHÔNG dùng được với clean_session=false vì mỗi lần khởi
257
+ động sẽ là một phiên mới toanh. Chạy nhiều worker thì mỗi worker phải một
258
+ id khác nhau, trùng id là hai bên đá nhau ra khỏi broker liên tục."""
259
+
260
+ clean_session: bool = True
261
+ """false = broker GIỮ tin QoS>=1 lại trong lúc client ngắt, nối lại là giao
262
+ tiếp. Cần đi kèm client_id cố định. true = mất tin trong lúc ngắt."""
263
+
264
+ keepalive_seconds: int = 30
265
+ """Nhịp tim MQTT: quá 1.5 nhịp không thấy gì thì broker coi như client
266
+ chết. Đây là thứ duy nhất phát hiện được cảnh rút cáp/mất điện."""
267
+
268
+ connect_timeout_seconds: float = 10.0
269
+ reconnect_delay_seconds: float = 1.0
270
+ max_reconnect_delay_seconds: float = 30.0
271
+
272
+
273
+ class KafkaSettings(BaseModel):
274
+ """Cấu hình Kafka. Đặt qua APP_KAFKA__*, ví dụ APP_KAFKA__ENABLED=true.
275
+
276
+ MẶC ĐỊNH TẮT. Dùng khi cần nhật ký sự kiện ĐỌC LẠI ĐƯỢC: tin không biến mất
277
+ sau khi xử lý mà nằm lại theo thời gian giữ, nhiều nhóm consumer đọc cùng
278
+ một dòng tin ở các vị trí khác nhau.
279
+ """
280
+
281
+ enabled: bool = False
282
+ bootstrap_servers: str = "localhost:9092"
283
+ """Danh sách HOST:CỔNG ngăn bằng dấu phẩy. Chỉ cần vài broker để hỏi đường;
284
+ client tự tìm ra phần còn lại của cụm."""
285
+
286
+ client_id: str = "pymodular"
287
+ acks: Literal["0", "1", "all"] = "all"
288
+ """Bao nhiêu bản sao phải ghi xong thì mới coi là gửi thành công.
289
+ all = an toàn nhất (chậm hơn) | 1 = chỉ leader | 0 = bắn đi rồi thôi."""
290
+
291
+ request_timeout_seconds: float = 20.0
292
+ connect_timeout_seconds: float = 10.0
293
+ reconnect_delay_seconds: float = 1.0
294
+ max_reconnect_delay_seconds: float = 30.0
295
+
296
+
297
+ class Settings(BaseSettings):
298
+ """Cấu hình của khung. **Kế thừa được** để thêm biến của riêng bạn.
299
+
300
+ # src/config.py
301
+ from pydantic import BaseModel, Field
302
+ from pymodular import Settings
303
+
304
+ class JwtSettings(BaseModel):
305
+ secret: str = ""
306
+ ttl_seconds: int = 3600
307
+
308
+ class AppSettings(Settings):
309
+ jwt: JwtSettings = Field(default_factory=JwtSettings, alias="APP_JWT")
310
+ stripe_key: str = Field(default="", alias="APP_STRIPE_KEY")
311
+
312
+ # src/main.py
313
+ app = create_app(AppSettings())
314
+
315
+ Đọc được ngay từ `.env` hoặc biến môi trường: `APP_JWT__SECRET=...`,
316
+ `APP_STRIPE_KEY=...`. Không phải khai báo ở đâu khác.
317
+
318
+ Service nhận nó qua DI bằng CHÍNH lớp con, và vẫn có gợi ý kiểu đầy đủ:
319
+
320
+ @injectable
321
+ class TokenService:
322
+ def __init__(self, settings: AppSettings) -> None:
323
+ self._secret = settings.jwt.secret
324
+
325
+ `create_app()` đăng ký instance dưới cả `Settings` lẫn mọi lớp con của nó,
326
+ nên thư viện hỏi `Settings` và code của bạn hỏi `AppSettings` đều nhận đúng
327
+ một đối tượng đó.
328
+ """
329
+
330
+ model_config = SettingsConfigDict(
331
+ env_file=".env",
332
+ env_file_encoding="utf-8",
333
+ env_nested_delimiter="__",
334
+ env_prefix="",
335
+ extra="ignore",
336
+ case_sensitive=False,
337
+ populate_by_name=True,
338
+ )
339
+
340
+ # Các trường cấp app dùng prefix APP_ để tránh đụng biến môi trường hệ thống.
341
+ env: Environment = Field(default="local", alias="APP_ENV")
342
+ debug: bool = Field(default=True, alias="APP_DEBUG")
343
+ name: str = Field(default="pymodular", alias="APP_NAME")
344
+ version: str = Field(default="0.1.0", alias="APP_VERSION")
345
+ api_prefix: str = Field(default="/api", alias="APP_API_PREFIX")
346
+ host: str = Field(default="0.0.0.0", alias="APP_HOST")
347
+ port: int = Field(default=8000, alias="APP_PORT")
348
+
349
+ # Nhóm lồng nhau cũng dùng tiền tố APP_ cho đồng bộ; đặt qua biến môi
350
+ # trường bằng dấu ngăn "__", ví dụ APP_DB__DRIVER=postgres.
351
+ log: LogSettings = Field(default_factory=LogSettings, alias="APP_LOG")
352
+ cors: CorsSettings = Field(default_factory=CorsSettings, alias="APP_CORS")
353
+ db: DatabaseSettings = Field(default_factory=DatabaseSettings, alias="APP_DB")
354
+ ws: WebSocketSettings = Field(default_factory=WebSocketSettings, alias="APP_WS")
355
+ rabbitmq: RabbitSettings = Field(default_factory=RabbitSettings, alias="APP_RABBITMQ")
356
+ redis: RedisSettings = Field(default_factory=RedisSettings, alias="APP_REDIS")
357
+ mqtt: MqttSettings = Field(default_factory=MqttSettings, alias="APP_MQTT")
358
+ kafka: KafkaSettings = Field(default_factory=KafkaSettings, alias="APP_KAFKA")
359
+
360
+
361
+ @property
362
+ def is_prod(self) -> bool:
363
+ return self.env == "prod"
364
+
365
+ @property
366
+ def docs_url(self) -> str | None:
367
+ return None if self.is_prod else "/docs"
368
+
369
+ @property
370
+ def redoc_url(self) -> str | None:
371
+ return None if self.is_prod else "/redoc"
372
+
373
+ @property
374
+ def openapi_url(self) -> str | None:
375
+ return None if self.is_prod else f"{self.api_prefix}/openapi.json"
376
+
377
+ def check_production_safety(self) -> list[str]:
378
+ """Những cấu hình không được phép mang lên prod. Trả về danh sách cảnh báo."""
379
+ problems: list[str] = []
380
+ if not self.is_prod:
381
+ return problems
382
+
383
+ if self.cors.allows_any_origin:
384
+ problems.append(
385
+ "cors.allow_origins đang là '*' — với allow_credentials=True thì mọi "
386
+ "website đều gọi được API kèm cookie người dùng. Hãy liệt kê domain cụ thể."
387
+ )
388
+ if self.debug:
389
+ problems.append("debug=True ở prod sẽ lộ chi tiết lỗi ra client.")
390
+ if self.db.driver == "memory":
391
+ problems.append(
392
+ "db.driver='memory' mất dữ liệu khi restart và sai khi chạy nhiều worker."
393
+ )
394
+ if self.db.schema_mode != "off":
395
+ problems.append(
396
+ f"db.schema_mode={self.db.schema_mode!r} tự chỉnh schema lúc khởi động; "
397
+ "prod nên đặt 'off' và dùng Alembic."
398
+ )
399
+ if self.db.drop_columns:
400
+ problems.append("db.drop_columns=True có thể xoá cột kèm dữ liệu.")
401
+ if self.ws.adapter == "local":
402
+ problems.append(
403
+ "ws.adapter='local' chỉ đúng với MỘT worker: mỗi worker giữ sổ kết nối "
404
+ "riêng nên broadcast sẽ không tới được client đang nối vào worker khác. "
405
+ "Chạy nhiều worker thì đặt APP_WS__ADAPTER=redis."
406
+ )
407
+ return problems
408
+
409
+
410
+ # Biến môi trường đã đổi tên. Đặt tên cũ thì pydantic lặng lẽ bỏ qua và dùng
411
+ # giá trị mặc định — nghĩa là app chạy sai mà không báo gì. Bảng này để phát
412
+ # hiện và nói thẳng phải sửa thành gì.
413
+ _TEN_CU: dict[str, str] = {
414
+ "APP_MQ__": "APP_RABBITMQ__",
415
+ }
416
+
417
+ # Biến đã bỏ hẳn. Để trong .env thì vô hại nhưng gây hiểu nhầm là nó còn tác
418
+ # dụng — nói rõ để người ta xoá đi.
419
+ _DA_BO: frozenset[str] = frozenset(
420
+ {
421
+ "BRIDGE_ENABLED", # cầu nối RabbitMQ -> WebSocket, đã gỡ
422
+ "MAX_SUBSCRIPTIONS_PER_SOCKET", # nt
423
+ "FAIL_ON_STARTUP", # luôn tự nối lại, không còn lựa chọn dừng app
424
+ "EXCHANGES", # exchange tự khai lúc dùng, không cần liệt kê
425
+ "DURABLE", # chuyển vào @rabbitmq_subscriber(durable=...)
426
+ "CONSUMER_MAX_RETRIES", # chuyển vào @rabbitmq_subscriber(max_retries=...)
427
+ "CONSUMER_RETRY_DELAY_SECONDS", # chuyển vào @rabbitmq_subscriber(retry_delay=...)
428
+ "PREFETCH", # chuyển vào @rabbitmq_subscriber(prefetch=...)
429
+ }
430
+ )
431
+
432
+
433
+ def check_deprecated_env(env_file: str = ".env") -> list[str]:
434
+ """Tìm khai báo mang tên cũ, trong BIẾN MÔI TRƯỜNG lẫn trong .env.
435
+
436
+ Phải soi cả file .env chứ không chỉ os.environ: pydantic đọc thẳng file đó,
437
+ còn tên cũ nằm trong file thì không xuất hiện ở os.environ. Bỏ sót chỗ này
438
+ thì cảnh báo vô dụng đúng vào trường hợp cần nó nhất.
439
+ """
440
+ names = set(os.environ)
441
+
442
+ path = Path(env_file)
443
+ if path.exists():
444
+ for line in path.read_text(encoding="utf-8").splitlines():
445
+ line = line.strip()
446
+ if line and not line.startswith("#") and "=" in line:
447
+ names.add(line.split("=", 1)[0].strip())
448
+
449
+ problems: list[str] = []
450
+ for name in sorted(names):
451
+ # Kiểm tra "đã bỏ" TRƯỚC "đổi tên": một biến vừa mang tên cũ vừa không
452
+ # còn dùng thì lời khuyên đúng là xoá đi, không phải đổi tên.
453
+ if name.rpartition("__")[2] in _DA_BO and name.startswith(("APP_MQ__", "APP_RABBITMQ__")):
454
+ problems.append(f"{name} -> không còn dùng, xoá dòng này")
455
+ continue
456
+ for cu, moi in _TEN_CU.items():
457
+ if name.startswith(cu):
458
+ problems.append(f"{name} -> đổi thành {name.replace(cu, moi, 1)}")
459
+ return problems
460
+
461
+
462
+ _LOP_SETTINGS: type[Settings] = Settings
463
+
464
+
465
+ def use_settings(cls: type[Settings]) -> None:
466
+ """Khai lớp Settings mở rộng, cho những chỗ khung TỰ dựng cấu hình.
467
+
468
+ `create_app(AppSettings())` đã tự gọi hàm này. Chỉ phải gọi tay ở những
469
+ đường vào không đi qua create_app — chủ yếu là Alembic (`migrations/env.py`)
470
+ và script chạy một lần:
471
+
472
+ from src.core.config import AppSettings
473
+ use_settings(AppSettings)
474
+ settings = get_settings() # giờ trả về AppSettings
475
+ """
476
+ global _LOP_SETTINGS
477
+ if not (isinstance(cls, type) and issubclass(cls, Settings)):
478
+ raise TypeError(f"{cls!r} phải là lớp con của Settings")
479
+ if cls is not _LOP_SETTINGS:
480
+ _LOP_SETTINGS = cls
481
+ get_settings.cache_clear()
482
+
483
+
484
+ def settings_class() -> type[Settings]:
485
+ """Lớp đang được dùng để dựng cấu hình."""
486
+ return _LOP_SETTINGS
487
+
488
+
489
+ @lru_cache(maxsize=1)
490
+ def get_settings() -> Settings:
491
+ """Singleton settings — cache để không parse .env nhiều lần.
492
+
493
+ Trả về lớp đã khai bằng `use_settings()`, mặc định là `Settings`.
494
+ """
495
+ return _LOP_SETTINGS()