mdb-engine 0.1.6__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/README.md +144 -0
- mdb_engine/__init__.py +37 -0
- mdb_engine/auth/README.md +631 -0
- mdb_engine/auth/__init__.py +128 -0
- mdb_engine/auth/casbin_factory.py +199 -0
- mdb_engine/auth/casbin_models.py +46 -0
- mdb_engine/auth/config_defaults.py +71 -0
- mdb_engine/auth/config_helpers.py +213 -0
- mdb_engine/auth/cookie_utils.py +158 -0
- mdb_engine/auth/decorators.py +350 -0
- mdb_engine/auth/dependencies.py +747 -0
- mdb_engine/auth/helpers.py +64 -0
- mdb_engine/auth/integration.py +578 -0
- mdb_engine/auth/jwt.py +225 -0
- mdb_engine/auth/middleware.py +241 -0
- mdb_engine/auth/oso_factory.py +323 -0
- mdb_engine/auth/provider.py +570 -0
- mdb_engine/auth/restrictions.py +271 -0
- mdb_engine/auth/session_manager.py +477 -0
- mdb_engine/auth/token_lifecycle.py +213 -0
- mdb_engine/auth/token_store.py +289 -0
- mdb_engine/auth/users.py +1516 -0
- mdb_engine/auth/utils.py +614 -0
- mdb_engine/cli/__init__.py +13 -0
- mdb_engine/cli/commands/__init__.py +7 -0
- mdb_engine/cli/commands/generate.py +105 -0
- mdb_engine/cli/commands/migrate.py +83 -0
- mdb_engine/cli/commands/show.py +70 -0
- mdb_engine/cli/commands/validate.py +63 -0
- mdb_engine/cli/main.py +41 -0
- mdb_engine/cli/utils.py +92 -0
- mdb_engine/config.py +217 -0
- mdb_engine/constants.py +160 -0
- mdb_engine/core/README.md +542 -0
- mdb_engine/core/__init__.py +42 -0
- mdb_engine/core/app_registration.py +392 -0
- mdb_engine/core/connection.py +243 -0
- mdb_engine/core/engine.py +749 -0
- mdb_engine/core/index_management.py +162 -0
- mdb_engine/core/manifest.py +2793 -0
- mdb_engine/core/seeding.py +179 -0
- mdb_engine/core/service_initialization.py +355 -0
- mdb_engine/core/types.py +413 -0
- mdb_engine/database/README.md +522 -0
- mdb_engine/database/__init__.py +31 -0
- mdb_engine/database/abstraction.py +635 -0
- mdb_engine/database/connection.py +387 -0
- mdb_engine/database/scoped_wrapper.py +1721 -0
- mdb_engine/embeddings/README.md +184 -0
- mdb_engine/embeddings/__init__.py +62 -0
- mdb_engine/embeddings/dependencies.py +193 -0
- mdb_engine/embeddings/service.py +759 -0
- mdb_engine/exceptions.py +167 -0
- mdb_engine/indexes/README.md +651 -0
- mdb_engine/indexes/__init__.py +21 -0
- mdb_engine/indexes/helpers.py +145 -0
- mdb_engine/indexes/manager.py +895 -0
- mdb_engine/memory/README.md +451 -0
- mdb_engine/memory/__init__.py +30 -0
- mdb_engine/memory/service.py +1285 -0
- mdb_engine/observability/README.md +515 -0
- mdb_engine/observability/__init__.py +42 -0
- mdb_engine/observability/health.py +296 -0
- mdb_engine/observability/logging.py +161 -0
- mdb_engine/observability/metrics.py +297 -0
- mdb_engine/routing/README.md +462 -0
- mdb_engine/routing/__init__.py +73 -0
- mdb_engine/routing/websockets.py +813 -0
- mdb_engine/utils/__init__.py +7 -0
- mdb_engine-0.1.6.dist-info/METADATA +213 -0
- mdb_engine-0.1.6.dist-info/RECORD +75 -0
- mdb_engine-0.1.6.dist-info/WHEEL +5 -0
- mdb_engine-0.1.6.dist-info/entry_points.txt +2 -0
- mdb_engine-0.1.6.dist-info/licenses/LICENSE +661 -0
- mdb_engine-0.1.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WebSocket Routing and Connection Management
|
|
3
|
+
|
|
4
|
+
This module provides OPTIONAL WebSocket support for MDB_ENGINE apps via manifest.json configuration.
|
|
5
|
+
Apps can declare WebSocket endpoints in their manifest, and the engine automatically
|
|
6
|
+
handles connection management, authentication, and message routing.
|
|
7
|
+
|
|
8
|
+
WebSocket support is OPTIONAL and only enabled when:
|
|
9
|
+
1. Apps define "websockets" in their manifest.json
|
|
10
|
+
2. FastAPI WebSocket support is available
|
|
11
|
+
|
|
12
|
+
Key Features:
|
|
13
|
+
- App-level isolation: Each app has its own WebSocket manager
|
|
14
|
+
- Automatic authentication: Integrates with mdb_engine auth system
|
|
15
|
+
- Manifest-driven configuration: Define endpoints in manifest.json
|
|
16
|
+
- Bi-directional communication: Supports broadcasting and listening to client messages
|
|
17
|
+
- Automatic ping/pong: Keeps connections alive
|
|
18
|
+
- Connection metadata: Tracks user_id, user_email, connected_at for each connection
|
|
19
|
+
|
|
20
|
+
This module is part of MDB_ENGINE - MongoDB Engine.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
|
|
29
|
+
|
|
30
|
+
# Check if FastAPI WebSocket support is available (OPTIONAL dependency)
|
|
31
|
+
try:
|
|
32
|
+
from fastapi import WebSocket, WebSocketDisconnect
|
|
33
|
+
|
|
34
|
+
WEBSOCKETS_AVAILABLE = True
|
|
35
|
+
except ImportError:
|
|
36
|
+
WEBSOCKETS_AVAILABLE = False
|
|
37
|
+
# Create dummy classes for type hints when WebSockets aren't available
|
|
38
|
+
WebSocket = Any # type: ignore
|
|
39
|
+
WebSocketDisconnect = Exception # type: ignore
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class WebSocketConnection:
|
|
46
|
+
"""Metadata for a WebSocket connection."""
|
|
47
|
+
|
|
48
|
+
websocket: Any
|
|
49
|
+
app_slug: str
|
|
50
|
+
user_id: Optional[str] = None
|
|
51
|
+
user_email: Optional[str] = None
|
|
52
|
+
connected_at: datetime = None
|
|
53
|
+
|
|
54
|
+
def __post_init__(self):
|
|
55
|
+
if self.connected_at is None:
|
|
56
|
+
self.connected_at = datetime.utcnow()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class WebSocketConnectionManager:
|
|
60
|
+
"""
|
|
61
|
+
Manages WebSocket connections for an app with secure isolation.
|
|
62
|
+
|
|
63
|
+
Each app has its own manager instance, ensuring complete isolation.
|
|
64
|
+
Provides connection tracking, broadcasting, and automatic cleanup.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, app_slug: str):
|
|
68
|
+
"""
|
|
69
|
+
Initialize WebSocket connection manager for an app.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
app_slug: App slug for scoping connections (ensures isolation)
|
|
73
|
+
"""
|
|
74
|
+
self.app_slug = app_slug
|
|
75
|
+
self.active_connections: List[WebSocketConnection] = (
|
|
76
|
+
[]
|
|
77
|
+
) # List of connection metadata
|
|
78
|
+
self._lock = asyncio.Lock()
|
|
79
|
+
logger.debug(f"Initialized WebSocket manager for app: {app_slug}")
|
|
80
|
+
|
|
81
|
+
async def connect(
|
|
82
|
+
self,
|
|
83
|
+
websocket: Any,
|
|
84
|
+
user_id: Optional[str] = None,
|
|
85
|
+
user_email: Optional[str] = None,
|
|
86
|
+
) -> WebSocketConnection:
|
|
87
|
+
"""
|
|
88
|
+
Accept and register a WebSocket connection with metadata.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
websocket: FastAPI WebSocket instance
|
|
92
|
+
user_id: Optional user ID for authenticated connections
|
|
93
|
+
user_email: Optional user email for authenticated connections
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
WebSocketConnection instance with metadata
|
|
97
|
+
"""
|
|
98
|
+
# Note: websocket should already be accepted by the endpoint handler
|
|
99
|
+
# This is just for tracking - don't accept again
|
|
100
|
+
if (
|
|
101
|
+
hasattr(websocket, "client_state")
|
|
102
|
+
and websocket.client_state.name != "CONNECTED"
|
|
103
|
+
):
|
|
104
|
+
await websocket.accept()
|
|
105
|
+
connection = WebSocketConnection(
|
|
106
|
+
websocket=websocket,
|
|
107
|
+
app_slug=self.app_slug,
|
|
108
|
+
user_id=user_id,
|
|
109
|
+
user_email=user_email,
|
|
110
|
+
)
|
|
111
|
+
async with self._lock:
|
|
112
|
+
# Check if connection already exists (by websocket object identity)
|
|
113
|
+
if not any(conn.websocket is websocket for conn in self.active_connections):
|
|
114
|
+
self.active_connections.append(connection)
|
|
115
|
+
logger.info(
|
|
116
|
+
f"WebSocket connected for app '{self.app_slug}' "
|
|
117
|
+
f"(user: {user_email or 'anonymous'}). "
|
|
118
|
+
f"Total connections: {len(self.active_connections)}"
|
|
119
|
+
)
|
|
120
|
+
return connection
|
|
121
|
+
|
|
122
|
+
def disconnect(self, websocket: Any) -> None:
|
|
123
|
+
"""
|
|
124
|
+
Remove a WebSocket connection from tracking.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
websocket: WebSocket instance to remove
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
async def _disconnect():
|
|
131
|
+
async with self._lock:
|
|
132
|
+
self.active_connections = [
|
|
133
|
+
conn
|
|
134
|
+
for conn in self.active_connections
|
|
135
|
+
if conn.websocket is not websocket
|
|
136
|
+
]
|
|
137
|
+
logger.info(
|
|
138
|
+
f"WebSocket disconnected for app '{self.app_slug}'. "
|
|
139
|
+
f"Remaining connections: {len(self.active_connections)}"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
asyncio.create_task(_disconnect())
|
|
143
|
+
|
|
144
|
+
async def broadcast(
|
|
145
|
+
self, message: Dict[str, Any], filter_by_user: Optional[str] = None
|
|
146
|
+
) -> int:
|
|
147
|
+
"""
|
|
148
|
+
Broadcast a message to all connected clients for this app.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
message: Message dictionary to broadcast (will be JSON serialized)
|
|
152
|
+
filter_by_user: Optional user_id to filter recipients (if None, broadcasts to all)
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Number of clients that received the message
|
|
156
|
+
"""
|
|
157
|
+
if not self.active_connections:
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
# Add app context to message for security
|
|
161
|
+
message_with_context = {
|
|
162
|
+
**message,
|
|
163
|
+
"app_slug": self.app_slug, # Ensure message is scoped to this app
|
|
164
|
+
"timestamp": datetime.utcnow().isoformat(),
|
|
165
|
+
}
|
|
166
|
+
message_json = json.dumps(message_with_context)
|
|
167
|
+
disconnected = []
|
|
168
|
+
sent_count = 0
|
|
169
|
+
|
|
170
|
+
async with self._lock:
|
|
171
|
+
connections = list(self.active_connections)
|
|
172
|
+
|
|
173
|
+
for connection in connections:
|
|
174
|
+
# Filter by user if specified
|
|
175
|
+
if filter_by_user and connection.user_id != filter_by_user:
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
# Check WebSocket state before attempting to send
|
|
179
|
+
try:
|
|
180
|
+
# Check if WebSocket is in a valid state for sending
|
|
181
|
+
if hasattr(connection.websocket, "client_state"):
|
|
182
|
+
state = connection.websocket.client_state.name
|
|
183
|
+
if state not in ["CONNECTED"]:
|
|
184
|
+
# WebSocket is not in a connected state, mark for cleanup
|
|
185
|
+
disconnected.append(connection.websocket)
|
|
186
|
+
continue
|
|
187
|
+
except AttributeError:
|
|
188
|
+
# Type 2: Recoverable - websocket doesn't have client_state, try to send anyway
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
await connection.websocket.send_text(message_json)
|
|
193
|
+
sent_count += 1
|
|
194
|
+
except (WebSocketDisconnect, RuntimeError, OSError) as e:
|
|
195
|
+
# WebSocket closed/disconnected errors are expected
|
|
196
|
+
error_msg = str(e).lower()
|
|
197
|
+
if "close" not in error_msg and "disconnect" not in error_msg:
|
|
198
|
+
logger.debug(f"Error sending WebSocket message to client: {e}")
|
|
199
|
+
disconnected.append(connection.websocket)
|
|
200
|
+
|
|
201
|
+
# Clean up disconnected clients
|
|
202
|
+
if disconnected:
|
|
203
|
+
for ws in disconnected:
|
|
204
|
+
self.disconnect(ws)
|
|
205
|
+
|
|
206
|
+
return sent_count
|
|
207
|
+
|
|
208
|
+
async def send_to_connection(self, websocket: Any, message: Dict[str, Any]) -> None:
|
|
209
|
+
"""
|
|
210
|
+
Send a message to a specific WebSocket connection.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
websocket: Target WebSocket instance
|
|
214
|
+
message: Message dictionary to send
|
|
215
|
+
"""
|
|
216
|
+
# Check WebSocket state before attempting to send
|
|
217
|
+
if hasattr(websocket, "client_state"):
|
|
218
|
+
try:
|
|
219
|
+
state = websocket.client_state.name
|
|
220
|
+
if state not in ["CONNECTED"]:
|
|
221
|
+
# WebSocket is not in a connected state, disconnect and return
|
|
222
|
+
self.disconnect(websocket)
|
|
223
|
+
return
|
|
224
|
+
except AttributeError:
|
|
225
|
+
# Type 2: Recoverable - websocket doesn't have client_state, try to send anyway
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
# Add app context for security
|
|
230
|
+
message_with_context = {
|
|
231
|
+
**message,
|
|
232
|
+
"app_slug": self.app_slug,
|
|
233
|
+
"timestamp": datetime.utcnow().isoformat(),
|
|
234
|
+
}
|
|
235
|
+
message_json = json.dumps(message_with_context)
|
|
236
|
+
await websocket.send_text(message_json)
|
|
237
|
+
except (WebSocketDisconnect, RuntimeError, OSError) as e:
|
|
238
|
+
# WebSocket closed/disconnected errors are expected
|
|
239
|
+
error_msg = str(e).lower()
|
|
240
|
+
if "close" not in error_msg and "disconnect" not in error_msg:
|
|
241
|
+
logger.debug(f"Error sending message to specific WebSocket: {e}")
|
|
242
|
+
self.disconnect(websocket)
|
|
243
|
+
|
|
244
|
+
def get_connections_by_user(self, user_id: str) -> List[WebSocketConnection]:
|
|
245
|
+
"""
|
|
246
|
+
Get all connections for a specific user.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
user_id: User ID to filter by
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
List of WebSocketConnection instances for that user
|
|
253
|
+
"""
|
|
254
|
+
return [conn for conn in self.active_connections if conn.user_id == user_id]
|
|
255
|
+
|
|
256
|
+
def get_connection_count_by_user(self, user_id: str) -> int:
|
|
257
|
+
"""
|
|
258
|
+
Get connection count for a specific user.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
user_id: User ID to count
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Number of connections for that user
|
|
265
|
+
"""
|
|
266
|
+
return len(self.get_connections_by_user(user_id))
|
|
267
|
+
|
|
268
|
+
def get_connection_count(self) -> int:
|
|
269
|
+
"""Get the number of active connections."""
|
|
270
|
+
return len(self.active_connections)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# Global registry of WebSocket managers per app (app-level isolation)
|
|
274
|
+
_websocket_managers: Dict[str, WebSocketConnectionManager] = {}
|
|
275
|
+
_manager_lock = asyncio.Lock()
|
|
276
|
+
|
|
277
|
+
# Global registry of message handlers per app (for listening to client messages)
|
|
278
|
+
# Note: Registration happens synchronously during app startup, so no lock needed
|
|
279
|
+
_message_handlers: Dict[
|
|
280
|
+
str, Dict[str, Callable[[Any, Dict[str, Any]], Awaitable[None]]]
|
|
281
|
+
] = {}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def get_websocket_manager(app_slug: str) -> WebSocketConnectionManager:
|
|
285
|
+
"""
|
|
286
|
+
Get or create a WebSocket connection manager for an app.
|
|
287
|
+
|
|
288
|
+
This ensures app-level isolation - each app has its own manager instance.
|
|
289
|
+
Connections are automatically scoped to the app_slug.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
app_slug: App slug (ensures isolation)
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
WebSocketConnectionManager instance for the app
|
|
296
|
+
"""
|
|
297
|
+
async with _manager_lock:
|
|
298
|
+
if app_slug not in _websocket_managers:
|
|
299
|
+
_websocket_managers[app_slug] = WebSocketConnectionManager(app_slug)
|
|
300
|
+
logger.debug(f"Created WebSocket manager for app: {app_slug}")
|
|
301
|
+
return _websocket_managers[app_slug]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def get_websocket_manager_sync(app_slug: str) -> WebSocketConnectionManager:
|
|
305
|
+
"""
|
|
306
|
+
Synchronous version of get_websocket_manager for use in non-async contexts.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
app_slug: App slug
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
WebSocketConnectionManager instance for the app
|
|
313
|
+
"""
|
|
314
|
+
if app_slug not in _websocket_managers:
|
|
315
|
+
_websocket_managers[app_slug] = WebSocketConnectionManager(app_slug)
|
|
316
|
+
logger.debug(f"Created WebSocket manager for app: {app_slug}")
|
|
317
|
+
return _websocket_managers[app_slug]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
async def authenticate_websocket(
|
|
321
|
+
websocket: Any, app_slug: str, require_auth: bool = True
|
|
322
|
+
) -> Tuple[Optional[str], Optional[str]]:
|
|
323
|
+
"""
|
|
324
|
+
Authenticate a WebSocket connection.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
websocket: FastAPI WebSocket instance
|
|
328
|
+
app_slug: App slug for context
|
|
329
|
+
require_auth: Whether authentication is required
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
Tuple of (user_id, user_email) or (None, None) if not authenticated
|
|
333
|
+
|
|
334
|
+
Raises:
|
|
335
|
+
WebSocketDisconnect: If authentication is required but fails
|
|
336
|
+
"""
|
|
337
|
+
if not WEBSOCKETS_AVAILABLE:
|
|
338
|
+
raise ImportError(
|
|
339
|
+
"WebSocket support is not available. FastAPI WebSocket support must be installed."
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
from fastapi import WebSocketDisconnect
|
|
343
|
+
|
|
344
|
+
if not require_auth:
|
|
345
|
+
return None, None
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
# Try to get token from query params or cookies
|
|
349
|
+
token = None
|
|
350
|
+
query_token = None
|
|
351
|
+
cookie_token = None
|
|
352
|
+
|
|
353
|
+
# Check query parameters
|
|
354
|
+
# FastAPI WebSocket query params are accessed via websocket.query_params
|
|
355
|
+
if hasattr(websocket, "query_params"):
|
|
356
|
+
if websocket.query_params:
|
|
357
|
+
query_token = websocket.query_params.get("token")
|
|
358
|
+
logger.info(
|
|
359
|
+
f"WebSocket query_params for app '{app_slug}': {dict(websocket.query_params)}"
|
|
360
|
+
)
|
|
361
|
+
if query_token:
|
|
362
|
+
token = query_token
|
|
363
|
+
logger.info(
|
|
364
|
+
f"WebSocket token found in query params for app "
|
|
365
|
+
f"'{app_slug}' (length: {len(query_token)})"
|
|
366
|
+
)
|
|
367
|
+
else:
|
|
368
|
+
logger.info(f"WebSocket query_params is empty for app '{app_slug}'")
|
|
369
|
+
else:
|
|
370
|
+
logger.warning(
|
|
371
|
+
f"WebSocket has no query_params attribute for app '{app_slug}'"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# If no token in query, try to get from cookies (if available)
|
|
375
|
+
# Check both ws_token (non-httponly, for JS access) and token (httponly)
|
|
376
|
+
if not token:
|
|
377
|
+
if hasattr(websocket, "cookies"):
|
|
378
|
+
cookie_token = websocket.cookies.get(
|
|
379
|
+
"ws_token"
|
|
380
|
+
) or websocket.cookies.get("token")
|
|
381
|
+
if cookie_token:
|
|
382
|
+
token = cookie_token
|
|
383
|
+
logger.debug(
|
|
384
|
+
f"WebSocket token found in cookies for app '{app_slug}'"
|
|
385
|
+
)
|
|
386
|
+
else:
|
|
387
|
+
logger.debug(f"WebSocket has no cookies attribute for app '{app_slug}'")
|
|
388
|
+
|
|
389
|
+
logger.info(
|
|
390
|
+
f"WebSocket auth check for app '{app_slug}': "
|
|
391
|
+
f"query_token={bool(query_token)}, "
|
|
392
|
+
f"cookie_token={bool(cookie_token)}, "
|
|
393
|
+
f"final_token={bool(token)}, require_auth={require_auth}"
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
if not token:
|
|
397
|
+
logger.warning(
|
|
398
|
+
f"No token found for WebSocket connection to app '{app_slug}' "
|
|
399
|
+
f"(require_auth={require_auth})"
|
|
400
|
+
)
|
|
401
|
+
if require_auth:
|
|
402
|
+
# Don't close before accepting - return error info instead
|
|
403
|
+
# The caller will handle closing after accept
|
|
404
|
+
return None, None # Signal auth failure
|
|
405
|
+
return None, None
|
|
406
|
+
|
|
407
|
+
from ..auth.dependencies import SECRET_KEY
|
|
408
|
+
from ..auth.jwt import decode_jwt_token
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
payload = decode_jwt_token(token, str(SECRET_KEY))
|
|
412
|
+
user_id = payload.get("sub") or payload.get("user_id")
|
|
413
|
+
user_email = payload.get("email")
|
|
414
|
+
|
|
415
|
+
logger.info(
|
|
416
|
+
f"WebSocket authenticated successfully for app '{app_slug}': {user_email}"
|
|
417
|
+
)
|
|
418
|
+
return user_id, user_email
|
|
419
|
+
except Exception as decode_error:
|
|
420
|
+
logger.error(
|
|
421
|
+
f"JWT decode error for app '{app_slug}': {decode_error}", exc_info=True
|
|
422
|
+
)
|
|
423
|
+
raise
|
|
424
|
+
|
|
425
|
+
except WebSocketDisconnect:
|
|
426
|
+
raise
|
|
427
|
+
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
|
+
)
|
|
431
|
+
if require_auth:
|
|
432
|
+
# Don't close before accepting - return error info instead
|
|
433
|
+
return None, None # Signal auth failure
|
|
434
|
+
return None, None
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def register_message_handler(
|
|
438
|
+
app_slug: str,
|
|
439
|
+
endpoint_name: str,
|
|
440
|
+
handler: Callable[[Any, Dict[str, Any]], Awaitable[None]],
|
|
441
|
+
) -> None:
|
|
442
|
+
"""
|
|
443
|
+
Register a message handler for a WebSocket endpoint.
|
|
444
|
+
|
|
445
|
+
This allows your app to listen to and process messages from WebSocket clients.
|
|
446
|
+
Handlers are automatically called when clients send messages.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
app_slug: App slug
|
|
450
|
+
endpoint_name: Endpoint name (key in manifest.json websockets config)
|
|
451
|
+
handler: Async function to handle messages.
|
|
452
|
+
Signature: async def handler(websocket, message: Dict[str, Any]) -> None
|
|
453
|
+
|
|
454
|
+
Example:
|
|
455
|
+
```python
|
|
456
|
+
async def handle_client_message(websocket, message):
|
|
457
|
+
message_type = message.get("type")
|
|
458
|
+
if message_type == "subscribe":
|
|
459
|
+
# Handle subscription request
|
|
460
|
+
await broadcast_to_app("my_app", {
|
|
461
|
+
"type": "subscribed",
|
|
462
|
+
"channel": message.get("channel")
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
register_message_handler("my_app", "realtime", handle_client_message)
|
|
466
|
+
```
|
|
467
|
+
"""
|
|
468
|
+
# Called synchronously during app startup, so no async lock needed
|
|
469
|
+
if app_slug not in _message_handlers:
|
|
470
|
+
_message_handlers[app_slug] = {}
|
|
471
|
+
_message_handlers[app_slug][endpoint_name] = handler
|
|
472
|
+
logger.info(
|
|
473
|
+
f"Registered message handler for app '{app_slug}', endpoint '{endpoint_name}'"
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def get_message_handler(
|
|
478
|
+
app_slug: str, endpoint_name: str
|
|
479
|
+
) -> Optional[Callable[[Any, Dict[str, Any]], Awaitable[None]]]:
|
|
480
|
+
"""
|
|
481
|
+
Get a registered message handler for an endpoint.
|
|
482
|
+
|
|
483
|
+
Args:
|
|
484
|
+
app_slug: App slug
|
|
485
|
+
endpoint_name: Endpoint name
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
Handler function or None if not registered
|
|
489
|
+
"""
|
|
490
|
+
# Called synchronously during route creation (startup is single-threaded)
|
|
491
|
+
return _message_handlers.get(app_slug, {}).get(endpoint_name)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
async def _accept_websocket_connection(websocket: Any, app_slug: str) -> None:
|
|
495
|
+
"""Accept WebSocket connection with error handling."""
|
|
496
|
+
try:
|
|
497
|
+
await websocket.accept()
|
|
498
|
+
print(f"✅ [WEBSOCKET ACCEPTED] App: '{app_slug}'")
|
|
499
|
+
logger.info(f"✅ WebSocket accepted for app '{app_slug}'")
|
|
500
|
+
except Exception as accept_error:
|
|
501
|
+
print(f"❌ [WEBSOCKET ACCEPT FAILED] App: '{app_slug}', Error: {accept_error}")
|
|
502
|
+
logger.error(
|
|
503
|
+
f"❌ Failed to accept WebSocket for app '{app_slug}': {accept_error}",
|
|
504
|
+
exc_info=True,
|
|
505
|
+
)
|
|
506
|
+
raise
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
async def _authenticate_websocket_connection(
|
|
510
|
+
websocket: Any, app_slug: str, require_auth: bool
|
|
511
|
+
) -> tuple:
|
|
512
|
+
"""Authenticate WebSocket connection and return (user_id, user_email)."""
|
|
513
|
+
try:
|
|
514
|
+
user_id, user_email = await authenticate_websocket(
|
|
515
|
+
websocket, app_slug, require_auth
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
if require_auth and not user_id:
|
|
519
|
+
logger.warning(
|
|
520
|
+
f"WebSocket authentication failed for app '{app_slug}' - closing connection"
|
|
521
|
+
)
|
|
522
|
+
try:
|
|
523
|
+
await websocket.close(code=1008, reason="Authentication required")
|
|
524
|
+
except (WebSocketDisconnect, RuntimeError, OSError) as e:
|
|
525
|
+
logger.debug(
|
|
526
|
+
f"WebSocket already closed during auth failure cleanup: {e}"
|
|
527
|
+
)
|
|
528
|
+
raise WebSocketDisconnect(code=1008)
|
|
529
|
+
|
|
530
|
+
return user_id, user_email
|
|
531
|
+
|
|
532
|
+
except WebSocketDisconnect:
|
|
533
|
+
logger.warning(
|
|
534
|
+
f"WebSocket connection rejected for app '{app_slug}' - authentication failed"
|
|
535
|
+
)
|
|
536
|
+
raise
|
|
537
|
+
except (
|
|
538
|
+
ValueError,
|
|
539
|
+
TypeError,
|
|
540
|
+
AttributeError,
|
|
541
|
+
KeyError,
|
|
542
|
+
RuntimeError,
|
|
543
|
+
) as auth_error:
|
|
544
|
+
logger.error(
|
|
545
|
+
f"Unexpected error during WebSocket authentication for app "
|
|
546
|
+
f"'{app_slug}': {auth_error}",
|
|
547
|
+
exc_info=True,
|
|
548
|
+
)
|
|
549
|
+
try:
|
|
550
|
+
await websocket.close(
|
|
551
|
+
code=1011, reason="Internal server error during authentication"
|
|
552
|
+
)
|
|
553
|
+
except (WebSocketDisconnect, RuntimeError, OSError) as e:
|
|
554
|
+
logger.debug(f"WebSocket already closed during auth error cleanup: {e}")
|
|
555
|
+
raise WebSocketDisconnect(code=1011)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
async def _handle_websocket_message(
|
|
559
|
+
websocket: Any,
|
|
560
|
+
message: Dict[str, Any],
|
|
561
|
+
manager: Any,
|
|
562
|
+
app_slug: str,
|
|
563
|
+
endpoint_name: str,
|
|
564
|
+
handler: Optional[Callable],
|
|
565
|
+
) -> bool:
|
|
566
|
+
"""Handle incoming WebSocket message. Returns True if should continue, False if disconnect."""
|
|
567
|
+
if message.get("type") == "websocket.disconnect":
|
|
568
|
+
logger.info(f"WebSocket client disconnected for app '{app_slug}'")
|
|
569
|
+
return False
|
|
570
|
+
elif message.get("type") == "websocket.receive":
|
|
571
|
+
if "text" in message:
|
|
572
|
+
try:
|
|
573
|
+
data = json.loads(message["text"])
|
|
574
|
+
|
|
575
|
+
if data.get("type") == "pong":
|
|
576
|
+
return True
|
|
577
|
+
|
|
578
|
+
current_handler = (
|
|
579
|
+
handler if handler else get_message_handler(app_slug, endpoint_name)
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
if current_handler:
|
|
583
|
+
# Type 4: Let handler errors bubble up to framework
|
|
584
|
+
await current_handler(websocket, data)
|
|
585
|
+
else:
|
|
586
|
+
logger.debug(
|
|
587
|
+
f"Received message on WebSocket for app '{app_slug}' "
|
|
588
|
+
f"but no handler registered. "
|
|
589
|
+
f"Use register_message_handler() to handle client messages."
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
except json.JSONDecodeError:
|
|
593
|
+
logger.debug("Received non-JSON message from WebSocket client")
|
|
594
|
+
return True
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def create_websocket_endpoint(
|
|
598
|
+
app_slug: str,
|
|
599
|
+
path: str,
|
|
600
|
+
endpoint_name: str,
|
|
601
|
+
handler: Optional[Callable[[Any, Dict[str, Any]], Awaitable[None]]] = None,
|
|
602
|
+
require_auth: bool = True,
|
|
603
|
+
ping_interval: int = 30,
|
|
604
|
+
) -> Callable:
|
|
605
|
+
"""
|
|
606
|
+
Create a WebSocket endpoint handler for an app.
|
|
607
|
+
|
|
608
|
+
WebSocket support is OPTIONAL - this will raise ImportError if dependencies are missing.
|
|
609
|
+
|
|
610
|
+
This function returns a FastAPI WebSocket route handler that:
|
|
611
|
+
- Manages connections via WebSocketConnectionManager
|
|
612
|
+
- Handles authentication if required
|
|
613
|
+
- Routes messages to registered handlers (via register_message_handler)
|
|
614
|
+
- Provides automatic ping/pong for keepalive
|
|
615
|
+
|
|
616
|
+
Args:
|
|
617
|
+
app_slug: App slug
|
|
618
|
+
path: WebSocket path (e.g., "/ws", "/events")
|
|
619
|
+
endpoint_name: Endpoint name (key in manifest.json) - used to lookup handlers
|
|
620
|
+
handler: Optional async function to handle incoming messages
|
|
621
|
+
(deprecated, use register_message_handler instead).
|
|
622
|
+
Signature: async def handler(websocket, message: Dict[str, Any]) -> None
|
|
623
|
+
require_auth: Whether to require authentication (default: True)
|
|
624
|
+
ping_interval: Ping interval in seconds (default: 30)
|
|
625
|
+
|
|
626
|
+
Returns:
|
|
627
|
+
FastAPI WebSocket route handler function
|
|
628
|
+
|
|
629
|
+
Raises:
|
|
630
|
+
ImportError: If WebSocket dependencies are not available
|
|
631
|
+
"""
|
|
632
|
+
if not WEBSOCKETS_AVAILABLE:
|
|
633
|
+
raise ImportError(
|
|
634
|
+
"WebSocket support is not available. "
|
|
635
|
+
"FastAPI WebSocket support must be installed to use WebSocket endpoints. "
|
|
636
|
+
"Install with: pip install 'fastapi[standard]' or ensure WebSocket "
|
|
637
|
+
"support is available."
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
# Get manager (will be created if needed)
|
|
641
|
+
# Note: In async context, use await get_websocket_manager()
|
|
642
|
+
# For route creation, we use sync version since routes are created at startup
|
|
643
|
+
manager = get_websocket_manager_sync(app_slug)
|
|
644
|
+
|
|
645
|
+
# Use proper WebSocket type if available, otherwise Any
|
|
646
|
+
websocket_type = WebSocket if WEBSOCKETS_AVAILABLE else Any
|
|
647
|
+
|
|
648
|
+
async def websocket_endpoint(websocket: websocket_type):
|
|
649
|
+
"""WebSocket endpoint handler with authentication and app isolation."""
|
|
650
|
+
# CRITICAL: Log immediately - this proves the handler is being called
|
|
651
|
+
# This print should appear in server logs when a WebSocket connection is attempted
|
|
652
|
+
import sys
|
|
653
|
+
|
|
654
|
+
print(
|
|
655
|
+
f"🔌 [WEBSOCKET HANDLER CALLED] App: '{app_slug}', Path: {path}",
|
|
656
|
+
file=sys.stderr,
|
|
657
|
+
flush=True,
|
|
658
|
+
)
|
|
659
|
+
print(
|
|
660
|
+
f"🔌 [WEBSOCKET HANDLER CALLED] App: '{app_slug}', Path: {path}", flush=True
|
|
661
|
+
)
|
|
662
|
+
logger.info(f"🔌 [WEBSOCKET HANDLER CALLED] App: '{app_slug}', Path: {path}")
|
|
663
|
+
connection = None
|
|
664
|
+
try:
|
|
665
|
+
# Log connection attempt with query params (can access before accept)
|
|
666
|
+
query_str = "None"
|
|
667
|
+
try:
|
|
668
|
+
if hasattr(websocket, "query_params"):
|
|
669
|
+
if websocket.query_params:
|
|
670
|
+
query_str = str(dict(websocket.query_params))
|
|
671
|
+
else:
|
|
672
|
+
query_str = "Empty"
|
|
673
|
+
else:
|
|
674
|
+
query_str = "No query_params attr"
|
|
675
|
+
except (AttributeError, TypeError) as e:
|
|
676
|
+
query_str = f"Error accessing query_params: {e}"
|
|
677
|
+
|
|
678
|
+
# CRITICAL: Log immediately to verify handler is being called
|
|
679
|
+
print(
|
|
680
|
+
f"🔌 [WEBSOCKET HANDLER CALLED] App: '{app_slug}', Path: {path}, Query: {query_str}"
|
|
681
|
+
)
|
|
682
|
+
logger.info(
|
|
683
|
+
f"🔌 WebSocket connection attempt for app '{app_slug}' "
|
|
684
|
+
f"(require_auth={require_auth}, query_params={query_str})"
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
# Accept connection FIRST (required before we can do anything)
|
|
688
|
+
await _accept_websocket_connection(websocket, app_slug)
|
|
689
|
+
|
|
690
|
+
# Authenticate connection (after accept, so we can close properly if needed)
|
|
691
|
+
user_id, user_email = await _authenticate_websocket_connection(
|
|
692
|
+
websocket, app_slug, require_auth
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
# Connect with metadata (websocket already accepted)
|
|
696
|
+
connection = await manager.connect(
|
|
697
|
+
websocket, user_id=user_id, user_email=user_email
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
# Send initial connection confirmation
|
|
701
|
+
await manager.send_to_connection(
|
|
702
|
+
websocket,
|
|
703
|
+
{
|
|
704
|
+
"type": "connected",
|
|
705
|
+
"app_slug": app_slug,
|
|
706
|
+
"message": "WebSocket connected successfully",
|
|
707
|
+
"authenticated": user_id is not None,
|
|
708
|
+
"user_email": user_email,
|
|
709
|
+
"timestamp": datetime.utcnow().isoformat(),
|
|
710
|
+
},
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
# Keep connection alive and handle messages
|
|
714
|
+
while True:
|
|
715
|
+
try:
|
|
716
|
+
# Wait for messages with timeout for ping/pong
|
|
717
|
+
message = await asyncio.wait_for(
|
|
718
|
+
websocket.receive(), timeout=float(ping_interval)
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
should_continue = await _handle_websocket_message(
|
|
722
|
+
websocket, message, manager, app_slug, endpoint_name, handler
|
|
723
|
+
)
|
|
724
|
+
if not should_continue:
|
|
725
|
+
break
|
|
726
|
+
|
|
727
|
+
except asyncio.TimeoutError:
|
|
728
|
+
# Send ping to keep connection alive
|
|
729
|
+
try:
|
|
730
|
+
await manager.send_to_connection(
|
|
731
|
+
websocket,
|
|
732
|
+
{
|
|
733
|
+
"type": "ping",
|
|
734
|
+
"timestamp": datetime.utcnow().isoformat(),
|
|
735
|
+
},
|
|
736
|
+
)
|
|
737
|
+
except (
|
|
738
|
+
WebSocketDisconnect,
|
|
739
|
+
RuntimeError,
|
|
740
|
+
OSError,
|
|
741
|
+
):
|
|
742
|
+
# Type 2: Recoverable - connection error during ping, break loop
|
|
743
|
+
break
|
|
744
|
+
|
|
745
|
+
except (
|
|
746
|
+
WebSocketDisconnect,
|
|
747
|
+
RuntimeError,
|
|
748
|
+
OSError,
|
|
749
|
+
) as e:
|
|
750
|
+
error_msg = str(e).lower()
|
|
751
|
+
if any(
|
|
752
|
+
keyword in error_msg
|
|
753
|
+
for keyword in ["disconnect", "closed", "connection", "broken"]
|
|
754
|
+
):
|
|
755
|
+
logger.info(f"WebSocket disconnected for app '{app_slug}': {e}")
|
|
756
|
+
break
|
|
757
|
+
logger.warning(f"WebSocket receive error for app '{app_slug}': {e}")
|
|
758
|
+
await asyncio.sleep(0.1)
|
|
759
|
+
|
|
760
|
+
except (
|
|
761
|
+
WebSocketDisconnect,
|
|
762
|
+
RuntimeError,
|
|
763
|
+
OSError,
|
|
764
|
+
ValueError,
|
|
765
|
+
TypeError,
|
|
766
|
+
AttributeError,
|
|
767
|
+
) as e:
|
|
768
|
+
logger.error(
|
|
769
|
+
f"WebSocket connection error for app '{app_slug}': {e}", exc_info=True
|
|
770
|
+
)
|
|
771
|
+
finally:
|
|
772
|
+
if connection:
|
|
773
|
+
manager.disconnect(websocket)
|
|
774
|
+
|
|
775
|
+
return websocket_endpoint
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
async def broadcast_to_app(
|
|
779
|
+
app_slug: str, message: Dict[str, Any], user_id: Optional[str] = None
|
|
780
|
+
) -> int:
|
|
781
|
+
"""
|
|
782
|
+
Convenience function to broadcast a message to all WebSocket clients for an app.
|
|
783
|
+
|
|
784
|
+
This is the simplest way to send WebSocket messages from anywhere in your app code.
|
|
785
|
+
Messages are automatically scoped to the app for security.
|
|
786
|
+
|
|
787
|
+
Args:
|
|
788
|
+
app_slug: App slug (ensures isolation)
|
|
789
|
+
message: Message dictionary to broadcast
|
|
790
|
+
user_id: Optional user_id to filter recipients (if None, broadcasts to all)
|
|
791
|
+
|
|
792
|
+
Returns:
|
|
793
|
+
Number of clients that received the message
|
|
794
|
+
|
|
795
|
+
Example:
|
|
796
|
+
```python
|
|
797
|
+
from mdb_engine.routing.websockets import broadcast_to_app
|
|
798
|
+
|
|
799
|
+
# Broadcast to all clients for this app
|
|
800
|
+
await broadcast_to_app("my_app", {
|
|
801
|
+
"type": "update",
|
|
802
|
+
"data": {"status": "completed"}
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
# Broadcast to specific user only
|
|
806
|
+
await broadcast_to_app("my_app", {
|
|
807
|
+
"type": "notification",
|
|
808
|
+
"data": {"message": "Hello"}
|
|
809
|
+
}, user_id="user123")
|
|
810
|
+
```
|
|
811
|
+
"""
|
|
812
|
+
manager = await get_websocket_manager(app_slug)
|
|
813
|
+
return await manager.broadcast(message, filter_by_user=user_id)
|