fixtureqa 0.3.4__tar.gz → 0.4.0__tar.gz

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 (102) hide show
  1. {fixtureqa-0.3.4/fixtureqa.egg-info → fixtureqa-0.4.0}/PKG-INFO +1 -1
  2. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/api/app.py +1 -0
  3. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/api/connection_manager.py +68 -50
  4. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/api/deps.py +17 -0
  5. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/api/routers/messages.py +6 -17
  6. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/api/routers/scenarios.py +16 -2
  7. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/api/routers/sessions.py +61 -109
  8. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/api/routers/ws.py +6 -4
  9. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/config_store.py +19 -28
  10. fixtureqa-0.4.0/fixture/core/fix_time.py +18 -0
  11. fixtureqa-0.4.0/fixture/core/json_store.py +82 -0
  12. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/message_store.py +40 -2
  13. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/perf_engine.py +2 -2
  14. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/perf_payload.py +2 -2
  15. fixtureqa-0.4.0/fixture/core/perf_store.py +35 -0
  16. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/perf_writer.py +40 -12
  17. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/scenario_runner.py +133 -85
  18. fixtureqa-0.4.0/fixture/core/scenario_store.py +36 -0
  19. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/session.py +37 -7
  20. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/session_manager.py +38 -6
  21. fixtureqa-0.4.0/fixture/core/template_store.py +36 -0
  22. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/venue_responses.py +3 -2
  23. fixtureqa-0.4.0/fixture/static/assets/index-CpzFFtxH.js +102 -0
  24. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/static/index.html +1 -1
  25. {fixtureqa-0.3.4 → fixtureqa-0.4.0/fixtureqa.egg-info}/PKG-INFO +1 -1
  26. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixtureqa.egg-info/SOURCES.txt +4 -1
  27. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/pyproject.toml +1 -1
  28. fixtureqa-0.4.0/tests/test_config_store.py +78 -0
  29. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/tests/test_connection_manager.py +5 -2
  30. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/tests/test_message_store.py +69 -0
  31. fixtureqa-0.4.0/tests/test_scenarios.py +197 -0
  32. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/tests/test_session_lifecycle.py +8 -4
  33. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/tests/test_sessions.py +46 -0
  34. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/tests/test_templates.py +15 -0
  35. fixtureqa-0.3.4/fixture/core/perf_store.py +0 -46
  36. fixtureqa-0.3.4/fixture/core/scenario_store.py +0 -66
  37. fixtureqa-0.3.4/fixture/core/template_store.py +0 -66
  38. fixtureqa-0.3.4/fixture/static/assets/index-BYDmHEr1.js +0 -102
  39. fixtureqa-0.3.4/tests/test_scenarios.py +0 -75
  40. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/LICENSE +0 -0
  41. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/README.md +0 -0
  42. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/__init__.py +0 -0
  43. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/__main__.py +0 -0
  44. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/api/__init__.py +0 -0
  45. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/api/routers/__init__.py +0 -0
  46. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/api/routers/admin.py +0 -0
  47. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/api/routers/auth.py +0 -0
  48. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/api/routers/branding.py +0 -0
  49. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/api/routers/custom_tags.py +0 -0
  50. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/api/routers/fix_spec.py +0 -0
  51. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/api/routers/perf.py +0 -0
  52. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/api/routers/setup.py +0 -0
  53. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/api/routers/spec_overlay.py +0 -0
  54. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/api/routers/templates.py +0 -0
  55. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/api/schemas.py +0 -0
  56. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/config/__init__.py +0 -0
  57. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/__init__.py +0 -0
  58. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/atomic_io.py +0 -0
  59. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/auth.py +0 -0
  60. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/custom_tag_store.py +0 -0
  61. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/db_migrations.py +0 -0
  62. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/events.py +0 -0
  63. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/fix_application.py +0 -0
  64. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/fix_builder.py +0 -0
  65. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/fix_parser.py +0 -0
  66. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/fix_spec_parser.py +0 -0
  67. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/fix_tags.py +0 -0
  68. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/housekeeping.py +0 -0
  69. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/inbound.py +0 -0
  70. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/message_log.py +0 -0
  71. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/models.py +0 -0
  72. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/perf_models.py +0 -0
  73. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/perf_stats.py +0 -0
  74. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/spec_overlay_store.py +0 -0
  75. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/core/user_store.py +0 -0
  76. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/fix_specs/FIX42.xml +0 -0
  77. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/fix_specs/FIX44.xml +0 -0
  78. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/server.py +0 -0
  79. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  80. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/static/assets/index-D9vW5wFo.css +0 -0
  81. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  82. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/static/favicon.svg +0 -0
  83. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixture/ui/__init__.py +0 -0
  84. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixtureqa.egg-info/dependency_links.txt +0 -0
  85. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixtureqa.egg-info/entry_points.txt +0 -0
  86. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixtureqa.egg-info/requires.txt +0 -0
  87. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/fixtureqa.egg-info/top_level.txt +0 -0
  88. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/setup.cfg +0 -0
  89. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/tests/test_atomic_io.py +0 -0
  90. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/tests/test_auth.py +0 -0
  91. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/tests/test_db_migrations.py +0 -0
  92. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/tests/test_fix_builder.py +0 -0
  93. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/tests/test_health.py +0 -0
  94. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/tests/test_inbound.py +0 -0
  95. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/tests/test_inbound_validation.py +0 -0
  96. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/tests/test_perf_api.py +0 -0
  97. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/tests/test_perf_engine.py +0 -0
  98. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/tests/test_perf_models.py +0 -0
  99. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/tests/test_perf_payload.py +0 -0
  100. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/tests/test_perf_rehydrate.py +0 -0
  101. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/tests/test_session_manager_concurrency.py +0 -0
  102. {fixtureqa-0.3.4 → fixtureqa-0.4.0}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixtureqa
3
- Version: 0.3.4
3
+ Version: 0.4.0
4
4
  Summary: FIXture — FIX Protocol Testing Tool
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -30,6 +30,7 @@ async def lifespan(app: FastAPI):
30
30
  loop = asyncio.get_running_loop()
31
31
  app.state.loop = loop
32
32
  app.state.scenario_runner.set_loop(loop)
33
+ app.state.connection_manager.set_loop(loop)
33
34
  # Register event bridge with SessionManager
34
35
  sm: SessionManager = app.state.session_manager
35
36
  cm: ConnectionManager = app.state.connection_manager
@@ -5,6 +5,7 @@ fixcore callbacks fire on the asyncio event loop — no thread-crossing needed.
5
5
  """
6
6
 
7
7
  import asyncio
8
+ import json
8
9
  import logging
9
10
  from datetime import datetime, timezone
10
11
 
@@ -79,6 +80,9 @@ class ConnectionManager:
79
80
 
80
81
  def __init__(self):
81
82
  self._clients: dict[str, dict[WebSocket, _ConnInfo]] = {}
83
+ # The FastAPI/uvicorn event loop, bound in the app lifespan — needed to
84
+ # schedule broadcasts submitted from worker threads (scenario runner).
85
+ self._loop: asyncio.AbstractEventLoop | None = None
82
86
  # Hold strong references to in-flight broadcast tasks. asyncio only keeps
83
87
  # a weak reference to tasks, so a fire-and-forget create_task() can be
84
88
  # garbage-collected mid-execution, silently dropping a broadcast.
@@ -185,63 +189,77 @@ class ConnectionManager:
185
189
  }
186
190
  await self._broadcast(sid, payload, c["owner"])
187
191
 
192
+ def set_loop(self, loop: asyncio.AbstractEventLoop) -> None:
193
+ """Bind the event loop that off-loop callers submit broadcasts to."""
194
+ self._loop = loop
195
+
188
196
  def broadcast_scenario_event(self, uid: str, data: dict) -> None:
189
- """Schedule delivery to all WS connections for uid."""
190
- self._spawn(self._broadcast_to_user(uid, data))
197
+ """Schedule delivery to all WS connections for uid.
198
+
199
+ Thread-safe: the scenario runner calls this from its worker threads,
200
+ where create_task raises RuntimeError (no running loop in that thread)
201
+ — which killed the run thread on its very first scenario_start
202
+ broadcast after the fixcore migration dropped call_soon_threadsafe.
203
+ """
204
+ coro = self._broadcast_to_user(uid, data)
205
+ try:
206
+ asyncio.get_running_loop()
207
+ except RuntimeError:
208
+ loop = self._loop
209
+ if loop is None or loop.is_closed():
210
+ coro.close()
211
+ return
212
+ try:
213
+ asyncio.run_coroutine_threadsafe(coro, loop)
214
+ except RuntimeError:
215
+ # Loop closed between the check and the submit (app shutting
216
+ # down while a scenario run is mid-flight) — drop the broadcast
217
+ # rather than killing the run thread.
218
+ coro.close()
219
+ return
220
+ self._spawn(coro)
191
221
 
192
222
  # ------------------------------------------------------------------
193
223
  # Internal async broadcast
194
224
  # ------------------------------------------------------------------
195
225
 
196
- async def _broadcast(self, session_id: str, payload: dict, owner_uid: str) -> None:
197
- specific = dict(self._clients.get(session_id, {}))
198
- wildcard = dict(self._clients.get("*", {}))
199
-
200
- logger.debug("_broadcast: type=%s session=%s owner=%r clients_specific=%d wildcard=%d",
201
- payload.get("type"), session_id, owner_uid, len(specific), len(wildcard))
202
-
203
- # Build target set: connections that may receive this event
204
- # A connection receives the event if:
205
- # - it is an admin, OR
206
- # - its uid matches the session owner
207
- targets: dict[WebSocket, _ConnInfo] = {}
208
- for ws, info in {**specific, **wildcard}.items():
209
- if info.is_admin or info.uid == owner_uid:
210
- targets[ws] = info
211
-
212
- logger.debug("_broadcast: %d target(s) for owner=%r", len(targets), owner_uid)
213
-
214
- dead: list[tuple[WebSocket, str]] = []
215
- for ws in targets:
216
- try:
217
- if ws.client_state == WebSocketState.CONNECTED:
218
- await ws.send_json(payload)
219
- except Exception:
220
- for key, sockets in self._clients.items():
221
- if ws in sockets:
222
- dead.append((ws, key))
226
+ async def _send_text(self, ws: WebSocket, text: str) -> bool:
227
+ """Send pre-serialized JSON to one socket. False = socket is dead."""
228
+ try:
229
+ if ws.client_state == WebSocketState.CONNECTED:
230
+ await ws.send_text(text)
231
+ return True
232
+ except Exception:
233
+ return False
234
+
235
+ def _prune(self, dead: list[WebSocket]) -> None:
236
+ for ws in dead:
237
+ for sockets in self._clients.values():
238
+ sockets.pop(ws, None)
239
+
240
+ async def _fan_out(self, targets: list[WebSocket], payload: dict) -> None:
241
+ """Serialize once and send concurrently — per-socket send_json would
242
+ re-encode the same payload per client, and sequential awaits let one
243
+ slow client head-of-line-block delivery to everyone else."""
244
+ if not targets:
245
+ return
246
+ text = json.dumps(payload)
247
+ oks = await asyncio.gather(*(self._send_text(ws, text) for ws in targets))
248
+ self._prune([ws for ws, ok in zip(targets, oks) if not ok])
223
249
 
224
- for ws, key in dead:
225
- self._clients.get(key, {}).pop(ws, None)
250
+ async def _broadcast(self, session_id: str, payload: dict, owner_uid: str) -> None:
251
+ # A connection receives the event if it is an admin, or its uid
252
+ # matches the session owner.
253
+ targets = [ws for ws, info in {**self._clients.get(session_id, {}),
254
+ **self._clients.get("*", {})}.items()
255
+ if info.is_admin or info.uid == owner_uid]
256
+ await self._fan_out(targets, payload)
226
257
 
227
258
  async def _broadcast_to_user(self, uid: str, data: dict) -> None:
228
- """Send data to all WS connections belonging to uid."""
229
- all_clients = {}
259
+ """Send data to all WS connections belonging to uid (admins included)."""
260
+ all_clients: dict[WebSocket, _ConnInfo] = {}
230
261
  for sockets in self._clients.values():
231
- for ws, info in sockets.items():
232
- all_clients[ws] = info
233
-
234
- dead: list[tuple[WebSocket, str]] = []
235
- for ws, info in all_clients.items():
236
- if info.uid != uid and not info.is_admin:
237
- continue
238
- try:
239
- if ws.client_state == WebSocketState.CONNECTED:
240
- await ws.send_json(data)
241
- except Exception:
242
- for key, sockets in self._clients.items():
243
- if ws in sockets:
244
- dead.append((ws, key))
245
-
246
- for ws, key in dead:
247
- self._clients.get(key, {}).pop(ws, None)
262
+ all_clients.update(sockets)
263
+ targets = [ws for ws, info in all_clients.items()
264
+ if info.uid == uid or info.is_admin]
265
+ await self._fan_out(targets, data)
@@ -59,6 +59,23 @@ def require_platform_admin(user: User = Depends(get_current_user)) -> User:
59
59
  return user
60
60
 
61
61
 
62
+ def get_owned_session(
63
+ session_id: str,
64
+ request: HTTPConnection,
65
+ user: User = Depends(get_current_user),
66
+ ):
67
+ """Resolve the `session_id` path param to its SessionConfig, enforcing
68
+ that the caller owns it (or is admin). One dependency instead of a
69
+ _check_ownership copy in every session-scoped router."""
70
+ sm: SessionManager = request.app.state.session_manager
71
+ cfg = sm.get_config(session_id)
72
+ if cfg is None:
73
+ raise HTTPException(status_code=404, detail="Session not found")
74
+ if not user.is_admin and cfg.owner_uid != user.uid:
75
+ raise HTTPException(status_code=403, detail="Access denied")
76
+ return cfg
77
+
78
+
62
79
  def get_housekeeping(request: HTTPConnection) -> HousekeepingService:
63
80
  return request.app.state.housekeeping
64
81
 
@@ -4,38 +4,29 @@ from typing import Annotated, Optional
4
4
  from fastapi import APIRouter, Depends, HTTPException, Query
5
5
  from fastapi.responses import StreamingResponse
6
6
 
7
+ from ...core.models import SessionConfig
7
8
  from ...core.session_manager import SessionManager
8
9
  from ...core.user_store import User
9
10
  from ..schemas import MessageEntryResponse
10
- from ..deps import get_session_manager, get_current_user
11
+ from ..deps import get_session_manager, get_owned_session
11
12
 
12
13
  router = APIRouter(tags=["messages"])
13
14
 
14
15
  SM = Annotated[SessionManager, Depends(get_session_manager)]
15
- CurrentUser = Annotated[User, Depends(get_current_user)]
16
-
17
-
18
- def _check_ownership(sm: SessionManager, session_id: str, user: User) -> None:
19
- configs = sm.list_sessions()
20
- cfg = next((c for c in configs if c.session_id == session_id), None)
21
- if cfg is None:
22
- raise HTTPException(status_code=404, detail="Session not found")
23
- if not user.is_admin and cfg.owner_uid != user.uid:
24
- raise HTTPException(status_code=403, detail="Access denied")
16
+ OwnedSession = Annotated[SessionConfig, Depends(get_owned_session)]
25
17
 
26
18
 
27
19
  @router.get("/{session_id}/messages", response_model=list[MessageEntryResponse])
28
20
  def get_messages(
29
21
  session_id: str,
22
+ _cfg: OwnedSession,
30
23
  sm: SM,
31
- user: CurrentUser,
32
24
  direction: Optional[str] = Query(default=None, description="IN or OUT"),
33
25
  admin: Optional[bool] = Query(default=None),
34
26
  msg_type: Optional[str] = Query(default=None),
35
27
  limit: int = Query(default=200, ge=1, le=5000),
36
28
  before_id: Optional[int] = Query(default=None, description="Cursor: return rows with id < before_id"),
37
29
  ):
38
- _check_ownership(sm, session_id, user)
39
30
  try:
40
31
  state = sm.get_state(session_id)
41
32
  except KeyError:
@@ -72,11 +63,10 @@ def get_messages(
72
63
  @router.get("/{session_id}/messages/export")
73
64
  def export_messages(
74
65
  session_id: str,
66
+ _cfg: OwnedSession,
75
67
  sm: SM,
76
- user: CurrentUser,
77
68
  fmt: str = Query(default="csv", description="Export format: csv or fix"),
78
69
  ):
79
- _check_ownership(sm, session_id, user)
80
70
  try:
81
71
  state = sm.get_state(session_id)
82
72
  except KeyError:
@@ -127,8 +117,7 @@ def export_messages(
127
117
 
128
118
 
129
119
  @router.delete("/{session_id}/messages", status_code=204)
130
- def clear_messages(session_id: str, sm: SM, user: CurrentUser):
131
- _check_ownership(sm, session_id, user)
120
+ def clear_messages(session_id: str, _cfg: OwnedSession, sm: SM):
132
121
  try:
133
122
  state = sm.get_state(session_id)
134
123
  except KeyError:
@@ -3,14 +3,17 @@ from typing import Annotated
3
3
 
4
4
  from ...core.scenario_store import ScenarioStore
5
5
  from ...core.scenario_runner import ScenarioRunner
6
+ from ...core.session_manager import SessionManager
6
7
  from ...core.user_store import User
7
- from ..deps import get_current_user, get_scenario_store, get_scenario_runner
8
+ from ..deps import (get_current_user, get_scenario_store, get_scenario_runner,
9
+ get_session_manager)
8
10
  from ..schemas import ScenarioRequest, ScenarioResponse
9
11
 
10
12
  router = APIRouter(tags=["scenarios"])
11
13
 
12
14
  SS = Depends(get_scenario_store)
13
15
  SR = Depends(get_scenario_runner)
16
+ SM = Depends(get_session_manager)
14
17
  CurrentUser = Depends(get_current_user)
15
18
 
16
19
 
@@ -50,16 +53,27 @@ def run_scenario(
50
53
  scenario_id: str,
51
54
  store: ScenarioStore = SS,
52
55
  runner: ScenarioRunner = SR,
56
+ sm: SessionManager = SM,
53
57
  user: User = CurrentUser,
54
58
  ):
55
59
  scenario = store.get_scenario(user.uid, scenario_id)
56
60
  if scenario is None:
57
61
  raise HTTPException(status_code=404, detail="Scenario not found")
62
+ # The session_id is scenario data, not a path param — enforce ownership
63
+ # here or any user could drive another user's session through a scenario.
64
+ cfg = sm.get_config(scenario.get("session_id", ""))
65
+ if cfg is None:
66
+ raise HTTPException(status_code=404, detail="Session not found")
67
+ if not user.is_admin and cfg.owner_uid != user.uid:
68
+ raise HTTPException(status_code=403, detail="Access denied")
58
69
  run_id = runner.run(scenario, user.uid)
59
70
  return {"run_id": run_id}
60
71
 
61
72
 
62
73
  @router.delete("/runs/{run_id}", status_code=status.HTTP_204_NO_CONTENT)
63
74
  def abort_run(run_id: str, runner: ScenarioRunner = SR, user: User = CurrentUser):
64
- runner.abort(run_id)
75
+ try:
76
+ runner.abort(run_id, uid=user.uid, is_admin=user.is_admin)
77
+ except PermissionError:
78
+ raise HTTPException(status_code=403, detail="Access denied")
65
79
  return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -1,30 +1,53 @@
1
1
  from typing import Annotated, Optional
2
2
  from fastapi import APIRouter, Depends, HTTPException, Query
3
3
 
4
+ from ...core.message_store import MessageStore
4
5
  from ...core.session_manager import SessionManager
5
6
  from ...core.models import SessionConfig, ConnectionType, SessionRole
6
7
  from ...core.user_store import User, UserStore
7
8
  from ..schemas import SessionConfigRequest, SessionResponse, UpdateSessionRequest, SendMessageRequest, SeqNumRequest, SeqNumResponse
8
- from ..deps import get_session_manager, get_current_user, get_user_store
9
+ from ..deps import get_session_manager, get_current_user, get_owned_session, get_user_store
9
10
 
10
11
  router = APIRouter(tags=["sessions"])
11
12
 
12
13
  SM = Annotated[SessionManager, Depends(get_session_manager)]
13
14
  US = Annotated[UserStore, Depends(get_user_store)]
14
15
  CurrentUser = Annotated[User, Depends(get_current_user)]
16
+ OwnedSession = Annotated[SessionConfig, Depends(get_owned_session)]
15
17
 
16
18
 
17
- def _session_response(sm: SessionManager, session_id: str, us: Optional[UserStore] = None) -> SessionResponse:
18
- cfg_list = [c for c in sm.list_sessions() if c.session_id == session_id]
19
- if not cfg_list:
19
+ def _config_from_request(body: SessionConfigRequest, owner_uid: str) -> SessionConfig:
20
+ """Map the request schema onto SessionConfig in one place create and
21
+ import used to hand-copy 24 fields each, and a field missed in one copy
22
+ silently lost its value on that path."""
23
+ try:
24
+ ct = ConnectionType(body.connection_type)
25
+ except ValueError:
26
+ raise HTTPException(status_code=422, detail=f"Invalid connection_type: {body.connection_type!r}")
27
+ try:
28
+ role = SessionRole(body.session_role)
29
+ except ValueError:
30
+ raise HTTPException(status_code=422, detail=f"Invalid session_role: {body.session_role!r}")
31
+ data = body.model_dump(exclude={"connection_type", "session_role", "auto_start"})
32
+ return SessionConfig(**data, connection_type=ct, session_role=role, owner_uid=owner_uid)
33
+
34
+
35
+ def _session_response(sm: SessionManager, session_id: str, us: Optional[UserStore] = None,
36
+ counts: Optional[dict] = None) -> SessionResponse:
37
+ cfg = sm.get_config(session_id)
38
+ if cfg is None:
20
39
  raise HTTPException(status_code=404, detail="Session not found")
21
- cfg = cfg_list[0]
22
40
  state = sm.get_state(session_id)
23
41
  log = state.message_log
24
42
  owner_username: Optional[str] = None
25
43
  if us is not None:
26
44
  owner = us.get_by_uid(cfg.owner_uid)
27
45
  owner_username = owner.username if owner else None
46
+ if counts is not None and isinstance(log, MessageStore):
47
+ # List endpoint: one GROUP BY for all sessions instead of COUNT(*) each.
48
+ message_count = counts.get(session_id, 0)
49
+ else:
50
+ message_count = len(log) if log else 0
28
51
  return SessionResponse(
29
52
  session_id=cfg.session_id,
30
53
  display_name=cfg.display_name,
@@ -53,68 +76,29 @@ def _session_response(sm: SessionManager, session_id: str, us: Optional[UserStor
53
76
  status=state.status.name,
54
77
  last_sent_seq_num=state.last_sent_seq_num,
55
78
  last_recv_seq_num=state.last_recv_seq_num,
56
- message_count=len(log) if log else 0,
79
+ message_count=message_count,
57
80
  status_changed_at=state.status_changed_at,
58
81
  last_heartbeat_at=state.last_heartbeat_at,
59
82
  owner_username=owner_username,
60
83
  )
61
84
 
62
85
 
63
- def _check_ownership(sm: SessionManager, session_id: str, user: User) -> SessionConfig:
64
- """Return the SessionConfig if the user owns it (or is admin). Raises 404/403 otherwise."""
65
- configs = sm.list_sessions()
66
- cfg = next((c for c in configs if c.session_id == session_id), None)
67
- if cfg is None:
68
- raise HTTPException(status_code=404, detail="Session not found")
69
- if not user.is_admin and cfg.owner_uid != user.uid:
70
- raise HTTPException(status_code=403, detail="Access denied")
71
- return cfg
86
+ def _owner_store(us: UserStore, user: User) -> Optional[UserStore]:
87
+ """Only admin responses carry owner_username (the UI owner badge / Mine filter)."""
88
+ return us if user.is_admin else None
72
89
 
73
90
 
74
91
  @router.get("/", response_model=list[SessionResponse])
75
92
  def list_sessions(sm: SM, us: US, user: CurrentUser):
76
93
  configs = sm.list_sessions_for_user(user.uid, is_admin=user.is_admin)
77
- user_store = us if user.is_admin else None
78
- return [_session_response(sm, cfg.session_id, user_store) for cfg in configs]
94
+ user_store = _owner_store(us, user)
95
+ counts = sm.message_counts()
96
+ return [_session_response(sm, cfg.session_id, user_store, counts) for cfg in configs]
79
97
 
80
98
 
81
99
  @router.post("/", response_model=SessionResponse, status_code=201)
82
- async def create_session(body: SessionConfigRequest, sm: SM, user: CurrentUser):
83
- try:
84
- ct = ConnectionType(body.connection_type)
85
- except ValueError:
86
- raise HTTPException(status_code=422, detail=f"Invalid connection_type: {body.connection_type!r}")
87
- try:
88
- role = SessionRole(body.session_role)
89
- except ValueError:
90
- raise HTTPException(status_code=422, detail=f"Invalid session_role: {body.session_role!r}")
91
-
92
- cfg = SessionConfig(
93
- display_name=body.display_name,
94
- begin_string=body.begin_string,
95
- sender_comp_id=body.sender_comp_id,
96
- target_comp_id=body.target_comp_id,
97
- connection_type=ct,
98
- host=body.host,
99
- port=body.port,
100
- heartbeat_interval=body.heartbeat_interval,
101
- reconnect_interval=body.reconnect_interval,
102
- data_dictionary_path=body.data_dictionary_path,
103
- use_file_log=body.use_file_log,
104
- start_time=body.start_time,
105
- end_time=body.end_time,
106
- reset_on_logon=body.reset_on_logon,
107
- reset_on_logout=body.reset_on_logout,
108
- durable_seqnums=body.durable_seqnums,
109
- log_messages=body.log_messages,
110
- validate_inbound=body.validate_inbound,
111
- session_role=role,
112
- auto_ack=body.auto_ack,
113
- auto_ack_delay_ms=body.auto_ack_delay_ms,
114
- auto_fill=body.auto_fill,
115
- auto_fill_delay_ms=body.auto_fill_delay_ms,
116
- owner_uid=user.uid,
117
- )
100
+ async def create_session(body: SessionConfigRequest, sm: SM, us: US, user: CurrentUser):
101
+ cfg = _config_from_request(body, owner_uid=user.uid)
118
102
 
119
103
  try:
120
104
  session_id = sm.add_session(cfg)
@@ -127,75 +111,46 @@ async def create_session(body: SessionConfigRequest, sm: SM, user: CurrentUser):
127
111
  except Exception as e:
128
112
  raise HTTPException(status_code=500, detail=f"Session added but failed to start: {e}")
129
113
 
130
- return _session_response(sm, session_id)
114
+ return _session_response(sm, session_id, _owner_store(us, user))
131
115
 
132
116
 
133
117
  @router.post("/import", response_model=list[SessionResponse], status_code=201)
134
- def import_sessions(body: list[SessionConfigRequest], sm: SM, user: CurrentUser):
118
+ def import_sessions(body: list[SessionConfigRequest], sm: SM, us: US, user: CurrentUser):
135
119
  """
136
120
  Bulk-create sessions from an exported config file.
137
- Each entry is processed independently; invalid entries are skipped.
138
- Returns the list of successfully created sessions.
121
+ Each entry is processed independently; invalid entries (bad enums, port
122
+ conflicts, duplicate ids) are skipped. Returns the successfully created
123
+ sessions.
139
124
  """
140
125
  created = []
141
126
  for item in body:
142
127
  try:
143
- ct = ConnectionType(item.connection_type)
144
- role = SessionRole(item.session_role)
145
- cfg = SessionConfig(
146
- display_name=item.display_name,
147
- begin_string=item.begin_string,
148
- sender_comp_id=item.sender_comp_id,
149
- target_comp_id=item.target_comp_id,
150
- connection_type=ct,
151
- host=item.host,
152
- port=item.port,
153
- heartbeat_interval=item.heartbeat_interval,
154
- reconnect_interval=item.reconnect_interval,
155
- data_dictionary_path=item.data_dictionary_path,
156
- use_file_log=item.use_file_log,
157
- start_time=item.start_time,
158
- end_time=item.end_time,
159
- reset_on_logon=item.reset_on_logon,
160
- reset_on_logout=item.reset_on_logout,
161
- durable_seqnums=item.durable_seqnums,
162
- log_messages=item.log_messages,
163
- validate_inbound=item.validate_inbound,
164
- session_role=role,
165
- auto_ack=item.auto_ack,
166
- auto_ack_delay_ms=item.auto_ack_delay_ms,
167
- auto_fill=item.auto_fill,
168
- auto_fill_delay_ms=item.auto_fill_delay_ms,
169
- owner_uid=user.uid,
170
- )
128
+ cfg = _config_from_request(item, owner_uid=user.uid)
171
129
  session_id = sm.add_session(cfg)
172
- created.append(_session_response(sm, session_id))
173
- except Exception:
174
- pass # skip invalid / conflicting entries
130
+ except (HTTPException, ValueError, KeyError):
131
+ continue # invalid / conflicting entry — skip just this one
132
+ created.append(_session_response(sm, session_id, _owner_store(us, user)))
175
133
  return created
176
134
 
177
135
 
178
136
  @router.get("/{session_id}", response_model=SessionResponse)
179
- def get_session(session_id: str, sm: SM, user: CurrentUser):
180
- _check_ownership(sm, session_id, user)
181
- return _session_response(sm, session_id)
137
+ def get_session(session_id: str, _cfg: OwnedSession, sm: SM, us: US, user: CurrentUser):
138
+ return _session_response(sm, session_id, _owner_store(us, user))
182
139
 
183
140
 
184
141
  @router.patch("/{session_id}", response_model=SessionResponse)
185
- def update_session(session_id: str, body: UpdateSessionRequest, sm: SM, user: CurrentUser):
186
- _check_ownership(sm, session_id, user)
142
+ def update_session(session_id: str, body: UpdateSessionRequest, _cfg: OwnedSession, sm: SM, us: US, user: CurrentUser):
187
143
  try:
188
144
  sm.update_session(session_id, body)
189
145
  except RuntimeError as e:
190
146
  raise HTTPException(status_code=409, detail=str(e))
191
147
  except (ValueError, KeyError) as e:
192
148
  raise HTTPException(status_code=422, detail=str(e))
193
- return _session_response(sm, session_id)
149
+ return _session_response(sm, session_id, _owner_store(us, user))
194
150
 
195
151
 
196
152
  @router.delete("/{session_id}", status_code=204)
197
- async def remove_session(session_id: str, sm: SM, user: CurrentUser):
198
- _check_ownership(sm, session_id, user)
153
+ async def remove_session(session_id: str, _cfg: OwnedSession, sm: SM):
199
154
  try:
200
155
  await sm.remove_session(session_id)
201
156
  except KeyError:
@@ -203,36 +158,35 @@ async def remove_session(session_id: str, sm: SM, user: CurrentUser):
203
158
 
204
159
 
205
160
  @router.post("/{session_id}/start", response_model=SessionResponse)
206
- async def start_session(session_id: str, sm: SM, user: CurrentUser):
207
- _check_ownership(sm, session_id, user)
161
+ async def start_session(session_id: str, _cfg: OwnedSession, sm: SM, us: US, user: CurrentUser):
208
162
  try:
209
163
  await sm.start_session(session_id)
210
164
  except RuntimeError as e:
211
165
  raise HTTPException(status_code=409, detail=str(e))
212
- return _session_response(sm, session_id)
166
+ return _session_response(sm, session_id, _owner_store(us, user))
213
167
 
214
168
 
215
169
  @router.post("/{session_id}/stop", response_model=SessionResponse)
216
170
  async def stop_session(
217
171
  session_id: str,
172
+ _cfg: OwnedSession,
218
173
  sm: SM,
174
+ us: US,
219
175
  user: CurrentUser,
220
176
  force: bool = Query(default=False),
221
177
  ):
222
- _check_ownership(sm, session_id, user)
223
178
  await sm.stop_session(session_id, force=force)
224
- return _session_response(sm, session_id)
179
+ return _session_response(sm, session_id, _owner_store(us, user))
225
180
 
226
181
 
227
182
  @router.post("/{session_id}/send")
228
- async def send_message(session_id: str, body: SendMessageRequest, sm: SM, user: CurrentUser):
229
- _check_ownership(sm, session_id, user)
183
+ async def send_message(session_id: str, body: SendMessageRequest, _cfg: OwnedSession, sm: SM):
230
184
  from ...core.fix_builder import build_message_from_raw
231
- from datetime import datetime, timezone
185
+ from ...core.fix_time import utc_timestamp
232
186
 
233
187
  # Build group-aware using the session's DataDictionary so repeating groups
234
188
  # (e.g. NoAllocs rows) survive instead of collapsing to their last instance.
235
- ts = None if body.no_auto_ts else datetime.now(timezone.utc).strftime("%Y%m%d-%H:%M:%S")
189
+ ts = None if body.no_auto_ts else utc_timestamp()
236
190
  try:
237
191
  msg = build_message_from_raw(body.raw, sm.get_data_dictionary(session_id), transact_time=ts)
238
192
  except ValueError as e:
@@ -245,8 +199,7 @@ async def send_message(session_id: str, body: SendMessageRequest, sm: SM, user:
245
199
 
246
200
 
247
201
  @router.get("/{session_id}/seqnums", response_model=SeqNumResponse)
248
- def get_seqnums(session_id: str, sm: SM, user: CurrentUser):
249
- _check_ownership(sm, session_id, user)
202
+ def get_seqnums(session_id: str, _cfg: OwnedSession, sm: SM):
250
203
  try:
251
204
  sender, target = sm.get_seqnums(session_id)
252
205
  except KeyError:
@@ -255,8 +208,7 @@ def get_seqnums(session_id: str, sm: SM, user: CurrentUser):
255
208
 
256
209
 
257
210
  @router.patch("/{session_id}/seqnums", response_model=SeqNumResponse)
258
- def set_seqnums(session_id: str, body: SeqNumRequest, sm: SM, user: CurrentUser):
259
- _check_ownership(sm, session_id, user)
211
+ def set_seqnums(session_id: str, body: SeqNumRequest, _cfg: OwnedSession, sm: SM):
260
212
  if body.sender is None and body.target is None:
261
213
  raise HTTPException(status_code=422, detail="Provide sender, target, or both")
262
214
  try:
@@ -81,7 +81,10 @@ async def ws_all_sessions(
81
81
  """Subscribe to events from all sessions."""
82
82
  uid, is_admin, subprotocol = _authenticate_ws(websocket, token)
83
83
  if not uid:
84
- logger.warning("WS /ws: auth failed (no token or invalid JWT)")
84
+ # Routine, not actionable: pre-login tabs, expired tokens, or a proxy
85
+ # stripping Sec-WebSocket-Protocol all land here and the client retries
86
+ # — at WARNING this used to flood the log with one line per attempt.
87
+ logger.debug("WS /ws: auth failed (no token or invalid JWT)")
85
88
  await websocket.close(code=4003, reason="Unauthorized")
86
89
  return
87
90
 
@@ -122,7 +125,7 @@ async def ws_single_session(
122
125
  """Subscribe to events from a single session."""
123
126
  uid, is_admin, subprotocol = _authenticate_ws(websocket, token)
124
127
  if not uid:
125
- logger.warning("WS /ws/%s: auth failed (no token or invalid JWT)", session_id)
128
+ logger.debug("WS /ws/%s: auth failed (no token or invalid JWT)", session_id)
126
129
  await websocket.close(code=4003, reason="Unauthorized")
127
130
  return
128
131
 
@@ -134,8 +137,7 @@ async def ws_single_session(
134
137
  return
135
138
 
136
139
  # Check ownership of this specific session
137
- configs = sm.list_sessions()
138
- cfg = next((c for c in configs if c.session_id == session_id), None)
140
+ cfg = sm.get_config(session_id)
139
141
  if cfg is None or (not is_admin and cfg.owner_uid != uid):
140
142
  logger.warning("WS /ws/%s: access denied for uid=%s", session_id, uid)
141
143
  await websocket.close(code=4003, reason="Access denied")