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.
Files changed (92) hide show
  1. mdb_engine/__init__.py +116 -11
  2. mdb_engine/auth/ARCHITECTURE.md +112 -0
  3. mdb_engine/auth/README.md +654 -11
  4. mdb_engine/auth/__init__.py +136 -29
  5. mdb_engine/auth/audit.py +592 -0
  6. mdb_engine/auth/base.py +252 -0
  7. mdb_engine/auth/casbin_factory.py +265 -70
  8. mdb_engine/auth/config_defaults.py +5 -5
  9. mdb_engine/auth/config_helpers.py +19 -18
  10. mdb_engine/auth/cookie_utils.py +12 -16
  11. mdb_engine/auth/csrf.py +483 -0
  12. mdb_engine/auth/decorators.py +10 -16
  13. mdb_engine/auth/dependencies.py +69 -71
  14. mdb_engine/auth/helpers.py +3 -3
  15. mdb_engine/auth/integration.py +61 -88
  16. mdb_engine/auth/jwt.py +11 -15
  17. mdb_engine/auth/middleware.py +79 -35
  18. mdb_engine/auth/oso_factory.py +21 -41
  19. mdb_engine/auth/provider.py +270 -171
  20. mdb_engine/auth/rate_limiter.py +505 -0
  21. mdb_engine/auth/restrictions.py +21 -36
  22. mdb_engine/auth/session_manager.py +24 -41
  23. mdb_engine/auth/shared_middleware.py +977 -0
  24. mdb_engine/auth/shared_users.py +775 -0
  25. mdb_engine/auth/token_lifecycle.py +10 -12
  26. mdb_engine/auth/token_store.py +17 -32
  27. mdb_engine/auth/users.py +99 -159
  28. mdb_engine/auth/utils.py +236 -42
  29. mdb_engine/cli/commands/generate.py +546 -10
  30. mdb_engine/cli/commands/validate.py +3 -7
  31. mdb_engine/cli/utils.py +7 -7
  32. mdb_engine/config.py +13 -28
  33. mdb_engine/constants.py +65 -0
  34. mdb_engine/core/README.md +117 -6
  35. mdb_engine/core/__init__.py +39 -7
  36. mdb_engine/core/app_registration.py +31 -50
  37. mdb_engine/core/app_secrets.py +289 -0
  38. mdb_engine/core/connection.py +20 -12
  39. mdb_engine/core/encryption.py +222 -0
  40. mdb_engine/core/engine.py +2862 -115
  41. mdb_engine/core/index_management.py +12 -16
  42. mdb_engine/core/manifest.py +628 -204
  43. mdb_engine/core/ray_integration.py +436 -0
  44. mdb_engine/core/seeding.py +13 -21
  45. mdb_engine/core/service_initialization.py +20 -30
  46. mdb_engine/core/types.py +40 -43
  47. mdb_engine/database/README.md +140 -17
  48. mdb_engine/database/__init__.py +17 -6
  49. mdb_engine/database/abstraction.py +37 -50
  50. mdb_engine/database/connection.py +51 -30
  51. mdb_engine/database/query_validator.py +367 -0
  52. mdb_engine/database/resource_limiter.py +204 -0
  53. mdb_engine/database/scoped_wrapper.py +747 -237
  54. mdb_engine/dependencies.py +427 -0
  55. mdb_engine/di/__init__.py +34 -0
  56. mdb_engine/di/container.py +247 -0
  57. mdb_engine/di/providers.py +206 -0
  58. mdb_engine/di/scopes.py +139 -0
  59. mdb_engine/embeddings/README.md +54 -24
  60. mdb_engine/embeddings/__init__.py +31 -24
  61. mdb_engine/embeddings/dependencies.py +38 -155
  62. mdb_engine/embeddings/service.py +78 -75
  63. mdb_engine/exceptions.py +104 -12
  64. mdb_engine/indexes/README.md +30 -13
  65. mdb_engine/indexes/__init__.py +1 -0
  66. mdb_engine/indexes/helpers.py +11 -11
  67. mdb_engine/indexes/manager.py +59 -123
  68. mdb_engine/memory/README.md +95 -4
  69. mdb_engine/memory/__init__.py +1 -2
  70. mdb_engine/memory/service.py +363 -1168
  71. mdb_engine/observability/README.md +4 -2
  72. mdb_engine/observability/__init__.py +26 -9
  73. mdb_engine/observability/health.py +17 -17
  74. mdb_engine/observability/logging.py +10 -10
  75. mdb_engine/observability/metrics.py +40 -19
  76. mdb_engine/repositories/__init__.py +34 -0
  77. mdb_engine/repositories/base.py +325 -0
  78. mdb_engine/repositories/mongo.py +233 -0
  79. mdb_engine/repositories/unit_of_work.py +166 -0
  80. mdb_engine/routing/README.md +1 -1
  81. mdb_engine/routing/__init__.py +1 -3
  82. mdb_engine/routing/websockets.py +41 -75
  83. mdb_engine/utils/__init__.py +3 -1
  84. mdb_engine/utils/mongo.py +117 -0
  85. mdb_engine-0.4.12.dist-info/METADATA +492 -0
  86. mdb_engine-0.4.12.dist-info/RECORD +97 -0
  87. {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/WHEEL +1 -1
  88. mdb_engine-0.1.6.dist-info/METADATA +0 -213
  89. mdb_engine-0.1.6.dist-info/RECORD +0 -75
  90. {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/entry_points.txt +0 -0
  91. {mdb_engine-0.1.6.dist-info → mdb_engine-0.4.12.dist-info}/licenses/LICENSE +0 -0
  92. {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
@@ -286,7 +286,7 @@ async def safe_message_handler(websocket, message):
286
286
  "type": "action_result",
287
287
  "result": result
288
288
  })
289
- except Exception as e:
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")
@@ -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
@@ -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, Awaitable, Callable, Dict, List, Optional, Tuple
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: Optional[str] = None
51
- user_email: Optional[str] = None
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: List[WebSocketConnection] = (
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: Optional[str] = None,
85
- user_email: Optional[str] = None,
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: Dict[str, Any]) -> None:
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) -> List[WebSocketConnection]:
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: Dict[str, WebSocketConnectionManager] = {}
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: Dict[
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
- ) -> Tuple[Optional[str], Optional[str]]:
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 Exception as decode_error:
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, Dict[str, Any]], Awaitable[None]],
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
- ) -> Optional[Callable[[Any, Dict[str, Any]], Awaitable[None]]]:
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 Exception as accept_error:
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
- 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)
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: Dict[str, Any],
532
+ message: dict[str, Any],
561
533
  manager: Any,
562
534
  app_slug: str,
563
535
  endpoint_name: str,
564
- handler: Optional[Callable],
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: Optional[Callable[[Any, Dict[str, Any]], Awaitable[None]]] = None,
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: Dict[str, Any], user_id: Optional[str] = None
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.
@@ -4,4 +4,6 @@ Utility functions and helpers for MDB Engine.
4
4
  This module provides utility functions used across the MDB Engine codebase.
5
5
  """
6
6
 
7
- __all__ = []
7
+ from .mongo import clean_mongo_doc, clean_mongo_docs
8
+
9
+ __all__ = ["clean_mongo_doc", "clean_mongo_docs"]
@@ -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]