mdb-engine 0.1.6__py3-none-any.whl → 0.4.12__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.
- mdb_engine/__init__.py +116 -11
- mdb_engine/auth/ARCHITECTURE.md +112 -0
- mdb_engine/auth/README.md +654 -11
- mdb_engine/auth/__init__.py +136 -29
- mdb_engine/auth/audit.py +592 -0
- mdb_engine/auth/base.py +252 -0
- mdb_engine/auth/casbin_factory.py +265 -70
- mdb_engine/auth/config_defaults.py +5 -5
- mdb_engine/auth/config_helpers.py +19 -18
- mdb_engine/auth/cookie_utils.py +12 -16
- mdb_engine/auth/csrf.py +483 -0
- mdb_engine/auth/decorators.py +10 -16
- mdb_engine/auth/dependencies.py +69 -71
- mdb_engine/auth/helpers.py +3 -3
- mdb_engine/auth/integration.py +61 -88
- mdb_engine/auth/jwt.py +11 -15
- mdb_engine/auth/middleware.py +79 -35
- mdb_engine/auth/oso_factory.py +21 -41
- mdb_engine/auth/provider.py +270 -171
- mdb_engine/auth/rate_limiter.py +505 -0
- mdb_engine/auth/restrictions.py +21 -36
- mdb_engine/auth/session_manager.py +24 -41
- mdb_engine/auth/shared_middleware.py +977 -0
- mdb_engine/auth/shared_users.py +775 -0
- mdb_engine/auth/token_lifecycle.py +10 -12
- mdb_engine/auth/token_store.py +17 -32
- mdb_engine/auth/users.py +99 -159
- mdb_engine/auth/utils.py +236 -42
- mdb_engine/cli/commands/generate.py +546 -10
- mdb_engine/cli/commands/validate.py +3 -7
- mdb_engine/cli/utils.py +7 -7
- mdb_engine/config.py +13 -28
- mdb_engine/constants.py +65 -0
- mdb_engine/core/README.md +117 -6
- mdb_engine/core/__init__.py +39 -7
- mdb_engine/core/app_registration.py +31 -50
- mdb_engine/core/app_secrets.py +289 -0
- mdb_engine/core/connection.py +20 -12
- mdb_engine/core/encryption.py +222 -0
- mdb_engine/core/engine.py +2862 -115
- mdb_engine/core/index_management.py +12 -16
- mdb_engine/core/manifest.py +628 -204
- mdb_engine/core/ray_integration.py +436 -0
- mdb_engine/core/seeding.py +13 -21
- mdb_engine/core/service_initialization.py +20 -30
- mdb_engine/core/types.py +40 -43
- mdb_engine/database/README.md +140 -17
- mdb_engine/database/__init__.py +17 -6
- mdb_engine/database/abstraction.py +37 -50
- mdb_engine/database/connection.py +51 -30
- mdb_engine/database/query_validator.py +367 -0
- mdb_engine/database/resource_limiter.py +204 -0
- mdb_engine/database/scoped_wrapper.py +747 -237
- mdb_engine/dependencies.py +427 -0
- mdb_engine/di/__init__.py +34 -0
- mdb_engine/di/container.py +247 -0
- mdb_engine/di/providers.py +206 -0
- mdb_engine/di/scopes.py +139 -0
- mdb_engine/embeddings/README.md +54 -24
- mdb_engine/embeddings/__init__.py +31 -24
- mdb_engine/embeddings/dependencies.py +38 -155
- mdb_engine/embeddings/service.py +78 -75
- mdb_engine/exceptions.py +104 -12
- mdb_engine/indexes/README.md +30 -13
- mdb_engine/indexes/__init__.py +1 -0
- mdb_engine/indexes/helpers.py +11 -11
- mdb_engine/indexes/manager.py +59 -123
- mdb_engine/memory/README.md +95 -4
- mdb_engine/memory/__init__.py +1 -2
- mdb_engine/memory/service.py +363 -1168
- mdb_engine/observability/README.md +4 -2
- mdb_engine/observability/__init__.py +26 -9
- mdb_engine/observability/health.py +17 -17
- mdb_engine/observability/logging.py +10 -10
- mdb_engine/observability/metrics.py +40 -19
- mdb_engine/repositories/__init__.py +34 -0
- mdb_engine/repositories/base.py +325 -0
- mdb_engine/repositories/mongo.py +233 -0
- mdb_engine/repositories/unit_of_work.py +166 -0
- mdb_engine/routing/README.md +1 -1
- mdb_engine/routing/__init__.py +1 -3
- mdb_engine/routing/websockets.py +41 -75
- mdb_engine/utils/__init__.py +3 -1
- mdb_engine/utils/mongo.py +117 -0
- mdb_engine-0.4.12.dist-info/METADATA +492 -0
- mdb_engine-0.4.12.dist-info/RECORD +97 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/WHEEL +1 -1
- mdb_engine-0.1.6.dist-info/METADATA +0 -213
- mdb_engine-0.1.6.dist-info/RECORD +0 -75
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/entry_points.txt +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/licenses/LICENSE +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit of Work Pattern
|
|
3
|
+
|
|
4
|
+
Manages repository access and provides a clean interface for data operations.
|
|
5
|
+
The UnitOfWork acts as a factory for repositories and manages their lifecycle.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any, Generic, TypeVar
|
|
10
|
+
|
|
11
|
+
from .base import Entity, Repository
|
|
12
|
+
from .mongo import MongoRepository
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T", bound=Entity)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class UnitOfWork:
|
|
20
|
+
"""
|
|
21
|
+
Unit of Work for managing repository access.
|
|
22
|
+
|
|
23
|
+
Provides a clean interface for accessing repositories through
|
|
24
|
+
attribute access (e.g., uow.users, uow.orders).
|
|
25
|
+
|
|
26
|
+
The UnitOfWork is request-scoped - one instance per HTTP request.
|
|
27
|
+
Repositories are created lazily and cached for the duration of the request.
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
# In a route handler
|
|
31
|
+
@app.get("/users/{user_id}")
|
|
32
|
+
async def get_user(user_id: str, ctx: RequestContext = Depends()):
|
|
33
|
+
# Access repository through UnitOfWork
|
|
34
|
+
user = await ctx.uow.users.get(user_id)
|
|
35
|
+
return user
|
|
36
|
+
|
|
37
|
+
# With explicit repository method
|
|
38
|
+
@app.get("/orders")
|
|
39
|
+
async def list_orders(ctx: RequestContext = Depends()):
|
|
40
|
+
repo = ctx.uow.repository("orders", Order)
|
|
41
|
+
return await repo.find({"status": "pending"})
|
|
42
|
+
|
|
43
|
+
Repository Naming Convention:
|
|
44
|
+
- Attribute access uses collection name: uow.users -> users collection
|
|
45
|
+
- The entity class defaults to Entity if not registered
|
|
46
|
+
- Register entity classes for type-safe repositories
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
db: Any, # ScopedMongoWrapper - avoid import cycle
|
|
52
|
+
entity_registry: dict[str, type[Entity]] | None = None,
|
|
53
|
+
):
|
|
54
|
+
"""
|
|
55
|
+
Initialize the Unit of Work.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
db: ScopedMongoWrapper for database access
|
|
59
|
+
entity_registry: Optional mapping of collection names to entity classes
|
|
60
|
+
"""
|
|
61
|
+
self._db = db
|
|
62
|
+
self._repositories: dict[str, Repository] = {}
|
|
63
|
+
self._entity_registry: dict[str, type[Entity]] = entity_registry or {}
|
|
64
|
+
|
|
65
|
+
def register_entity(self, collection_name: str, entity_class: type[Entity]) -> None:
|
|
66
|
+
"""
|
|
67
|
+
Register an entity class for a collection.
|
|
68
|
+
|
|
69
|
+
This enables type-safe repository access.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
collection_name: Name of the collection
|
|
73
|
+
entity_class: Entity subclass for this collection
|
|
74
|
+
"""
|
|
75
|
+
self._entity_registry[collection_name] = entity_class
|
|
76
|
+
|
|
77
|
+
def repository(
|
|
78
|
+
self,
|
|
79
|
+
name: str,
|
|
80
|
+
entity_class: type[T] | None = None,
|
|
81
|
+
) -> Repository[T]:
|
|
82
|
+
"""
|
|
83
|
+
Get or create a repository for a collection.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
name: Collection name
|
|
87
|
+
entity_class: Optional entity class override
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Repository instance for the collection
|
|
91
|
+
"""
|
|
92
|
+
if name in self._repositories:
|
|
93
|
+
return self._repositories[name]
|
|
94
|
+
|
|
95
|
+
# Determine entity class
|
|
96
|
+
if entity_class is None:
|
|
97
|
+
entity_class = self._entity_registry.get(name, Entity)
|
|
98
|
+
|
|
99
|
+
# Get collection from db wrapper
|
|
100
|
+
collection = getattr(self._db, name)
|
|
101
|
+
|
|
102
|
+
# Create repository
|
|
103
|
+
repo = MongoRepository(collection, entity_class)
|
|
104
|
+
self._repositories[name] = repo
|
|
105
|
+
|
|
106
|
+
logger.debug(f"Created repository for '{name}' with entity {entity_class.__name__}")
|
|
107
|
+
return repo
|
|
108
|
+
|
|
109
|
+
def __getattr__(self, name: str) -> Repository:
|
|
110
|
+
"""
|
|
111
|
+
Access repositories via attribute syntax.
|
|
112
|
+
|
|
113
|
+
Example:
|
|
114
|
+
uow.users # Returns Repository for 'users' collection
|
|
115
|
+
uow.orders # Returns Repository for 'orders' collection
|
|
116
|
+
"""
|
|
117
|
+
# Prevent recursion on private attributes
|
|
118
|
+
if name.startswith("_"):
|
|
119
|
+
raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")
|
|
120
|
+
|
|
121
|
+
return self.repository(name)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def db(self) -> Any:
|
|
125
|
+
"""
|
|
126
|
+
Direct access to the underlying ScopedMongoWrapper.
|
|
127
|
+
|
|
128
|
+
Use this for operations not covered by the Repository interface,
|
|
129
|
+
like complex aggregations or raw queries.
|
|
130
|
+
|
|
131
|
+
Example:
|
|
132
|
+
# Complex aggregation
|
|
133
|
+
pipeline = [{"$match": {...}}, {"$group": {...}}]
|
|
134
|
+
results = await ctx.uow.db.users.aggregate(pipeline).to_list(None)
|
|
135
|
+
"""
|
|
136
|
+
return self._db
|
|
137
|
+
|
|
138
|
+
def dispose(self) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Dispose of the UnitOfWork and clear cached repositories.
|
|
141
|
+
|
|
142
|
+
Called automatically at the end of a request scope.
|
|
143
|
+
"""
|
|
144
|
+
self._repositories.clear()
|
|
145
|
+
logger.debug("UnitOfWork disposed")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class TypedUnitOfWork(UnitOfWork, Generic[T]):
|
|
149
|
+
"""
|
|
150
|
+
Generic typed UnitOfWork for better IDE support.
|
|
151
|
+
|
|
152
|
+
This is a convenience class that provides type hints for specific
|
|
153
|
+
repository types.
|
|
154
|
+
|
|
155
|
+
Usage:
|
|
156
|
+
class MyUnitOfWork(TypedUnitOfWork):
|
|
157
|
+
@property
|
|
158
|
+
def users(self) -> Repository[User]:
|
|
159
|
+
return self.repository("users", User)
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def orders(self) -> Repository[Order]:
|
|
163
|
+
return self.repository("orders", Order)
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
pass
|
mdb_engine/routing/README.md
CHANGED
|
@@ -286,7 +286,7 @@ async def safe_message_handler(websocket, message):
|
|
|
286
286
|
"type": "action_result",
|
|
287
287
|
"result": result
|
|
288
288
|
})
|
|
289
|
-
except
|
|
289
|
+
except (ValueError, TypeError, KeyError) as e:
|
|
290
290
|
logger.error(f"Error handling message: {e}")
|
|
291
291
|
# Send error to client
|
|
292
292
|
manager = await get_websocket_manager("my_app")
|
mdb_engine/routing/__init__.py
CHANGED
|
@@ -23,9 +23,7 @@ def _check_websockets_available():
|
|
|
23
23
|
try:
|
|
24
24
|
from fastapi import WebSocket
|
|
25
25
|
|
|
26
|
-
_websockets_module = __import__(
|
|
27
|
-
".websockets", fromlist=[""], package=__name__
|
|
28
|
-
)
|
|
26
|
+
_websockets_module = __import__(".websockets", fromlist=[""], package=__name__)
|
|
29
27
|
_websockets_available = True
|
|
30
28
|
except (ImportError, AttributeError):
|
|
31
29
|
_websockets_available = False
|
mdb_engine/routing/websockets.py
CHANGED
|
@@ -23,9 +23,10 @@ This module is part of MDB_ENGINE - MongoDB Engine.
|
|
|
23
23
|
import asyncio
|
|
24
24
|
import json
|
|
25
25
|
import logging
|
|
26
|
+
from collections.abc import Awaitable, Callable
|
|
26
27
|
from dataclasses import dataclass
|
|
27
28
|
from datetime import datetime
|
|
28
|
-
from typing import Any
|
|
29
|
+
from typing import Any
|
|
29
30
|
|
|
30
31
|
# Check if FastAPI WebSocket support is available (OPTIONAL dependency)
|
|
31
32
|
try:
|
|
@@ -47,8 +48,8 @@ class WebSocketConnection:
|
|
|
47
48
|
|
|
48
49
|
websocket: Any
|
|
49
50
|
app_slug: str
|
|
50
|
-
user_id:
|
|
51
|
-
user_email:
|
|
51
|
+
user_id: str | None = None
|
|
52
|
+
user_email: str | None = None
|
|
52
53
|
connected_at: datetime = None
|
|
53
54
|
|
|
54
55
|
def __post_init__(self):
|
|
@@ -72,17 +73,15 @@ class WebSocketConnectionManager:
|
|
|
72
73
|
app_slug: App slug for scoping connections (ensures isolation)
|
|
73
74
|
"""
|
|
74
75
|
self.app_slug = app_slug
|
|
75
|
-
self.active_connections:
|
|
76
|
-
[]
|
|
77
|
-
) # List of connection metadata
|
|
76
|
+
self.active_connections: list[WebSocketConnection] = [] # List of connection metadata
|
|
78
77
|
self._lock = asyncio.Lock()
|
|
79
78
|
logger.debug(f"Initialized WebSocket manager for app: {app_slug}")
|
|
80
79
|
|
|
81
80
|
async def connect(
|
|
82
81
|
self,
|
|
83
82
|
websocket: Any,
|
|
84
|
-
user_id:
|
|
85
|
-
user_email:
|
|
83
|
+
user_id: str | None = None,
|
|
84
|
+
user_email: str | None = None,
|
|
86
85
|
) -> WebSocketConnection:
|
|
87
86
|
"""
|
|
88
87
|
Accept and register a WebSocket connection with metadata.
|
|
@@ -97,10 +96,7 @@ class WebSocketConnectionManager:
|
|
|
97
96
|
"""
|
|
98
97
|
# Note: websocket should already be accepted by the endpoint handler
|
|
99
98
|
# This is just for tracking - don't accept again
|
|
100
|
-
if (
|
|
101
|
-
hasattr(websocket, "client_state")
|
|
102
|
-
and websocket.client_state.name != "CONNECTED"
|
|
103
|
-
):
|
|
99
|
+
if hasattr(websocket, "client_state") and websocket.client_state.name != "CONNECTED":
|
|
104
100
|
await websocket.accept()
|
|
105
101
|
connection = WebSocketConnection(
|
|
106
102
|
websocket=websocket,
|
|
@@ -130,9 +126,7 @@ class WebSocketConnectionManager:
|
|
|
130
126
|
async def _disconnect():
|
|
131
127
|
async with self._lock:
|
|
132
128
|
self.active_connections = [
|
|
133
|
-
conn
|
|
134
|
-
for conn in self.active_connections
|
|
135
|
-
if conn.websocket is not websocket
|
|
129
|
+
conn for conn in self.active_connections if conn.websocket is not websocket
|
|
136
130
|
]
|
|
137
131
|
logger.info(
|
|
138
132
|
f"WebSocket disconnected for app '{self.app_slug}'. "
|
|
@@ -141,9 +135,7 @@ class WebSocketConnectionManager:
|
|
|
141
135
|
|
|
142
136
|
asyncio.create_task(_disconnect())
|
|
143
137
|
|
|
144
|
-
async def broadcast(
|
|
145
|
-
self, message: Dict[str, Any], filter_by_user: Optional[str] = None
|
|
146
|
-
) -> int:
|
|
138
|
+
async def broadcast(self, message: dict[str, Any], filter_by_user: str | None = None) -> int:
|
|
147
139
|
"""
|
|
148
140
|
Broadcast a message to all connected clients for this app.
|
|
149
141
|
|
|
@@ -205,7 +197,7 @@ class WebSocketConnectionManager:
|
|
|
205
197
|
|
|
206
198
|
return sent_count
|
|
207
199
|
|
|
208
|
-
async def send_to_connection(self, websocket: Any, message:
|
|
200
|
+
async def send_to_connection(self, websocket: Any, message: dict[str, Any]) -> None:
|
|
209
201
|
"""
|
|
210
202
|
Send a message to a specific WebSocket connection.
|
|
211
203
|
|
|
@@ -241,7 +233,7 @@ class WebSocketConnectionManager:
|
|
|
241
233
|
logger.debug(f"Error sending message to specific WebSocket: {e}")
|
|
242
234
|
self.disconnect(websocket)
|
|
243
235
|
|
|
244
|
-
def get_connections_by_user(self, user_id: str) ->
|
|
236
|
+
def get_connections_by_user(self, user_id: str) -> list[WebSocketConnection]:
|
|
245
237
|
"""
|
|
246
238
|
Get all connections for a specific user.
|
|
247
239
|
|
|
@@ -271,14 +263,12 @@ class WebSocketConnectionManager:
|
|
|
271
263
|
|
|
272
264
|
|
|
273
265
|
# Global registry of WebSocket managers per app (app-level isolation)
|
|
274
|
-
_websocket_managers:
|
|
266
|
+
_websocket_managers: dict[str, WebSocketConnectionManager] = {}
|
|
275
267
|
_manager_lock = asyncio.Lock()
|
|
276
268
|
|
|
277
269
|
# Global registry of message handlers per app (for listening to client messages)
|
|
278
270
|
# Note: Registration happens synchronously during app startup, so no lock needed
|
|
279
|
-
_message_handlers:
|
|
280
|
-
str, Dict[str, Callable[[Any, Dict[str, Any]], Awaitable[None]]]
|
|
281
|
-
] = {}
|
|
271
|
+
_message_handlers: dict[str, dict[str, Callable[[Any, dict[str, Any]], Awaitable[None]]]] = {}
|
|
282
272
|
|
|
283
273
|
|
|
284
274
|
async def get_websocket_manager(app_slug: str) -> WebSocketConnectionManager:
|
|
@@ -319,7 +309,7 @@ def get_websocket_manager_sync(app_slug: str) -> WebSocketConnectionManager:
|
|
|
319
309
|
|
|
320
310
|
async def authenticate_websocket(
|
|
321
311
|
websocket: Any, app_slug: str, require_auth: bool = True
|
|
322
|
-
) ->
|
|
312
|
+
) -> tuple[str | None, str | None]:
|
|
323
313
|
"""
|
|
324
314
|
Authenticate a WebSocket connection.
|
|
325
315
|
|
|
@@ -367,22 +357,16 @@ async def authenticate_websocket(
|
|
|
367
357
|
else:
|
|
368
358
|
logger.info(f"WebSocket query_params is empty for app '{app_slug}'")
|
|
369
359
|
else:
|
|
370
|
-
logger.warning(
|
|
371
|
-
f"WebSocket has no query_params attribute for app '{app_slug}'"
|
|
372
|
-
)
|
|
360
|
+
logger.warning(f"WebSocket has no query_params attribute for app '{app_slug}'")
|
|
373
361
|
|
|
374
362
|
# If no token in query, try to get from cookies (if available)
|
|
375
363
|
# Check both ws_token (non-httponly, for JS access) and token (httponly)
|
|
376
364
|
if not token:
|
|
377
365
|
if hasattr(websocket, "cookies"):
|
|
378
|
-
cookie_token = websocket.cookies.get(
|
|
379
|
-
"ws_token"
|
|
380
|
-
) or websocket.cookies.get("token")
|
|
366
|
+
cookie_token = websocket.cookies.get("ws_token") or websocket.cookies.get("token")
|
|
381
367
|
if cookie_token:
|
|
382
368
|
token = cookie_token
|
|
383
|
-
logger.debug(
|
|
384
|
-
f"WebSocket token found in cookies for app '{app_slug}'"
|
|
385
|
-
)
|
|
369
|
+
logger.debug(f"WebSocket token found in cookies for app '{app_slug}'")
|
|
386
370
|
else:
|
|
387
371
|
logger.debug(f"WebSocket has no cookies attribute for app '{app_slug}'")
|
|
388
372
|
|
|
@@ -404,6 +388,8 @@ async def authenticate_websocket(
|
|
|
404
388
|
return None, None # Signal auth failure
|
|
405
389
|
return None, None
|
|
406
390
|
|
|
391
|
+
import jwt
|
|
392
|
+
|
|
407
393
|
from ..auth.dependencies import SECRET_KEY
|
|
408
394
|
from ..auth.jwt import decode_jwt_token
|
|
409
395
|
|
|
@@ -412,22 +398,16 @@ async def authenticate_websocket(
|
|
|
412
398
|
user_id = payload.get("sub") or payload.get("user_id")
|
|
413
399
|
user_email = payload.get("email")
|
|
414
400
|
|
|
415
|
-
logger.info(
|
|
416
|
-
f"WebSocket authenticated successfully for app '{app_slug}': {user_email}"
|
|
417
|
-
)
|
|
401
|
+
logger.info(f"WebSocket authenticated successfully for app '{app_slug}': {user_email}")
|
|
418
402
|
return user_id, user_email
|
|
419
|
-
except
|
|
420
|
-
logger.error(
|
|
421
|
-
f"JWT decode error for app '{app_slug}': {decode_error}", exc_info=True
|
|
422
|
-
)
|
|
403
|
+
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError) as decode_error:
|
|
404
|
+
logger.error(f"JWT decode error for app '{app_slug}': {decode_error}", exc_info=True)
|
|
423
405
|
raise
|
|
424
406
|
|
|
425
407
|
except WebSocketDisconnect:
|
|
426
408
|
raise
|
|
427
409
|
except (ValueError, TypeError, AttributeError, KeyError, RuntimeError) as e:
|
|
428
|
-
logger.error(
|
|
429
|
-
f"WebSocket authentication failed for app '{app_slug}': {e}", exc_info=True
|
|
430
|
-
)
|
|
410
|
+
logger.error(f"WebSocket authentication failed for app '{app_slug}': {e}", exc_info=True)
|
|
431
411
|
if require_auth:
|
|
432
412
|
# Don't close before accepting - return error info instead
|
|
433
413
|
return None, None # Signal auth failure
|
|
@@ -437,7 +417,7 @@ async def authenticate_websocket(
|
|
|
437
417
|
def register_message_handler(
|
|
438
418
|
app_slug: str,
|
|
439
419
|
endpoint_name: str,
|
|
440
|
-
handler: Callable[[Any,
|
|
420
|
+
handler: Callable[[Any, dict[str, Any]], Awaitable[None]],
|
|
441
421
|
) -> None:
|
|
442
422
|
"""
|
|
443
423
|
Register a message handler for a WebSocket endpoint.
|
|
@@ -469,14 +449,12 @@ def register_message_handler(
|
|
|
469
449
|
if app_slug not in _message_handlers:
|
|
470
450
|
_message_handlers[app_slug] = {}
|
|
471
451
|
_message_handlers[app_slug][endpoint_name] = handler
|
|
472
|
-
logger.info(
|
|
473
|
-
f"Registered message handler for app '{app_slug}', endpoint '{endpoint_name}'"
|
|
474
|
-
)
|
|
452
|
+
logger.info(f"Registered message handler for app '{app_slug}', endpoint '{endpoint_name}'")
|
|
475
453
|
|
|
476
454
|
|
|
477
455
|
def get_message_handler(
|
|
478
456
|
app_slug: str, endpoint_name: str
|
|
479
|
-
) ->
|
|
457
|
+
) -> Callable[[Any, dict[str, Any]], Awaitable[None]] | None:
|
|
480
458
|
"""
|
|
481
459
|
Get a registered message handler for an endpoint.
|
|
482
460
|
|
|
@@ -497,7 +475,7 @@ async def _accept_websocket_connection(websocket: Any, app_slug: str) -> None:
|
|
|
497
475
|
await websocket.accept()
|
|
498
476
|
print(f"✅ [WEBSOCKET ACCEPTED] App: '{app_slug}'")
|
|
499
477
|
logger.info(f"✅ WebSocket accepted for app '{app_slug}'")
|
|
500
|
-
except
|
|
478
|
+
except (RuntimeError, ConnectionError, OSError) as accept_error:
|
|
501
479
|
print(f"❌ [WEBSOCKET ACCEPT FAILED] App: '{app_slug}', Error: {accept_error}")
|
|
502
480
|
logger.error(
|
|
503
481
|
f"❌ Failed to accept WebSocket for app '{app_slug}': {accept_error}",
|
|
@@ -511,9 +489,7 @@ async def _authenticate_websocket_connection(
|
|
|
511
489
|
) -> tuple:
|
|
512
490
|
"""Authenticate WebSocket connection and return (user_id, user_email)."""
|
|
513
491
|
try:
|
|
514
|
-
user_id, user_email = await authenticate_websocket(
|
|
515
|
-
websocket, app_slug, require_auth
|
|
516
|
-
)
|
|
492
|
+
user_id, user_email = await authenticate_websocket(websocket, app_slug, require_auth)
|
|
517
493
|
|
|
518
494
|
if require_auth and not user_id:
|
|
519
495
|
logger.warning(
|
|
@@ -522,9 +498,7 @@ async def _authenticate_websocket_connection(
|
|
|
522
498
|
try:
|
|
523
499
|
await websocket.close(code=1008, reason="Authentication required")
|
|
524
500
|
except (WebSocketDisconnect, RuntimeError, OSError) as e:
|
|
525
|
-
logger.debug(
|
|
526
|
-
f"WebSocket already closed during auth failure cleanup: {e}"
|
|
527
|
-
)
|
|
501
|
+
logger.debug(f"WebSocket already closed during auth failure cleanup: {e}")
|
|
528
502
|
raise WebSocketDisconnect(code=1008)
|
|
529
503
|
|
|
530
504
|
return user_id, user_email
|
|
@@ -547,21 +521,19 @@ async def _authenticate_websocket_connection(
|
|
|
547
521
|
exc_info=True,
|
|
548
522
|
)
|
|
549
523
|
try:
|
|
550
|
-
await websocket.close(
|
|
551
|
-
|
|
552
|
-
)
|
|
553
|
-
|
|
554
|
-
logger.debug(f"WebSocket already closed during auth error cleanup: {e}")
|
|
555
|
-
raise WebSocketDisconnect(code=1011)
|
|
524
|
+
await websocket.close(code=1011, reason="Internal server error during authentication")
|
|
525
|
+
except (WebSocketDisconnect, RuntimeError, OSError) as close_error:
|
|
526
|
+
logger.debug(f"WebSocket already closed during auth error cleanup: {close_error}")
|
|
527
|
+
raise WebSocketDisconnect(code=1011) from None
|
|
556
528
|
|
|
557
529
|
|
|
558
530
|
async def _handle_websocket_message(
|
|
559
531
|
websocket: Any,
|
|
560
|
-
message:
|
|
532
|
+
message: dict[str, Any],
|
|
561
533
|
manager: Any,
|
|
562
534
|
app_slug: str,
|
|
563
535
|
endpoint_name: str,
|
|
564
|
-
handler:
|
|
536
|
+
handler: Callable | None,
|
|
565
537
|
) -> bool:
|
|
566
538
|
"""Handle incoming WebSocket message. Returns True if should continue, False if disconnect."""
|
|
567
539
|
if message.get("type") == "websocket.disconnect":
|
|
@@ -598,7 +570,7 @@ def create_websocket_endpoint(
|
|
|
598
570
|
app_slug: str,
|
|
599
571
|
path: str,
|
|
600
572
|
endpoint_name: str,
|
|
601
|
-
handler:
|
|
573
|
+
handler: Callable[[Any, dict[str, Any]], Awaitable[None]] | None = None,
|
|
602
574
|
require_auth: bool = True,
|
|
603
575
|
ping_interval: int = 30,
|
|
604
576
|
) -> Callable:
|
|
@@ -656,9 +628,7 @@ def create_websocket_endpoint(
|
|
|
656
628
|
file=sys.stderr,
|
|
657
629
|
flush=True,
|
|
658
630
|
)
|
|
659
|
-
print(
|
|
660
|
-
f"🔌 [WEBSOCKET HANDLER CALLED] App: '{app_slug}', Path: {path}", flush=True
|
|
661
|
-
)
|
|
631
|
+
print(f"🔌 [WEBSOCKET HANDLER CALLED] App: '{app_slug}', Path: {path}", flush=True)
|
|
662
632
|
logger.info(f"🔌 [WEBSOCKET HANDLER CALLED] App: '{app_slug}', Path: {path}")
|
|
663
633
|
connection = None
|
|
664
634
|
try:
|
|
@@ -693,9 +663,7 @@ def create_websocket_endpoint(
|
|
|
693
663
|
)
|
|
694
664
|
|
|
695
665
|
# Connect with metadata (websocket already accepted)
|
|
696
|
-
connection = await manager.connect(
|
|
697
|
-
websocket, user_id=user_id, user_email=user_email
|
|
698
|
-
)
|
|
666
|
+
connection = await manager.connect(websocket, user_id=user_id, user_email=user_email)
|
|
699
667
|
|
|
700
668
|
# Send initial connection confirmation
|
|
701
669
|
await manager.send_to_connection(
|
|
@@ -765,9 +733,7 @@ def create_websocket_endpoint(
|
|
|
765
733
|
TypeError,
|
|
766
734
|
AttributeError,
|
|
767
735
|
) as e:
|
|
768
|
-
logger.error(
|
|
769
|
-
f"WebSocket connection error for app '{app_slug}': {e}", exc_info=True
|
|
770
|
-
)
|
|
736
|
+
logger.error(f"WebSocket connection error for app '{app_slug}': {e}", exc_info=True)
|
|
771
737
|
finally:
|
|
772
738
|
if connection:
|
|
773
739
|
manager.disconnect(websocket)
|
|
@@ -776,7 +742,7 @@ def create_websocket_endpoint(
|
|
|
776
742
|
|
|
777
743
|
|
|
778
744
|
async def broadcast_to_app(
|
|
779
|
-
app_slug: str, message:
|
|
745
|
+
app_slug: str, message: dict[str, Any], user_id: str | None = None
|
|
780
746
|
) -> int:
|
|
781
747
|
"""
|
|
782
748
|
Convenience function to broadcast a message to all WebSocket clients for an app.
|
mdb_engine/utils/__init__.py
CHANGED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MongoDB utility functions for MDB Engine.
|
|
3
|
+
|
|
4
|
+
This module provides utility functions for working with MongoDB documents,
|
|
5
|
+
including JSON serialization helpers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def clean_mongo_doc(doc: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
12
|
+
"""
|
|
13
|
+
Convert MongoDB document to JSON-serializable format.
|
|
14
|
+
|
|
15
|
+
Recursively converts MongoDB-specific types to JSON-compatible types:
|
|
16
|
+
- ObjectId -> str
|
|
17
|
+
- datetime -> ISO format string
|
|
18
|
+
- Nested dictionaries and lists are processed recursively
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
doc: MongoDB document (dict) or None
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Cleaned document with all MongoDB types converted, or None if input was None
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
```python
|
|
28
|
+
from mdb_engine.utils import clean_mongo_doc
|
|
29
|
+
|
|
30
|
+
# MongoDB document with ObjectId and datetime
|
|
31
|
+
doc = {
|
|
32
|
+
"_id": ObjectId("507f1f77bcf86cd799439011"),
|
|
33
|
+
"name": "John",
|
|
34
|
+
"created_at": datetime(2024, 1, 1, 12, 0, 0),
|
|
35
|
+
"nested": {
|
|
36
|
+
"id": ObjectId("507f1f77bcf86cd799439012")
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Convert to JSON-serializable format
|
|
41
|
+
cleaned = clean_mongo_doc(doc)
|
|
42
|
+
# {
|
|
43
|
+
# "_id": "507f1f77bcf86cd799439011",
|
|
44
|
+
# "name": "John",
|
|
45
|
+
# "created_at": "2024-01-01T12:00:00",
|
|
46
|
+
# "nested": {
|
|
47
|
+
# "id": "507f1f77bcf86cd799439012"
|
|
48
|
+
# }
|
|
49
|
+
# }
|
|
50
|
+
```
|
|
51
|
+
"""
|
|
52
|
+
from datetime import datetime
|
|
53
|
+
|
|
54
|
+
from bson import ObjectId
|
|
55
|
+
|
|
56
|
+
if doc is None:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
if not isinstance(doc, dict):
|
|
60
|
+
# If it's not a dict, try to convert it
|
|
61
|
+
if isinstance(doc, ObjectId):
|
|
62
|
+
return str(doc)
|
|
63
|
+
elif isinstance(doc, datetime):
|
|
64
|
+
return doc.isoformat() if hasattr(doc, "isoformat") else str(doc)
|
|
65
|
+
else:
|
|
66
|
+
return doc
|
|
67
|
+
|
|
68
|
+
cleaned: dict[str, Any] = {}
|
|
69
|
+
|
|
70
|
+
for key, value in doc.items():
|
|
71
|
+
if isinstance(value, ObjectId):
|
|
72
|
+
cleaned[key] = str(value)
|
|
73
|
+
elif isinstance(value, datetime):
|
|
74
|
+
cleaned[key] = value.isoformat() if hasattr(value, "isoformat") else str(value)
|
|
75
|
+
elif isinstance(value, dict):
|
|
76
|
+
cleaned[key] = clean_mongo_doc(value)
|
|
77
|
+
elif isinstance(value, list):
|
|
78
|
+
cleaned[key] = [
|
|
79
|
+
clean_mongo_doc(item)
|
|
80
|
+
if isinstance(item, dict)
|
|
81
|
+
else (
|
|
82
|
+
str(item)
|
|
83
|
+
if isinstance(item, ObjectId)
|
|
84
|
+
else (item.isoformat() if isinstance(item, datetime) else item)
|
|
85
|
+
)
|
|
86
|
+
for item in value
|
|
87
|
+
]
|
|
88
|
+
else:
|
|
89
|
+
cleaned[key] = value
|
|
90
|
+
|
|
91
|
+
return cleaned
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def clean_mongo_docs(docs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
95
|
+
"""
|
|
96
|
+
Convert a list of MongoDB documents to JSON-serializable format.
|
|
97
|
+
|
|
98
|
+
Convenience function that applies clean_mongo_doc to each document in a list.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
docs: List of MongoDB documents
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
List of cleaned documents
|
|
105
|
+
|
|
106
|
+
Example:
|
|
107
|
+
```python
|
|
108
|
+
from mdb_engine.utils import clean_mongo_docs
|
|
109
|
+
|
|
110
|
+
# List of MongoDB documents
|
|
111
|
+
docs = await db.collection.find({}).to_list(length=10)
|
|
112
|
+
|
|
113
|
+
# Convert all to JSON-serializable format
|
|
114
|
+
cleaned = clean_mongo_docs(docs)
|
|
115
|
+
```
|
|
116
|
+
"""
|
|
117
|
+
return [clean_mongo_doc(doc) for doc in docs]
|