Ekimart commited on
Commit
b2de8b7
·
1 Parent(s): 7b2c9fb

Mettre à jour WebSocketManager pour inclure l'initialisation, la gestion des tâches asynchrones, les statistiques de service et la collecte des données système, avec l'ajout de `psutil` pour surveiller l'utilisation de la mémoire. Ajuster les routes et la configuration pour améliorer la gestion des connexions WebSocket et des notifications.

Browse files
config.yml CHANGED
@@ -1,6 +1,7 @@
1
- APP_NAME: NotificationChannel
2
  APP_ENV: local
3
- APP_PRODUCTION: False
 
4
  APP_PORT: 7860
5
  APP_HOST: 0.0.0.0
6
 
@@ -10,3 +11,10 @@ WS_HOST: 0.0.0.0
10
  WS_PORT: 8765
11
  WS_SSL: false
12
  WS_CORS_ORIGINS: '*'
 
 
 
 
 
 
 
 
1
+ APP_NAME: Socket service
2
  APP_ENV: local
3
+ API_VERSION: 1.0.0
4
+ APP_DEBUG: False
5
  APP_PORT: 7860
6
  APP_HOST: 0.0.0.0
7
 
 
11
  WS_PORT: 8765
12
  WS_SSL: false
13
  WS_CORS_ORIGINS: '*'
14
+
15
+ WEBSOCKET_PING_INTERVAL: 20
16
+ WEBSOCKET_TIMEOUT: 60
17
+
18
+ CLEANUP_INTERVAL: 300
19
+ HEARTBEAT_INTERVAL: 30
20
+ STATS_INTERVAL: 60
core/manager/websocket_manager.py CHANGED
@@ -1,6 +1,9 @@
 
1
  from collections import deque
2
- from typing import Dict, List, Any, Deque
 
3
 
 
4
  from fastapi import WebSocket
5
 
6
  from core.settings.settings import Settings
@@ -20,8 +23,59 @@ class WebSocketManager:
20
  maxlen=self.settings.get("MAX_NOTIFICATION_HISTORY", 1000)
21
  )
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  async def connect(self, websocket: WebSocket, client: ClientInfo) -> str:
24
- """Établit une nouvelle connexion WebSocket"""
25
  await websocket.accept()
26
  session_id = client.session_id
27
 
@@ -29,6 +83,8 @@ class WebSocketManager:
29
  self.client_info[session_id] = client
30
  self.subscriptions[session_id] = []
31
 
 
 
32
  self.logger.info(f"Nouvelle connexion: {session_id}")
33
 
34
  await self._send(
@@ -45,7 +101,6 @@ class WebSocketManager:
45
  return session_id
46
 
47
  def disconnect(self, session_id: str) -> None:
48
- """Ferme une connexion et nettoie les ressources"""
49
  if session_id in self.active_connections:
50
  del self.active_connections[session_id]
51
  del self.client_info[session_id]
@@ -55,21 +110,22 @@ class WebSocketManager:
55
  async def send_personal_message(
56
  self, session_id: str, payload: Dict[str, Any]
57
  ) -> None:
58
- """Envoie un message à un client spécifique"""
59
  if ws := self.active_connections.get(session_id):
60
  try:
61
  await self._send(ws, payload)
 
62
  except RuntimeError as e:
63
  self.logger.error(f"Erreur d'envoi à {session_id}: {e}")
 
64
  self.disconnect(session_id)
65
 
66
- async def broadcast_notification(self, notification: Notification) -> None:
67
- """Diffuse une notification aux clients abonnés"""
68
  if notification.is_expired:
69
- return
70
 
71
  self._add_to_history(notification)
72
  payload = self._format_notification(notification)
 
73
 
74
  for session_id, subscriptions in self.subscriptions.items():
75
  if not (client := self.client_info.get(session_id)):
@@ -77,33 +133,41 @@ class WebSocketManager:
77
 
78
  if self._should_receive(subscriptions, notification, client):
79
  await self.send_personal_message(session_id, payload)
 
 
 
80
 
81
- async def send_to_user(self, user_id: str, notification: Notification) -> None:
82
- """Envoie une notification à un utilisateur spécifique"""
83
  if notification.is_expired:
84
- return
85
 
86
  self._add_to_history(notification)
87
  payload = self._format_notification(notification)
 
88
 
89
  for session_id, client in self.client_info.items():
90
  if client.user_id == user_id:
91
  await self.send_personal_message(session_id, payload)
 
92
 
93
- async def send_to_role(self, role: str, notification: Notification) -> None:
94
- """Envoie une notification à un rôle spécifique"""
 
95
  if notification.is_expired:
96
- return
97
 
98
  self._add_to_history(notification)
99
  payload = self._format_notification(notification)
 
100
 
101
  for session_id, client in self.client_info.items():
102
  if role in client.user_roles:
103
  await self.send_personal_message(session_id, payload)
 
 
 
104
 
105
  def add_subscription(self, session_id: str, subscription: Subscription) -> bool:
106
- """Ajoute un nouvel abonnement pour un client"""
107
  if not (sub_list := self.subscriptions.get(session_id)):
108
  return False
109
 
@@ -115,7 +179,6 @@ class WebSocketManager:
115
  return True
116
 
117
  def update_subscription(self, session_id: str, subscription: Subscription) -> bool:
118
- """Met à jour un abonnement existant"""
119
  if not (sub_list := self.subscriptions.get(session_id)):
120
  return False
121
 
@@ -126,7 +189,6 @@ class WebSocketManager:
126
  return False
127
 
128
  def remove_subscription(self, session_id: str, subscription_id: str) -> bool:
129
- """Supprime un abonnement"""
130
  if not (sub_list := self.subscriptions.get(session_id)):
131
  return False
132
 
@@ -137,29 +199,146 @@ class WebSocketManager:
137
  return len(self.subscriptions[session_id]) < initial_count
138
 
139
  def get_subscriptions(self, session_id: str) -> List[Subscription]:
140
- """Récupère les abonnements d'un client"""
141
  return self.subscriptions.get(session_id, [])[:]
142
 
143
- def get_notification_history(self, limit: int = 100) -> List[Notification]:
144
- """Récupère l'historique des notifications"""
145
- return [n for n in list(self.notification_history)[-limit:] if not n.is_expired]
146
 
147
  def get_stats(self) -> Dict[str, Any]:
148
- """Retourne les statistiques du service"""
 
149
  return {
150
  "active_connections": len(self.active_connections),
151
  "total_subscriptions": sum(len(s) for s in self.subscriptions.values()),
152
  "total_notifications": len(self.notification_history),
 
 
 
 
 
153
  "timestamp": current_utc_iso(),
154
  }
155
 
156
  @staticmethod
157
- async def _send(websocket: WebSocket, payload: Dict[str, Any]) -> None:
158
- """Mécanisme d'envoi générique"""
159
- await websocket.send_json(payload)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
 
161
  def _add_to_history(self, notification: Notification) -> None:
162
- """Ajoute une notification à l'historique"""
163
  if not notification.is_expired:
164
  self.notification_history.append(notification)
165
 
@@ -169,7 +348,6 @@ class WebSocketManager:
169
  notification: Notification,
170
  client: ClientInfo,
171
  ) -> bool:
172
- """Vérifie si un client doit recevoir une notification"""
173
  return any(
174
  sub.matches(notification, client.user_id, client.user_roles)
175
  for sub in subscriptions
@@ -177,5 +355,4 @@ class WebSocketManager:
177
 
178
  @staticmethod
179
  def _format_notification(notification: Notification) -> Dict[str, Any]:
180
- """Formate une notification pour l'envoi"""
181
  return {"type": "notification", **notification.model_dump(exclude_unset=True)}
 
1
+ import asyncio
2
  from collections import deque
3
+ from datetime import datetime, timezone
4
+ from typing import Dict, List, Any, Deque, Optional
5
 
6
+ import psutil
7
  from fastapi import WebSocket
8
 
9
  from core.settings.settings import Settings
 
23
  maxlen=self.settings.get("MAX_NOTIFICATION_HISTORY", 1000)
24
  )
25
 
26
+ self._start_time: Optional[datetime] = None
27
+ self._is_initialized: bool = False
28
+ self._shutdown_event: Optional[asyncio.Event] = None
29
+ self._background_tasks: List[asyncio.Task] = []
30
+
31
+ self._total_messages_sent: int = 0
32
+ self._total_connections_handled: int = 0
33
+ self._connection_errors: int = 0
34
+
35
+ async def initialize(self) -> None:
36
+ if self._is_initialized:
37
+ self.logger.warning("WebSocketManager already initialized")
38
+ return
39
+
40
+ self._start_time = datetime.now(timezone.utc)
41
+ self._shutdown_event = asyncio.Event()
42
+ self._is_initialized = True
43
+
44
+ self._background_tasks = [
45
+ asyncio.create_task(self._cleanup_expired_notifications()),
46
+ asyncio.create_task(self._heartbeat_checker()),
47
+ asyncio.create_task(self._stats_collector())
48
+ ]
49
+
50
+ self.logger.info("WebSocketManager initialized successfully")
51
+
52
+ async def shutdown(self) -> None:
53
+ if not self._is_initialized:
54
+ return
55
+
56
+ self.logger.info("Shutting down WebSocketManager...")
57
+
58
+ if self._shutdown_event:
59
+ self._shutdown_event.set()
60
+
61
+ for task in self._background_tasks:
62
+ if not task.done():
63
+ task.cancel()
64
+
65
+ if self._background_tasks:
66
+ await asyncio.gather(*self._background_tasks, return_exceptions=True)
67
+
68
+ await self._close_all_connections()
69
+
70
+ self.active_connections.clear()
71
+ self.client_info.clear()
72
+ self.subscriptions.clear()
73
+ self.notification_history.clear()
74
+
75
+ self._is_initialized = False
76
+ self.logger.info("WebSocketManager shutdown completed")
77
+
78
  async def connect(self, websocket: WebSocket, client: ClientInfo) -> str:
 
79
  await websocket.accept()
80
  session_id = client.session_id
81
 
 
83
  self.client_info[session_id] = client
84
  self.subscriptions[session_id] = []
85
 
86
+ self._total_connections_handled += 1
87
+
88
  self.logger.info(f"Nouvelle connexion: {session_id}")
89
 
90
  await self._send(
 
101
  return session_id
102
 
103
  def disconnect(self, session_id: str) -> None:
 
104
  if session_id in self.active_connections:
105
  del self.active_connections[session_id]
106
  del self.client_info[session_id]
 
110
  async def send_personal_message(
111
  self, session_id: str, payload: Dict[str, Any]
112
  ) -> None:
 
113
  if ws := self.active_connections.get(session_id):
114
  try:
115
  await self._send(ws, payload)
116
+ self._total_messages_sent += 1
117
  except RuntimeError as e:
118
  self.logger.error(f"Erreur d'envoi à {session_id}: {e}")
119
+ self._connection_errors += 1
120
  self.disconnect(session_id)
121
 
122
+ async def broadcast_notification(self, notification: Notification) -> Dict[str, Any]:
 
123
  if notification.is_expired:
124
+ return {"delivered_to": 0}
125
 
126
  self._add_to_history(notification)
127
  payload = self._format_notification(notification)
128
+ delivered_count = 0
129
 
130
  for session_id, subscriptions in self.subscriptions.items():
131
  if not (client := self.client_info.get(session_id)):
 
133
 
134
  if self._should_receive(subscriptions, notification, client):
135
  await self.send_personal_message(session_id, payload)
136
+ delivered_count += 1
137
+
138
+ return {"delivered_to": delivered_count}
139
 
140
+ async def send_to_user(self, user_id: str, notification: Notification) -> Dict[str, Any]:
 
141
  if notification.is_expired:
142
+ return {"delivered_to": 0}
143
 
144
  self._add_to_history(notification)
145
  payload = self._format_notification(notification)
146
+ delivered_count = 0
147
 
148
  for session_id, client in self.client_info.items():
149
  if client.user_id == user_id:
150
  await self.send_personal_message(session_id, payload)
151
+ delivered_count += 1
152
 
153
+ return {"delivered_to": delivered_count}
154
+
155
+ async def send_to_role(self, role: str, notification: Notification) -> Dict[str, Any]:
156
  if notification.is_expired:
157
+ return {"delivered_to": 0}
158
 
159
  self._add_to_history(notification)
160
  payload = self._format_notification(notification)
161
+ delivered_count = 0
162
 
163
  for session_id, client in self.client_info.items():
164
  if role in client.user_roles:
165
  await self.send_personal_message(session_id, payload)
166
+ delivered_count += 1
167
+
168
+ return {"delivered_to": delivered_count}
169
 
170
  def add_subscription(self, session_id: str, subscription: Subscription) -> bool:
 
171
  if not (sub_list := self.subscriptions.get(session_id)):
172
  return False
173
 
 
179
  return True
180
 
181
  def update_subscription(self, session_id: str, subscription: Subscription) -> bool:
 
182
  if not (sub_list := self.subscriptions.get(session_id)):
183
  return False
184
 
 
189
  return False
190
 
191
  def remove_subscription(self, session_id: str, subscription_id: str) -> bool:
 
192
  if not (sub_list := self.subscriptions.get(session_id)):
193
  return False
194
 
 
199
  return len(self.subscriptions[session_id]) < initial_count
200
 
201
  def get_subscriptions(self, session_id: str) -> List[Subscription]:
 
202
  return self.subscriptions.get(session_id, [])[:]
203
 
204
+ def get_notification_history(self, limit: int = 100, offset: int = 0) -> List[Notification]:
205
+ history = [n for n in list(self.notification_history) if not n.is_expired]
206
+ return history[offset:offset + limit]
207
 
208
  def get_stats(self) -> Dict[str, Any]:
209
+ memory_info = self.get_memory_usage()
210
+
211
  return {
212
  "active_connections": len(self.active_connections),
213
  "total_subscriptions": sum(len(s) for s in self.subscriptions.values()),
214
  "total_notifications": len(self.notification_history),
215
+ "total_messages_sent": self._total_messages_sent,
216
+ "total_connections_handled": self._total_connections_handled,
217
+ "connection_errors": self._connection_errors,
218
+ "uptime_seconds": self.get_uptime(),
219
+ "memory_usage": memory_info,
220
  "timestamp": current_utc_iso(),
221
  }
222
 
223
  @staticmethod
224
+ def get_current_timestamp() -> str:
225
+ return current_utc_iso()
226
+
227
+ def get_uptime(self) -> float:
228
+ if not self._start_time:
229
+ return 0.0
230
+ return (datetime.now(timezone.utc) - self._start_time).total_seconds()
231
+
232
+ def get_memory_usage(self) -> Dict[str, Any]:
233
+ try:
234
+ process = psutil.Process()
235
+ memory_info = process.memory_info()
236
+
237
+ return {
238
+ "rss_mb": round(memory_info.rss / 1024 / 1024, 2), # Resident Set Size
239
+ "vms_mb": round(memory_info.vms / 1024 / 1024, 2), # Virtual Memory Size
240
+ "percent": round(process.memory_percent(), 2),
241
+ "available_mb": round(psutil.virtual_memory().available / 1024 / 1024, 2),
242
+ "total_mb": round(psutil.virtual_memory().total / 1024 / 1024, 2)
243
+ }
244
+ except Exception as e:
245
+ self.logger.error(f"Error getting memory usage: {e}")
246
+ return {
247
+ "rss_mb": 0,
248
+ "vms_mb": 0,
249
+ "percent": 0,
250
+ "available_mb": 0,
251
+ "total_mb": 0,
252
+ "error": str(e)
253
+ }
254
+
255
+ async def _cleanup_expired_notifications(self) -> None:
256
+ while not self._shutdown_event.is_set():
257
+ try:
258
+ active_notifications = [
259
+ n for n in self.notification_history if not n.is_expired
260
+ ]
261
+
262
+ self.notification_history.clear()
263
+ self.notification_history.extend(active_notifications)
264
+
265
+ await asyncio.sleep(self.settings.get("CLEANUP_INTERVAL", 300)) # 5 minutes
266
+
267
+ except asyncio.CancelledError:
268
+ break
269
+ except Exception as e:
270
+ self.logger.error(f"Error in cleanup task: {e}")
271
+ await asyncio.sleep(60)
272
+
273
+ async def _heartbeat_checker(self) -> None:
274
+ while not self._shutdown_event.is_set():
275
+ try:
276
+ disconnected_sessions = []
277
+
278
+ for session_id, websocket in self.active_connections.items():
279
+ try:
280
+ await websocket.send_json({"type": "ping", "timestamp": current_utc_iso()})
281
+ except Exception as e:
282
+ self.logger.warning(f"Connection {session_id} appears to be dead: {e}")
283
+ disconnected_sessions.append(session_id)
284
+
285
+ for session_id in disconnected_sessions:
286
+ self.disconnect(session_id)
287
+
288
+ await asyncio.sleep(self.settings.get("HEARTBEAT_INTERVAL", 30)) # 30 seconds
289
+
290
+ except asyncio.CancelledError:
291
+ break
292
+ except Exception as e:
293
+ self.logger.error(f"Error in heartbeat checker: {e}")
294
+ await asyncio.sleep(60)
295
+
296
+ async def _stats_collector(self) -> None:
297
+ while not self._shutdown_event.is_set():
298
+ try:
299
+ stats = self.get_stats()
300
+ self.logger.info(f"Service stats: {stats}")
301
+
302
+ await asyncio.sleep(self.settings.get("STATS_INTERVAL", 60)) # 1 minute
303
+
304
+ except asyncio.CancelledError:
305
+ break
306
+ except Exception as e:
307
+ self.logger.error(f"Error in stats collector: {e}")
308
+ await asyncio.sleep(60)
309
+
310
+ async def _close_all_connections(self) -> None:
311
+ if not self.active_connections:
312
+ return
313
+
314
+ self.logger.info(f"Closing {len(self.active_connections)} active connections...")
315
+
316
+ shutdown_message = {
317
+ "type": "service_shutdown",
318
+ "title": "Service en cours d'arrêt",
319
+ "message": "Le service de notifications va être arrêté",
320
+ "timestamp": current_utc_iso()
321
+ }
322
+
323
+ close_tasks = []
324
+ for session_id, websocket in self.active_connections.items():
325
+ try:
326
+ await self._send(websocket, shutdown_message)
327
+ close_tasks.append(websocket.close())
328
+ except Exception as e:
329
+ self.logger.error(f"Error closing connection {session_id}: {e}")
330
+
331
+ if close_tasks:
332
+ await asyncio.gather(*close_tasks, return_exceptions=True)
333
+
334
+ async def _send(self, websocket: WebSocket, payload: Dict[str, Any]) -> None:
335
+ try:
336
+ await websocket.send_json(payload)
337
+ except Exception as e:
338
+ self.logger.error(f"Failed to send message: {e}")
339
+ raise RuntimeError(f"WebSocket send failed: {e}")
340
 
341
  def _add_to_history(self, notification: Notification) -> None:
 
342
  if not notification.is_expired:
343
  self.notification_history.append(notification)
344
 
 
348
  notification: Notification,
349
  client: ClientInfo,
350
  ) -> bool:
 
351
  return any(
352
  sub.matches(notification, client.user_id, client.user_roles)
353
  for sub in subscriptions
 
355
 
356
  @staticmethod
357
  def _format_notification(notification: Notification) -> Dict[str, Any]:
 
358
  return {"type": "notification", **notification.model_dump(exclude_unset=True)}
core/types/datamodels.py CHANGED
@@ -63,7 +63,7 @@ class Subscription(BaseModel):
63
  created_at: datetime = Field(default_factory=datetime.now)
64
 
65
  def matches(
66
- self, notification: "Notification", user_id: str, user_roles: List[str]
67
  ) -> bool:
68
  if self.notification_types and notification.type not in self.notification_types:
69
  return False
@@ -83,3 +83,5 @@ class ClientInfo(BaseModel):
83
  user_id: str
84
  user_roles: List[str] = Field(default_factory=list)
85
  connected_at: datetime = Field(default_factory=datetime.now)
 
 
 
63
  created_at: datetime = Field(default_factory=datetime.now)
64
 
65
  def matches(
66
+ self, notification: "Notification", user_id: str, user_roles: List[str]
67
  ) -> bool:
68
  if self.notification_types and notification.type not in self.notification_types:
69
  return False
 
83
  user_id: str
84
  user_roles: List[str] = Field(default_factory=list)
85
  connected_at: datetime = Field(default_factory=datetime.now)
86
+ ip_address: str
87
+ user_agent: str
main.py CHANGED
@@ -1,19 +1,10 @@
1
  from contextlib import asynccontextmanager
2
- from pathlib import Path
3
- from typing import Any, Dict, List, Optional
4
  from uuid import uuid4
5
 
6
  import uvicorn
7
- from fastapi import (
8
- FastAPI,
9
- WebSocket,
10
- Request,
11
- WebSocketDisconnect,
12
- HTTPException,
13
- Depends,
14
- )
15
- from fastapi.templating import Jinja2Templates
16
- from starlette.responses import HTMLResponse
17
 
18
  from core.manager.websocket_manager import WebSocketManager
19
  from core.settings.settings import Settings
@@ -25,220 +16,397 @@ from core.types.datamodels import (
25
  Notification,
26
  BroadcastRequest,
27
  )
28
- from core.utils.utils import current_utc_iso
29
-
30
- BASE_DIR = Path(__file__).resolve().parent.absolute()
31
- templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
32
-
33
- settings = Settings()
34
- manager = WebSocketManager(settings=settings)
35
 
36
- logger = manager.logger
37
 
38
-
39
- async def validate_session(session_id: str) -> str:
40
- if session_id not in manager.active_connections:
41
- raise HTTPException(
42
- status_code=404, detail="Session introuvable ou déjà fermée"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  )
44
- return session_id
45
-
46
-
47
- @asynccontextmanager
48
- async def lifespan(_app: FastAPI):
49
- """Gère les événements de démarrage et d'arrêt de l'application"""
50
-
51
- logger.info("Démarrage du service de notifications WebSocket")
52
-
53
- yield
54
-
55
- logger.info("Arrêt du service de notifications WebSocket")
56
 
57
-
58
- app = FastAPI(
59
- title=manager.settings.APP_NAME,
60
- description="Service de notifications en temps réel avec support WebSocket",
61
- version="1.0.0",
62
- lifespan=lifespan,
63
- docs_url="/docs",
64
- redoc_url="/redoc",
65
- websocket_ping_interval=20,
66
- websocket_timeout=60,
67
- )
68
-
69
-
70
- @app.websocket("/ws/{user_id}")
71
- async def websocket_endpoint(websocket: WebSocket, user_id: str):
72
- """Endpoint pour les connexions WebSocket"""
73
- session_id = ""
74
-
75
- try:
76
- client_info = ClientInfo(
77
- session_id=str(uuid4()),
78
- user_id=user_id,
79
- user_roles=(
80
- websocket.query_params.get("roles", "").split(",")
81
- if websocket.query_params.get("roles")
82
- else []
83
- ),
84
  )
85
 
86
- session_id = await manager.connect(websocket, client_info)
87
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  while True:
89
- message = await websocket.receive_text()
90
- if message == "ping":
91
- await websocket.send_text("pong")
92
- else:
93
- pass
94
-
95
- except WebSocketDisconnect:
96
- manager.disconnect(session_id)
97
- logger.info(f"Connexion WebSocket fermée: {session_id}")
98
- except Exception as e:
99
- logger.error(f"Erreur WebSocket: {e}")
100
- manager.disconnect(session_id)
101
-
102
-
103
- # Endpoints REST
104
- @app.get("/", response_class=HTMLResponse)
105
- async def root(request: Request):
106
- """Page de test pour les connexions WebSocket"""
107
- return templates.TemplateResponse(
108
- "dashboard.html",
109
- {"request": request, "current_time": current_utc_iso()},
110
- )
111
-
112
-
113
- @app.post("/subscriptions/{session_id}")
114
- async def add_subscription(
115
- session_id: str = Depends(validate_session),
116
- subscription_request: Optional[SubscriptionRequest] = None,
117
- ):
118
- """Ajoute un nouvel abonnement pour une session"""
119
-
120
- subscription = Subscription(**subscription_request.model_dump()) if subscription_request else Subscription(
121
- type="default", target="default")
122
-
123
- if not manager.add_subscription(session_id, subscription):
124
- raise HTTPException(status_code=400, detail="Échec de l'ajout de l'abonnement")
125
-
126
- return {
127
- "message": "Abonnement ajouté avec succès",
128
- "subscription_id": subscription.id,
129
- }
130
-
131
-
132
- @app.delete("/subscriptions/{session_id}/{subscription_id}")
133
- async def remove_subscription(
134
- subscription_id: str,
135
- session_id: str = Depends(validate_session),
136
- ):
137
- """Supprime un abonnement existant"""
138
- if not manager.remove_subscription(session_id, subscription_id):
139
- raise HTTPException(status_code=404, detail="Abonnement introuvable")
140
-
141
- return {"message": "Abonnement supprimé avec succès"}
142
-
143
-
144
- @app.get("/subscriptions/{session_id}")
145
- async def get_subscriptions(session_id: str = Depends(validate_session)):
146
- """Récupère les abonnements d'une session"""
147
- return manager.get_subscriptions(session_id)
148
-
149
-
150
- @app.post("/notifications/user/{user_id}")
151
- async def send_user_notification(
152
- user_id: str, notification_request: NotificationRequest
153
- ):
154
- """Envoie une notification à un utilisateur spécifique"""
155
- notification = Notification(**notification_request.model_dump())
156
- await manager.send_to_user(user_id, notification)
157
-
158
- return {
159
- "message": f"Notification envoyée à l'utilisateur {user_id}",
160
- "notification_id": notification.id,
161
- }
162
-
163
-
164
- @app.post("/notifications/role/{role}")
165
- async def send_role_notification(role: str, notification_request: NotificationRequest):
166
- """Envoie une notification à un rôle spécifique"""
167
- notification = Notification(**notification_request.model_dump())
168
- await manager.send_to_role(role, notification)
169
-
170
- return {
171
- "message": f"Notification envoyée au rôle {role}",
172
- "notification_id": notification.id,
173
- }
174
-
175
-
176
- @app.post("/notifications/broadcast")
177
- async def broadcast_notification(notification_request: NotificationRequest):
178
- """Diffuse une notification à tous les clients connectés"""
179
- notification = Notification(**notification_request.model_dump())
180
- await manager.broadcast_notification(notification)
181
-
182
- return {
183
- "message": "Notification diffusée",
184
- "notification_id": notification.id,
185
- }
186
-
187
-
188
- @app.post("/notifications/send")
189
- async def send_targeted_notification(request: BroadcastRequest):
190
- """Envoie une notification ciblée"""
191
- notification = Notification(**request.notification.model_dump())
192
-
193
- if request.target_type == "user":
194
- await manager.send_to_user(request.target_value, notification)
195
- elif request.target_type == "role":
196
- await manager.send_to_role(request.target_value, notification)
197
- elif request.target_type == "global":
198
- await manager.broadcast_notification(notification)
199
- else:
200
- raise HTTPException(status_code=400, detail="Type de cible invalide")
201
-
202
- return {
203
- "message": f"Notification envoyée à {request.target_type}: {request.target_value}",
204
- "notification_id": notification.id,
205
- }
206
-
207
-
208
- @app.get("/notifications/history")
209
- async def get_notification_history(limit: int = 100):
210
- """Récupère l'historique des notifications"""
211
- return manager.get_notification_history(limit)
212
-
213
-
214
- @app.get("/stats")
215
- async def get_service_stats():
216
- """Récupère les statistiques du service"""
217
- return manager.get_stats()
218
 
219
 
220
- @app.get("/clients")
221
- async def get_connected_clients():
222
- """Récupère la liste des clients connectés"""
223
- clients: List[Dict[str, Any]] = [
224
- {
225
- "session_id": session_id,
226
- "user_id": info.user_id,
227
- "user_roles": info.user_roles,
228
- "connected_at": info.connected_at.isoformat(),
229
- "subscriptions": len(manager.subscriptions.get(session_id, [])),
230
- }
231
- for session_id, info in manager.client_info.items()
232
- ]
233
- return {"clients": clients}
234
 
235
 
236
  if __name__ == "__main__":
237
- uvicorn.run(
238
- "main:app",
239
- host=manager.settings.APP_HOST,
240
- port=manager.settings.APP_PORT,
241
- reload=True,
242
- log_level="info",
243
- server_header=False,
244
- )
 
1
  from contextlib import asynccontextmanager
2
+ from typing import List, Optional
 
3
  from uuid import uuid4
4
 
5
  import uvicorn
6
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Depends, status
7
+ from fastapi.middleware.cors import CORSMiddleware
 
 
 
 
 
 
 
 
8
 
9
  from core.manager.websocket_manager import WebSocketManager
10
  from core.settings.settings import Settings
 
16
  Notification,
17
  BroadcastRequest,
18
  )
 
 
 
 
 
 
 
19
 
 
20
 
21
+ class WebSocketNotificationService:
22
+ def __init__(self, settings: Optional[Settings] = None):
23
+ self.settings = settings or Settings()
24
+ self.manager = WebSocketManager(settings=self.settings)
25
+ self.logger = self.manager.logger
26
+ self.app = self._create_app()
27
+ self._configure_middleware()
28
+ self._register_routes()
29
+
30
+ def _create_app(self) -> FastAPI:
31
+ return FastAPI(
32
+ title=self.settings.APP_NAME,
33
+ description="Serivce de notification WebSocket et de real-time data",
34
+ version=self.settings.API_VERSION,
35
+ lifespan=self._lifespan,
36
+ docs_url="/docs" if self.settings.APP_DEBUG else None,
37
+ redoc_url="/redoc" if self.settings.APP_DEBUG else None,
38
+ websocket_ping_interval=self.settings.WEBSOCKET_PING_INTERVAL,
39
+ websocket_timeout=self.settings.WEBSOCKET_TIMEOUT,
40
+ openapi_tags=[
41
+ {
42
+ "name": "health",
43
+ "description": "Santé et vérification du système"
44
+ },
45
+ {
46
+ "name": "websocket",
47
+ "description": "Gestion de connexions WebSocket"
48
+ },
49
+ {
50
+ "name": "subscriptions",
51
+ "description": "Gestion des subscriptions"
52
+ },
53
+ {
54
+ "name": "notifications",
55
+ "description": "Envoie et gestion des notifications"
56
+ },
57
+ {
58
+ "name": "monitoring",
59
+ "description": "Analytics et statistiques"
60
+ }
61
+ ]
62
  )
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
+ def _configure_middleware(self):
65
+ self.app.add_middleware(
66
+ CORSMiddleware,
67
+ allow_origins=[self.settings.WS_CORS_ORIGINS],
68
+ allow_credentials=True,
69
+ allow_methods=["*"],
70
+ allow_headers=["*"],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  )
72
 
73
+ @asynccontextmanager
74
+ async def _lifespan(self, app: FastAPI):
75
+ self.logger.info("🚀 Starting WebSocket notification service")
76
+
77
+ try:
78
+ await self.manager.initialize()
79
+ yield
80
+ finally:
81
+ await self.manager.shutdown()
82
+ self.logger.info("🛑 WebSocket notification service stopped")
83
+
84
+ def _register_routes(self):
85
+ self._register_health_routes()
86
+ self._register_websocket_routes()
87
+ self._register_subscription_routes()
88
+ self._register_notification_routes()
89
+ self._register_monitoring_routes()
90
+
91
+ def _register_health_routes(self):
92
+ @self.app.get("/", tags=["health"])
93
+ async def root():
94
+ return {
95
+ "service": self.settings.APP_NAME,
96
+ "version": self.settings.API_VERSION,
97
+ "status": "healthy",
98
+ "timestamp": self.manager.get_current_timestamp()
99
+ }
100
+
101
+ @self.app.get("/health", tags=["health"])
102
+ async def health_check():
103
+ return {
104
+ "status": "healthy",
105
+ "connections": len(self.manager.active_connections),
106
+ "uptime": self.manager.get_uptime(),
107
+ "memory_usage": self.manager.get_memory_usage()
108
+ }
109
+
110
+ def _register_websocket_routes(self):
111
+ @self.app.websocket("/ws/{user_id}")
112
+ async def websocket_endpoint(websocket: WebSocket, user_id: str):
113
+ session_id = ""
114
+
115
+ try:
116
+ client_info = ClientInfo(
117
+ session_id=str(uuid4()),
118
+ user_id=user_id,
119
+ user_roles=self._extract_roles(websocket),
120
+ ip_address=self._get_client_ip(websocket),
121
+ user_agent=self._get_user_agent(websocket)
122
+ )
123
+
124
+ session_id = await self.manager.connect(websocket, client_info)
125
+
126
+ await self._handle_websocket_messages(websocket, session_id)
127
+
128
+ except WebSocketDisconnect:
129
+ self.logger.info(f"WebSocket connection closed: {session_id}")
130
+ except Exception as e:
131
+ self.logger.error(f"WebSocket error: {e}")
132
+ await self._handle_websocket_error(websocket, e)
133
+ finally:
134
+ self.manager.disconnect(session_id)
135
+
136
+ def _register_subscription_routes(self):
137
+ @self.app.post("/subscriptions/{session_id}", tags=["subscriptions"])
138
+ async def add_subscription(
139
+ subscription_request: SubscriptionRequest,
140
+ session_id: str = Depends(self._validate_session),
141
+ ):
142
+ try:
143
+ subscription = Subscription(**subscription_request.model_dump())
144
+
145
+ if not self.manager.add_subscription(session_id, subscription):
146
+ raise HTTPException(
147
+ status_code=status.HTTP_404_NOT_FOUND,
148
+ detail="Failed to add subscription"
149
+ )
150
+
151
+ return {
152
+ "success": True,
153
+ "message": "Subscription added successfully",
154
+ "subscription_id": subscription.id,
155
+ "timestamp": self.manager.get_current_timestamp()
156
+ }
157
+ except Exception as e:
158
+ raise HTTPException(
159
+ status_code=status.HTTP_400_BAD_REQUEST,
160
+ detail=str(e)
161
+ )
162
+
163
+ @self.app.delete("/subscriptions/{session_id}/{subscription_id}", tags=["subscriptions"])
164
+ async def remove_subscription(
165
+ subscription_id: str,
166
+ session_id: str = Depends(self._validate_session),
167
+ ):
168
+ if not self.manager.remove_subscription(session_id, subscription_id):
169
+ raise HTTPException(
170
+ status_code=status.HTTP_404_NOT_FOUND,
171
+ detail="Subscription not found"
172
+ )
173
+
174
+ return {
175
+ "success": True,
176
+ "message": "Subscription removed successfully",
177
+ "timestamp": self.manager.get_current_timestamp()
178
+ }
179
+
180
+ @self.app.get("/subscriptions/{session_id}", tags=["subscriptions"])
181
+ async def get_subscriptions(
182
+ session_id: str = Depends(self._validate_session),
183
+ ):
184
+ """Get all subscriptions for a session"""
185
+ subscriptions = self.manager.get_subscriptions(session_id)
186
+ return {
187
+ "success": True,
188
+ "subscriptions": subscriptions,
189
+ "count": len(subscriptions),
190
+ "timestamp": self.manager.get_current_timestamp()
191
+ }
192
+
193
+ def _register_notification_routes(self):
194
+ @self.app.post("/notifications/user/{user_id}", tags=["notifications"])
195
+ async def send_user_notification(
196
+ user_id: str,
197
+ notification_request: NotificationRequest,
198
+ ):
199
+ try:
200
+ notification = Notification(**notification_request.model_dump())
201
+ result = await self.manager.send_to_user(user_id, notification)
202
+
203
+ return {
204
+ "success": True,
205
+ "message": f"Notification sent to user {user_id}",
206
+ "notification_id": notification.id,
207
+ "delivered_to": result.get("delivered_to", 0),
208
+ "timestamp": self.manager.get_current_timestamp()
209
+ }
210
+ except Exception as e:
211
+ raise HTTPException(
212
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
213
+ detail=str(e)
214
+ )
215
+
216
+ @self.app.post("/notifications/role/{role}", tags=["notifications"])
217
+ async def send_role_notification(
218
+ role: str,
219
+ notification_request: NotificationRequest,
220
+ ):
221
+ try:
222
+ notification = Notification(**notification_request.model_dump())
223
+ result = await self.manager.send_to_role(role, notification)
224
+
225
+ return {
226
+ "success": True,
227
+ "message": f"Notification sent to role {role}",
228
+ "notification_id": notification.id,
229
+ "delivered_to": result.get("delivered_to", 0),
230
+ "timestamp": self.manager.get_current_timestamp()
231
+ }
232
+ except Exception as e:
233
+ raise HTTPException(
234
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
235
+ detail=str(e)
236
+ )
237
+
238
+ @self.app.post("/notifications/broadcast", tags=["notifications"])
239
+ async def broadcast_notification(
240
+ notification_request: NotificationRequest,
241
+ ):
242
+ try:
243
+ notification = Notification(**notification_request.model_dump())
244
+ result = await self.manager.broadcast_notification(notification)
245
+
246
+ return {
247
+ "success": True,
248
+ "message": "Notification broadcasted",
249
+ "notification_id": notification.id,
250
+ "delivered_to": result.get("delivered_to", 0),
251
+ "timestamp": self.manager.get_current_timestamp()
252
+ }
253
+ except Exception as e:
254
+ raise HTTPException(
255
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
256
+ detail=str(e)
257
+ )
258
+
259
+ @self.app.post("/notifications/send", tags=["notifications"])
260
+ async def send_targeted_notification(
261
+ request: BroadcastRequest,
262
+ ):
263
+ try:
264
+ notification = Notification(**request.notification.model_dump())
265
+
266
+ if request.target_type == "user":
267
+ result = await self.manager.send_to_user(request.target_value, notification)
268
+ elif request.target_type == "role":
269
+ result = await self.manager.send_to_role(request.target_value, notification)
270
+ elif request.target_type == "global":
271
+ result = await self.manager.broadcast_notification(notification)
272
+ else:
273
+ raise ValueError("Invalid target type")
274
+
275
+ return {
276
+ "success": True,
277
+ "message": f"Notification sent to {request.target_type}: {request.target_value}",
278
+ "notification_id": notification.id,
279
+ "delivered_to": result.get("delivered_to", 0),
280
+ "timestamp": self.manager.get_current_timestamp()
281
+ }
282
+ except Exception as e:
283
+ raise HTTPException(
284
+ status_code=status.HTTP_400_BAD_REQUEST,
285
+ detail=str(e)
286
+ )
287
+
288
+ def _register_monitoring_routes(self):
289
+ @self.app.get("/notifications/history", tags=["monitoring"])
290
+ async def get_notification_history(
291
+ limit: int = 100,
292
+ offset: int = 0,
293
+ ):
294
+ history = self.manager.get_notification_history(limit, offset)
295
+ return {
296
+ "success": True,
297
+ "history": history,
298
+ "count": len(history),
299
+ "limit": limit,
300
+ "offset": offset,
301
+ "timestamp": self.manager.get_current_timestamp()
302
+ }
303
+
304
+ @self.app.get("/stats", tags=["monitoring"])
305
+ async def get_service_stats():
306
+ stats = self.manager.get_stats()
307
+ return {
308
+ "success": True,
309
+ "stats": stats,
310
+ "timestamp": self.manager.get_current_timestamp()
311
+ }
312
+
313
+ @self.app.get("/clients", tags=["monitoring"])
314
+ async def get_connected_clients():
315
+ clients = [
316
+ {
317
+ "session_id": session_id,
318
+ "user_id": info.user_id,
319
+ "user_roles": info.user_roles,
320
+ "ip_address": info.ip_address,
321
+ "connected_at": info.connected_at.isoformat(),
322
+ "subscriptions_count": len(self.manager.subscriptions.get(session_id, [])),
323
+ "last_activity": info.last_activity.isoformat() if hasattr(info, 'last_activity') else None
324
+ }
325
+ for session_id, info in self.manager.client_info.items()
326
+ ]
327
+
328
+ return {
329
+ "success": True,
330
+ "clients": clients,
331
+ "total_connections": len(clients),
332
+ "timestamp": self.manager.get_current_timestamp()
333
+ }
334
+
335
+ # Helper methods
336
+ async def _validate_session(self, session_id: str) -> str:
337
+ if session_id not in self.manager.active_connections:
338
+ raise HTTPException(
339
+ status_code=status.HTTP_404_NOT_FOUND,
340
+ detail="Session not found or already closed"
341
+ )
342
+ return session_id
343
+
344
+ @staticmethod
345
+ def _extract_roles(websocket: WebSocket) -> List[str]:
346
+ roles_param = websocket.query_params.get("roles", "")
347
+ return roles_param.split(",") if roles_param else []
348
+
349
+ @staticmethod
350
+ def _get_client_ip(websocket: WebSocket) -> str:
351
+ return websocket.client.host if websocket.client else "unknown"
352
+
353
+ @staticmethod
354
+ def _get_user_agent(websocket: WebSocket) -> str:
355
+ return websocket.headers.get("user-agent", "unknown")
356
+
357
+ async def _handle_websocket_messages(self, websocket: WebSocket, session_id: str):
358
  while True:
359
+ try:
360
+ message = await websocket.receive_text()
361
+ await self._process_websocket_message(websocket, session_id, message)
362
+ except WebSocketDisconnect:
363
+ raise
364
+ except Exception as e:
365
+ self.logger.error(f"Error processing WebSocket message: {e}")
366
+ await websocket.send_text(f"Error: {str(e)}")
367
+
368
+ async def _process_websocket_message(self, websocket: WebSocket, session_id: str, message: str):
369
+ if message == "ping":
370
+ await websocket.send_text("pong")
371
+ elif message.startswith("subscribe:"):
372
+ topic = message.replace("subscribe:", "")
373
+ subscription = Subscription(type="topic", target=topic)
374
+ self.manager.add_subscription(session_id, subscription)
375
+ await websocket.send_text(f"subscribed:{topic}")
376
+ elif message.startswith("unsubscribe:"):
377
+ topic = message.replace("unsubscribe:", "")
378
+ await websocket.send_text(f"unsubscribed:{topic}")
379
+ else:
380
+ await self._handle_custom_message(websocket, session_id, message)
381
+
382
+ async def _handle_custom_message(self, websocket: WebSocket, session_id: str, message: str):
383
+ """Handle custom WebSocket messages"""
384
+ pass
385
+
386
+ @staticmethod
387
+ async def _handle_websocket_error(websocket: WebSocket, error: Exception):
388
+ """Handle WebSocket errors gracefully"""
389
+ try:
390
+ await websocket.send_text(f"Error: {str(error)}")
391
+ except:
392
+ raise
393
+
394
+ def run(self):
395
+ uvicorn.run(
396
+ self.app,
397
+ host=self.settings.APP_HOST,
398
+ port=self.settings.APP_PORT,
399
+ reload=self.settings.APP_DEBUG,
400
+ log_level="info",
401
+ server_header=False,
402
+ access_log=self.settings.APP_DEBUG
403
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
 
405
 
406
+ def main():
407
+ service = WebSocketNotificationService()
408
+ service.run()
 
 
 
 
 
 
 
 
 
 
 
409
 
410
 
411
  if __name__ == "__main__":
412
+ main()
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -3,4 +3,5 @@ PyYAML
3
  pydantic
4
  fastapi
5
  uvicorn[standard]
6
- jinja2
 
 
3
  pydantic
4
  fastapi
5
  uvicorn[standard]
6
+ jinja2
7
+ psutil