fixtureqa 0.3.5__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.5/fixtureqa.egg-info → fixtureqa-0.4.0}/PKG-INFO +1 -1
  2. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/app.py +1 -0
  3. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/connection_manager.py +68 -50
  4. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/deps.py +17 -0
  5. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/messages.py +6 -17
  6. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/scenarios.py +16 -2
  7. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/sessions.py +49 -103
  8. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/ws.py +6 -4
  9. {fixtureqa-0.3.5 → 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.5 → fixtureqa-0.4.0}/fixture/core/message_store.py +40 -2
  13. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/perf_engine.py +2 -2
  14. {fixtureqa-0.3.5 → 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.5 → fixtureqa-0.4.0}/fixture/core/perf_writer.py +40 -12
  17. {fixtureqa-0.3.5 → 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.5 → fixtureqa-0.4.0}/fixture/core/session.py +37 -7
  20. {fixtureqa-0.3.5 → 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.5 → 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.5 → fixtureqa-0.4.0}/fixture/static/index.html +1 -1
  25. {fixtureqa-0.3.5 → fixtureqa-0.4.0/fixtureqa.egg-info}/PKG-INFO +1 -1
  26. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixtureqa.egg-info/SOURCES.txt +4 -1
  27. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/pyproject.toml +1 -1
  28. fixtureqa-0.4.0/tests/test_config_store.py +78 -0
  29. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_connection_manager.py +5 -2
  30. {fixtureqa-0.3.5 → 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.5 → fixtureqa-0.4.0}/tests/test_session_lifecycle.py +8 -4
  33. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_sessions.py +29 -0
  34. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_templates.py +15 -0
  35. fixtureqa-0.3.5/fixture/core/perf_store.py +0 -46
  36. fixtureqa-0.3.5/fixture/core/scenario_store.py +0 -66
  37. fixtureqa-0.3.5/fixture/core/template_store.py +0 -66
  38. fixtureqa-0.3.5/fixture/static/assets/index-BYDmHEr1.js +0 -102
  39. fixtureqa-0.3.5/tests/test_scenarios.py +0 -75
  40. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/LICENSE +0 -0
  41. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/README.md +0 -0
  42. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/__init__.py +0 -0
  43. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/__main__.py +0 -0
  44. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/__init__.py +0 -0
  45. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/__init__.py +0 -0
  46. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/admin.py +0 -0
  47. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/auth.py +0 -0
  48. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/branding.py +0 -0
  49. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/custom_tags.py +0 -0
  50. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/fix_spec.py +0 -0
  51. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/perf.py +0 -0
  52. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/setup.py +0 -0
  53. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/spec_overlay.py +0 -0
  54. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/routers/templates.py +0 -0
  55. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/api/schemas.py +0 -0
  56. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/config/__init__.py +0 -0
  57. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/__init__.py +0 -0
  58. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/atomic_io.py +0 -0
  59. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/auth.py +0 -0
  60. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/custom_tag_store.py +0 -0
  61. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/db_migrations.py +0 -0
  62. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/events.py +0 -0
  63. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/fix_application.py +0 -0
  64. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/fix_builder.py +0 -0
  65. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/fix_parser.py +0 -0
  66. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/fix_spec_parser.py +0 -0
  67. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/fix_tags.py +0 -0
  68. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/housekeeping.py +0 -0
  69. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/inbound.py +0 -0
  70. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/message_log.py +0 -0
  71. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/models.py +0 -0
  72. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/perf_models.py +0 -0
  73. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/perf_stats.py +0 -0
  74. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/spec_overlay_store.py +0 -0
  75. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/core/user_store.py +0 -0
  76. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/fix_specs/FIX42.xml +0 -0
  77. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/fix_specs/FIX44.xml +0 -0
  78. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/server.py +0 -0
  79. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  80. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/static/assets/index-D9vW5wFo.css +0 -0
  81. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  82. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/static/favicon.svg +0 -0
  83. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixture/ui/__init__.py +0 -0
  84. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixtureqa.egg-info/dependency_links.txt +0 -0
  85. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixtureqa.egg-info/entry_points.txt +0 -0
  86. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixtureqa.egg-info/requires.txt +0 -0
  87. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/fixtureqa.egg-info/top_level.txt +0 -0
  88. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/setup.cfg +0 -0
  89. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_atomic_io.py +0 -0
  90. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_auth.py +0 -0
  91. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_db_migrations.py +0 -0
  92. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_fix_builder.py +0 -0
  93. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_health.py +0 -0
  94. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_inbound.py +0 -0
  95. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_inbound_validation.py +0 -0
  96. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_perf_api.py +0 -0
  97. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_perf_engine.py +0 -0
  98. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_perf_models.py +0 -0
  99. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_perf_payload.py +0 -0
  100. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_perf_rehydrate.py +0 -0
  101. {fixtureqa-0.3.5 → fixtureqa-0.4.0}/tests/test_session_manager_concurrency.py +0 -0
  102. {fixtureqa-0.3.5 → 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.5
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,24 +76,13 @@ 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
72
-
73
-
74
86
  def _owner_store(us: UserStore, user: User) -> Optional[UserStore]:
75
87
  """Only admin responses carry owner_username (the UI owner badge / Mine filter)."""
76
88
  return us if user.is_admin else None
@@ -80,46 +92,13 @@ def _owner_store(us: UserStore, user: User) -> Optional[UserStore]:
80
92
  def list_sessions(sm: SM, us: US, user: CurrentUser):
81
93
  configs = sm.list_sessions_for_user(user.uid, is_admin=user.is_admin)
82
94
  user_store = _owner_store(us, user)
83
- return [_session_response(sm, cfg.session_id, user_store) for cfg in configs]
95
+ counts = sm.message_counts()
96
+ return [_session_response(sm, cfg.session_id, user_store, counts) for cfg in configs]
84
97
 
85
98
 
86
99
  @router.post("/", response_model=SessionResponse, status_code=201)
87
100
  async def create_session(body: SessionConfigRequest, sm: SM, us: US, user: CurrentUser):
88
- try:
89
- ct = ConnectionType(body.connection_type)
90
- except ValueError:
91
- raise HTTPException(status_code=422, detail=f"Invalid connection_type: {body.connection_type!r}")
92
- try:
93
- role = SessionRole(body.session_role)
94
- except ValueError:
95
- raise HTTPException(status_code=422, detail=f"Invalid session_role: {body.session_role!r}")
96
-
97
- cfg = SessionConfig(
98
- display_name=body.display_name,
99
- begin_string=body.begin_string,
100
- sender_comp_id=body.sender_comp_id,
101
- target_comp_id=body.target_comp_id,
102
- connection_type=ct,
103
- host=body.host,
104
- port=body.port,
105
- heartbeat_interval=body.heartbeat_interval,
106
- reconnect_interval=body.reconnect_interval,
107
- data_dictionary_path=body.data_dictionary_path,
108
- use_file_log=body.use_file_log,
109
- start_time=body.start_time,
110
- end_time=body.end_time,
111
- reset_on_logon=body.reset_on_logon,
112
- reset_on_logout=body.reset_on_logout,
113
- durable_seqnums=body.durable_seqnums,
114
- log_messages=body.log_messages,
115
- validate_inbound=body.validate_inbound,
116
- session_role=role,
117
- auto_ack=body.auto_ack,
118
- auto_ack_delay_ms=body.auto_ack_delay_ms,
119
- auto_fill=body.auto_fill,
120
- auto_fill_delay_ms=body.auto_fill_delay_ms,
121
- owner_uid=user.uid,
122
- )
101
+ cfg = _config_from_request(body, owner_uid=user.uid)
123
102
 
124
103
  try:
125
104
  session_id = sm.add_session(cfg)
@@ -139,56 +118,28 @@ async def create_session(body: SessionConfigRequest, sm: SM, us: US, user: Curre
139
118
  def import_sessions(body: list[SessionConfigRequest], sm: SM, us: US, user: CurrentUser):
140
119
  """
141
120
  Bulk-create sessions from an exported config file.
142
- Each entry is processed independently; invalid entries are skipped.
143
- 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.
144
124
  """
145
125
  created = []
146
126
  for item in body:
147
127
  try:
148
- ct = ConnectionType(item.connection_type)
149
- role = SessionRole(item.session_role)
150
- cfg = SessionConfig(
151
- display_name=item.display_name,
152
- begin_string=item.begin_string,
153
- sender_comp_id=item.sender_comp_id,
154
- target_comp_id=item.target_comp_id,
155
- connection_type=ct,
156
- host=item.host,
157
- port=item.port,
158
- heartbeat_interval=item.heartbeat_interval,
159
- reconnect_interval=item.reconnect_interval,
160
- data_dictionary_path=item.data_dictionary_path,
161
- use_file_log=item.use_file_log,
162
- start_time=item.start_time,
163
- end_time=item.end_time,
164
- reset_on_logon=item.reset_on_logon,
165
- reset_on_logout=item.reset_on_logout,
166
- durable_seqnums=item.durable_seqnums,
167
- log_messages=item.log_messages,
168
- validate_inbound=item.validate_inbound,
169
- session_role=role,
170
- auto_ack=item.auto_ack,
171
- auto_ack_delay_ms=item.auto_ack_delay_ms,
172
- auto_fill=item.auto_fill,
173
- auto_fill_delay_ms=item.auto_fill_delay_ms,
174
- owner_uid=user.uid,
175
- )
128
+ cfg = _config_from_request(item, owner_uid=user.uid)
176
129
  session_id = sm.add_session(cfg)
177
- created.append(_session_response(sm, session_id, _owner_store(us, user)))
178
- except Exception:
179
- 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)))
180
133
  return created
181
134
 
182
135
 
183
136
  @router.get("/{session_id}", response_model=SessionResponse)
184
- def get_session(session_id: str, sm: SM, us: US, user: CurrentUser):
185
- _check_ownership(sm, session_id, user)
137
+ def get_session(session_id: str, _cfg: OwnedSession, sm: SM, us: US, user: CurrentUser):
186
138
  return _session_response(sm, session_id, _owner_store(us, user))
187
139
 
188
140
 
189
141
  @router.patch("/{session_id}", response_model=SessionResponse)
190
- def update_session(session_id: str, body: UpdateSessionRequest, sm: SM, us: US, user: CurrentUser):
191
- _check_ownership(sm, session_id, user)
142
+ def update_session(session_id: str, body: UpdateSessionRequest, _cfg: OwnedSession, sm: SM, us: US, user: CurrentUser):
192
143
  try:
193
144
  sm.update_session(session_id, body)
194
145
  except RuntimeError as e:
@@ -199,8 +150,7 @@ def update_session(session_id: str, body: UpdateSessionRequest, sm: SM, us: US,
199
150
 
200
151
 
201
152
  @router.delete("/{session_id}", status_code=204)
202
- async def remove_session(session_id: str, sm: SM, user: CurrentUser):
203
- _check_ownership(sm, session_id, user)
153
+ async def remove_session(session_id: str, _cfg: OwnedSession, sm: SM):
204
154
  try:
205
155
  await sm.remove_session(session_id)
206
156
  except KeyError:
@@ -208,8 +158,7 @@ async def remove_session(session_id: str, sm: SM, user: CurrentUser):
208
158
 
209
159
 
210
160
  @router.post("/{session_id}/start", response_model=SessionResponse)
211
- async def start_session(session_id: str, sm: SM, us: US, user: CurrentUser):
212
- _check_ownership(sm, session_id, user)
161
+ async def start_session(session_id: str, _cfg: OwnedSession, sm: SM, us: US, user: CurrentUser):
213
162
  try:
214
163
  await sm.start_session(session_id)
215
164
  except RuntimeError as e:
@@ -220,25 +169,24 @@ async def start_session(session_id: str, sm: SM, us: US, user: CurrentUser):
220
169
  @router.post("/{session_id}/stop", response_model=SessionResponse)
221
170
  async def stop_session(
222
171
  session_id: str,
172
+ _cfg: OwnedSession,
223
173
  sm: SM,
224
174
  us: US,
225
175
  user: CurrentUser,
226
176
  force: bool = Query(default=False),
227
177
  ):
228
- _check_ownership(sm, session_id, user)
229
178
  await sm.stop_session(session_id, force=force)
230
179
  return _session_response(sm, session_id, _owner_store(us, user))
231
180
 
232
181
 
233
182
  @router.post("/{session_id}/send")
234
- async def send_message(session_id: str, body: SendMessageRequest, sm: SM, user: CurrentUser):
235
- _check_ownership(sm, session_id, user)
183
+ async def send_message(session_id: str, body: SendMessageRequest, _cfg: OwnedSession, sm: SM):
236
184
  from ...core.fix_builder import build_message_from_raw
237
- from datetime import datetime, timezone
185
+ from ...core.fix_time import utc_timestamp
238
186
 
239
187
  # Build group-aware using the session's DataDictionary so repeating groups
240
188
  # (e.g. NoAllocs rows) survive instead of collapsing to their last instance.
241
- 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()
242
190
  try:
243
191
  msg = build_message_from_raw(body.raw, sm.get_data_dictionary(session_id), transact_time=ts)
244
192
  except ValueError as e:
@@ -251,8 +199,7 @@ async def send_message(session_id: str, body: SendMessageRequest, sm: SM, user:
251
199
 
252
200
 
253
201
  @router.get("/{session_id}/seqnums", response_model=SeqNumResponse)
254
- def get_seqnums(session_id: str, sm: SM, user: CurrentUser):
255
- _check_ownership(sm, session_id, user)
202
+ def get_seqnums(session_id: str, _cfg: OwnedSession, sm: SM):
256
203
  try:
257
204
  sender, target = sm.get_seqnums(session_id)
258
205
  except KeyError:
@@ -261,8 +208,7 @@ def get_seqnums(session_id: str, sm: SM, user: CurrentUser):
261
208
 
262
209
 
263
210
  @router.patch("/{session_id}/seqnums", response_model=SeqNumResponse)
264
- def set_seqnums(session_id: str, body: SeqNumRequest, sm: SM, user: CurrentUser):
265
- _check_ownership(sm, session_id, user)
211
+ def set_seqnums(session_id: str, body: SeqNumRequest, _cfg: OwnedSession, sm: SM):
266
212
  if body.sender is None and body.target is None:
267
213
  raise HTTPException(status_code=422, detail="Provide sender, target, or both")
268
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")
@@ -1,5 +1,7 @@
1
+ import dataclasses
1
2
  import json
2
3
  import os
4
+ from enum import Enum
3
5
  from typing import Dict, List
4
6
 
5
7
  from .atomic_io import atomic_write_json
@@ -73,32 +75,21 @@ def _load_file(path: str) -> List[SessionConfig]:
73
75
  return configs
74
76
 
75
77
 
78
+ # owner_uid is canonical from the directory name, never from file contents.
79
+ _EXCLUDED_FIELDS = {"owner_uid"}
80
+
81
+
76
82
  def _config_to_dict(cfg: SessionConfig) -> dict:
77
- return {
78
- "session_id": cfg.session_id,
79
- "display_name": cfg.display_name,
80
- "begin_string": cfg.begin_string,
81
- "sender_comp_id": cfg.sender_comp_id,
82
- "target_comp_id": cfg.target_comp_id,
83
- "connection_type": cfg.connection_type.value,
84
- "host": cfg.host,
85
- "port": cfg.port,
86
- "heartbeat_interval": cfg.heartbeat_interval,
87
- "reconnect_interval": cfg.reconnect_interval,
88
- "data_dictionary_path": cfg.data_dictionary_path,
89
- "file_store_path": cfg.file_store_path,
90
- "file_log_path": cfg.file_log_path,
91
- "use_file_log": cfg.use_file_log,
92
- "start_time": cfg.start_time,
93
- "end_time": cfg.end_time,
94
- "reset_on_logon": cfg.reset_on_logon,
95
- "reset_on_logout": cfg.reset_on_logout,
96
- "durable_seqnums": cfg.durable_seqnums,
97
- "log_messages": cfg.log_messages,
98
- "session_role": cfg.session_role.value,
99
- "auto_ack": cfg.auto_ack,
100
- "auto_ack_delay_ms": cfg.auto_ack_delay_ms,
101
- "auto_fill": cfg.auto_fill,
102
- "auto_fill_delay_ms": cfg.auto_fill_delay_ms,
103
- # owner_uid intentionally omitted — canonical from directory name
104
- }
83
+ """Serialize every SessionConfig field (enums → values, owner_uid excluded).
84
+
85
+ Derived from dataclasses.fields rather than a hand-maintained key list so a
86
+ newly added field can't be silently dropped from persistence (validate_inbound
87
+ was lost this way and reverted to its default on every restart).
88
+ """
89
+ out = {}
90
+ for f in dataclasses.fields(SessionConfig):
91
+ if f.name in _EXCLUDED_FIELDS:
92
+ continue
93
+ value = getattr(cfg, f.name)
94
+ out[f.name] = value.value if isinstance(value, Enum) else value
95
+ return out