fixtureqa 0.1.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 (59) hide show
  1. fixtureqa-0.1.0/LICENSE +21 -0
  2. fixtureqa-0.1.0/PKG-INFO +16 -0
  3. fixtureqa-0.1.0/README.md +96 -0
  4. fixtureqa-0.1.0/fixture/__init__.py +22 -0
  5. fixtureqa-0.1.0/fixture/__main__.py +161 -0
  6. fixtureqa-0.1.0/fixture/api/__init__.py +0 -0
  7. fixtureqa-0.1.0/fixture/api/app.py +95 -0
  8. fixtureqa-0.1.0/fixture/api/connection_manager.py +161 -0
  9. fixtureqa-0.1.0/fixture/api/deps.py +73 -0
  10. fixtureqa-0.1.0/fixture/api/routers/__init__.py +0 -0
  11. fixtureqa-0.1.0/fixture/api/routers/admin.py +178 -0
  12. fixtureqa-0.1.0/fixture/api/routers/auth.py +74 -0
  13. fixtureqa-0.1.0/fixture/api/routers/branding.py +33 -0
  14. fixtureqa-0.1.0/fixture/api/routers/fix_spec.py +41 -0
  15. fixtureqa-0.1.0/fixture/api/routers/messages.py +137 -0
  16. fixtureqa-0.1.0/fixture/api/routers/scenarios.py +65 -0
  17. fixtureqa-0.1.0/fixture/api/routers/sessions.py +272 -0
  18. fixtureqa-0.1.0/fixture/api/routers/setup.py +42 -0
  19. fixtureqa-0.1.0/fixture/api/routers/templates.py +36 -0
  20. fixtureqa-0.1.0/fixture/api/routers/ws.py +129 -0
  21. fixtureqa-0.1.0/fixture/api/schemas.py +289 -0
  22. fixtureqa-0.1.0/fixture/config/__init__.py +0 -0
  23. fixtureqa-0.1.0/fixture/core/__init__.py +0 -0
  24. fixtureqa-0.1.0/fixture/core/auth.py +68 -0
  25. fixtureqa-0.1.0/fixture/core/config_store.py +85 -0
  26. fixtureqa-0.1.0/fixture/core/events.py +22 -0
  27. fixtureqa-0.1.0/fixture/core/fix_application.py +67 -0
  28. fixtureqa-0.1.0/fixture/core/fix_parser.py +79 -0
  29. fixtureqa-0.1.0/fixture/core/fix_spec_parser.py +172 -0
  30. fixtureqa-0.1.0/fixture/core/fix_tags.py +297 -0
  31. fixtureqa-0.1.0/fixture/core/housekeeping.py +107 -0
  32. fixtureqa-0.1.0/fixture/core/message_log.py +115 -0
  33. fixtureqa-0.1.0/fixture/core/message_store.py +246 -0
  34. fixtureqa-0.1.0/fixture/core/models.py +87 -0
  35. fixtureqa-0.1.0/fixture/core/scenario_runner.py +331 -0
  36. fixtureqa-0.1.0/fixture/core/scenario_store.py +70 -0
  37. fixtureqa-0.1.0/fixture/core/session.py +278 -0
  38. fixtureqa-0.1.0/fixture/core/session_manager.py +173 -0
  39. fixtureqa-0.1.0/fixture/core/template_store.py +70 -0
  40. fixtureqa-0.1.0/fixture/core/user_store.py +186 -0
  41. fixtureqa-0.1.0/fixture/core/venue_responses.py +94 -0
  42. fixtureqa-0.1.0/fixture/fix_specs/FIX42.xml +2746 -0
  43. fixtureqa-0.1.0/fixture/fix_specs/FIX44.xml +6593 -0
  44. fixtureqa-0.1.0/fixture/server.py +37 -0
  45. fixtureqa-0.1.0/fixture/static/assets/ag-grid-_QKprVdm.js +326 -0
  46. fixtureqa-0.1.0/fixture/static/assets/index-B31-1dt-.css +1 -0
  47. fixtureqa-0.1.0/fixture/static/assets/index-CTsKxGdI.js +87 -0
  48. fixtureqa-0.1.0/fixture/static/assets/react-vendor-2eF0YfZT.js +1 -0
  49. fixtureqa-0.1.0/fixture/static/favicon.svg +12 -0
  50. fixtureqa-0.1.0/fixture/static/index.html +15 -0
  51. fixtureqa-0.1.0/fixture/ui/__init__.py +0 -0
  52. fixtureqa-0.1.0/fixtureqa.egg-info/PKG-INFO +16 -0
  53. fixtureqa-0.1.0/fixtureqa.egg-info/SOURCES.txt +57 -0
  54. fixtureqa-0.1.0/fixtureqa.egg-info/dependency_links.txt +1 -0
  55. fixtureqa-0.1.0/fixtureqa.egg-info/entry_points.txt +2 -0
  56. fixtureqa-0.1.0/fixtureqa.egg-info/requires.txt +10 -0
  57. fixtureqa-0.1.0/fixtureqa.egg-info/top_level.txt +1 -0
  58. fixtureqa-0.1.0/pyproject.toml +34 -0
  59. fixtureqa-0.1.0/setup.cfg +4 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aidan Chisholm
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: fixtureqa
3
+ Version: 0.1.0
4
+ Summary: FIXture — FIX Protocol Testing Tool
5
+ Requires-Python: >=3.10
6
+ License-File: LICENSE
7
+ Requires-Dist: fixcore-engine
8
+ Requires-Dist: fastapi>=0.111.0
9
+ Requires-Dist: uvicorn[standard]>=0.29.0
10
+ Requires-Dist: websockets>=12.0
11
+ Requires-Dist: python-jose[cryptography]>=3.3.0
12
+ Requires-Dist: passlib[bcrypt]>=1.7.4
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest; extra == "dev"
15
+ Requires-Dist: httpx; extra == "dev"
16
+ Dynamic: license-file
@@ -0,0 +1,96 @@
1
+ # FIXture
2
+
3
+ A Python FIX protocol testing tool. Supports FIX 4.2 and 4.4, dynamic session creation at runtime (no restart required), and both initiator and acceptor roles per session.
4
+
5
+ ![FIXture](frontend/src/assets/logo-dark.svg)
6
+
7
+ ---
8
+
9
+ ## Features
10
+
11
+ - **FIX 4.2 and 4.4** support with bundled data dictionaries
12
+ - **Initiator and acceptor** roles, configurable per session
13
+ - **Dynamic sessions** — add or remove sessions without restarting
14
+ - **Multi-user auth** — JWT login, per-user session isolation, admin panel
15
+ - **Message log** — real-time IN/OUT stream with field breakdown, filtering, cursor pagination
16
+ - **Compose & send** — paste raw FIX or use message templates; auto-inject tag 60 and primary ID
17
+ - **Templates** — create/edit reusable message templates with FIX spec field definitions
18
+ - **Scenarios** — automated send/expect/delay test scripts with live pass/fail results
19
+ - **Venue simulation** — auto-ack and auto-fill responses for inbound NewOrderSingles
20
+ - **Log analysis** — Overview stats, Throughput chart, Reject detail, Latency measurement (p50/p95/p99)
21
+ - **Export** — download session messages as CSV or raw FIX
22
+ - **Sequence numbers** — view and reset TX/RX seqnums per session
23
+ - **Persistent storage** — SQLite message store, per-user session configs, survives restarts
24
+ - **Web UI** — dark/light theme React frontend
25
+
26
+ ## Requirements
27
+
28
+ - Python 3.10+
29
+ - [quickfix](https://pypi.org/project/quickfix/) 1.15.1
30
+ - Node.js 20+ (frontend dev/build only)
31
+
32
+ ## Quick start — Docker (recommended)
33
+
34
+ ```bash
35
+ docker compose up
36
+ ```
37
+
38
+ Open http://localhost:8000. On first run, the setup page will prompt you to create an admin account.
39
+
40
+ ## Quick start — local
41
+
42
+ ```bash
43
+ # Install Python dependencies
44
+ pip install -e ".[dev]"
45
+
46
+ # Build the frontend (once, or after UI changes)
47
+ cd frontend && npm install && npm run build && cd ..
48
+
49
+ # Run
50
+ python -m fixture
51
+ ```
52
+
53
+ Open http://localhost:8000.
54
+
55
+ ## Development
56
+
57
+ ```bash
58
+ ./dev.sh # activates .venv, starts backend (:8000) + Vite dev server (:5173)
59
+ ```
60
+
61
+ Frontend dev server runs at http://localhost:5173 and proxies API/WebSocket calls to `:8000`.
62
+
63
+ ## Options
64
+
65
+ ```
66
+ python -m fixture [--host HOST] [--port PORT] [--log-level LEVEL] [--log-file PATH] [--data-dir PATH] [--db-path PATH]
67
+ ```
68
+
69
+ | Flag | Default | Description |
70
+ |---|---|---|
71
+ | `--host` | `0.0.0.0` | Bind address |
72
+ | `--port` | `8000` | HTTP port |
73
+ | `--log-level` | `info` | Log level (debug/info/warning/error) |
74
+ | `--log-file` | *(none)* | Write rotating log file (10 MB × 5 backups) |
75
+ | `--data-dir` | `./data` | Directory for per-user session configs and message DB |
76
+ | `--db-path` | `./users.db` | SQLite user accounts database |
77
+
78
+ ## Architecture
79
+
80
+ One QuickFIX engine per session, each running in its own daemon thread. This enables dynamic session creation without restarting.
81
+
82
+ ```
83
+ SessionManager
84
+ └── Session (one per FIX session)
85
+ ├── FixApplication ← QuickFIX callbacks
86
+ ├── SocketInitiator or SocketAcceptor
87
+ └── daemon thread
88
+ ```
89
+
90
+ Events flow upward via an observer pattern to the FastAPI WebSocket layer and into the React UI.
91
+
92
+ ## Running tests
93
+
94
+ ```bash
95
+ pytest tests/
96
+ ```
@@ -0,0 +1,22 @@
1
+ from .core.session_manager import SessionManager
2
+ from .core.models import SessionConfig, SessionState, SessionStatus, ConnectionType
3
+ from .core.events import SessionEvent, EventType
4
+ from .core.message_log import MessageLog, LogEntry
5
+ from .core.fix_parser import parse_raw, pretty_print
6
+ from .core.fix_tags import tag_name, msg_type_name
7
+
8
+ __all__ = [
9
+ "SessionManager",
10
+ "SessionConfig",
11
+ "SessionState",
12
+ "SessionStatus",
13
+ "ConnectionType",
14
+ "SessionEvent",
15
+ "EventType",
16
+ "MessageLog",
17
+ "LogEntry",
18
+ "parse_raw",
19
+ "pretty_print",
20
+ "tag_name",
21
+ "msg_type_name",
22
+ ]
@@ -0,0 +1,161 @@
1
+ """
2
+ python -m fixture [--host HOST] [--port PORT]
3
+ """
4
+
5
+ import argparse
6
+ import json
7
+ import logging
8
+ import logging.handlers
9
+ import os
10
+ import time
11
+
12
+ from .core.session_manager import SessionManager
13
+ from .core.config_store import ConfigStore
14
+ from .server import start_server
15
+
16
+ logger = logging.getLogger("fixture")
17
+
18
+
19
+ def _configure_logging(level_name: str, log_file: str | None = None) -> None:
20
+ """
21
+ Configure the root logger and (optionally) a rotating file handler.
22
+
23
+ Passing log_config=None to uvicorn.Config prevents uvicorn from
24
+ overriding this setup, so uvicorn's own loggers propagate here too.
25
+ """
26
+ level = getattr(logging, level_name.upper(), logging.INFO)
27
+ fmt = "%(asctime)s %(levelname)-8s %(name)s — %(message)s"
28
+ datefmt = "%Y-%m-%d %H:%M:%S"
29
+ formatter = logging.Formatter(fmt, datefmt)
30
+
31
+ root = logging.getLogger()
32
+ root.setLevel(level)
33
+
34
+ # Stdout handler
35
+ sh = logging.StreamHandler()
36
+ sh.setFormatter(formatter)
37
+ root.addHandler(sh)
38
+
39
+ # Optional rotating file handler
40
+ if log_file:
41
+ fh = logging.handlers.RotatingFileHandler(
42
+ log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
43
+ )
44
+ fh.setFormatter(formatter)
45
+ root.addHandler(fh)
46
+
47
+
48
+ def _migrate_legacy(sessions_file: str, data_dir: str, db_path: str) -> None:
49
+ """
50
+ One-time migration: read a legacy sessions.json, group by owner_uid,
51
+ write to data_dir/<uid>/sessions.json, then rename the legacy file.
52
+ Sessions with no owner_uid are assigned to the platform_admin.
53
+ """
54
+ if not os.path.isfile(sessions_file):
55
+ return
56
+
57
+ # Check if data_dir already has any sessions (migration already done)
58
+ if os.path.isdir(data_dir) and any(
59
+ os.path.isfile(os.path.join(data_dir, uid, "sessions.json"))
60
+ for uid in os.listdir(data_dir)
61
+ if os.path.isdir(os.path.join(data_dir, uid))
62
+ ):
63
+ return
64
+
65
+ logger.info("Migrating %s → %s/<uid>/sessions.json ...", sessions_file, data_dir)
66
+
67
+ try:
68
+ with open(sessions_file) as f:
69
+ data = json.load(f)
70
+ except (OSError, json.JSONDecodeError) as e:
71
+ logger.warning("Migration skipped: could not read %s: %s", sessions_file, e)
72
+ return
73
+
74
+ # Find the platform_admin uid to assign orphaned sessions
75
+ fallback_uid = _get_platform_admin_uid(db_path)
76
+
77
+ by_user: dict = {}
78
+ for entry in data:
79
+ uid = entry.get("owner_uid") or fallback_uid
80
+ if not uid:
81
+ continue
82
+ # Remove owner_uid from stored JSON (canonical from directory name)
83
+ entry.pop("owner_uid", None)
84
+ by_user.setdefault(uid, []).append(entry)
85
+
86
+ for uid, entries in by_user.items():
87
+ user_dir = os.path.join(data_dir, uid)
88
+ os.makedirs(user_dir, exist_ok=True)
89
+ path = os.path.join(user_dir, "sessions.json")
90
+ tmp = path + ".tmp"
91
+ with open(tmp, "w") as f:
92
+ json.dump(entries, f, indent=2)
93
+ os.replace(tmp, path)
94
+ logger.info(" Wrote %d session(s) for user %s", len(entries), uid)
95
+
96
+ migrated = sessions_file + ".migrated"
97
+ os.rename(sessions_file, migrated)
98
+ logger.info("Migration complete. Legacy file renamed to %s", migrated)
99
+
100
+
101
+ def _get_platform_admin_uid(db_path: str) -> str:
102
+ """Return the uid of the first platform_admin, or '' if not found."""
103
+ try:
104
+ import sqlite3
105
+ conn = sqlite3.connect(db_path)
106
+ row = conn.execute(
107
+ "SELECT uid FROM users WHERE role='platform_admin' ORDER BY created_at LIMIT 1"
108
+ ).fetchone()
109
+ conn.close()
110
+ return row[0] if row else ""
111
+ except Exception:
112
+ return ""
113
+
114
+
115
+ def main():
116
+ parser = argparse.ArgumentParser(description="FIXture FIX testing tool")
117
+ parser.add_argument("--host", default="0.0.0.0")
118
+ parser.add_argument("--port", type=int, default=8000)
119
+ parser.add_argument("--log-level", default="info")
120
+ parser.add_argument("--log-file", default=None,
121
+ help="Path for rotating log file (default: <data-dir>/fixture.log)")
122
+ parser.add_argument("--data-dir", default="./data",
123
+ help="Directory for per-user session data")
124
+ parser.add_argument("--db-path", default="./users.db",
125
+ help="Path to SQLite database for user accounts")
126
+ args = parser.parse_args()
127
+
128
+ # Ensure data dir exists before opening the log file inside it
129
+ os.makedirs(args.data_dir, exist_ok=True)
130
+
131
+ # Default log file lives next to the other data files
132
+ log_file = args.log_file or os.path.join(args.data_dir, "fixture.log")
133
+
134
+ # Configure logging before anything else so all modules inherit the setup
135
+ _configure_logging(args.log_level, log_file)
136
+
137
+ # Auto-migrate legacy sessions.json if present
138
+ _migrate_legacy("./sessions.json", args.data_dir, args.db_path)
139
+
140
+ store = ConfigStore(args.data_dir)
141
+ msg_db_path = os.path.join(args.data_dir, "messages.db")
142
+ sm = SessionManager(config_store=store, msg_db_path=msg_db_path)
143
+ sm.load_persisted()
144
+ server = start_server(sm, host=args.host, port=args.port, log_level=args.log_level,
145
+ db_path=args.db_path, msg_db_path=msg_db_path, log_dir="./log",
146
+ data_dir=args.data_dir)
147
+
148
+ logger.info("FIXture running at http://%s:%s", args.host, args.port)
149
+
150
+ try:
151
+ while not server.should_exit:
152
+ time.sleep(0.5)
153
+ except KeyboardInterrupt:
154
+ pass
155
+ finally:
156
+ server.should_exit = True
157
+ logger.info("Shutting down...")
158
+
159
+
160
+ if __name__ == "__main__":
161
+ main()
File without changes
@@ -0,0 +1,95 @@
1
+ import os
2
+ from contextlib import asynccontextmanager
3
+
4
+ from fastapi import FastAPI
5
+ from fastapi.staticfiles import StaticFiles
6
+ from fastapi.responses import FileResponse
7
+
8
+ from ..core.housekeeping import HousekeepingService
9
+ from ..core.session_manager import SessionManager
10
+ from ..core.scenario_store import ScenarioStore
11
+ from ..core.scenario_runner import ScenarioRunner
12
+ from ..core.template_store import TemplateStore
13
+ from ..core.user_store import UserStore
14
+ from .connection_manager import ConnectionManager
15
+ from .routers import sessions, messages, ws, auth, setup, admin, fix_spec, templates, scenarios, branding
16
+
17
+
18
+ @asynccontextmanager
19
+ async def lifespan(app: FastAPI):
20
+ # Register event bridge with SessionManager
21
+ sm: SessionManager = app.state.session_manager
22
+ cm: ConnectionManager = app.state.connection_manager
23
+ sm.subscribe(cm.sync_event_handler)
24
+ # Start background housekeeping thread
25
+ app.state.housekeeping.start()
26
+ yield
27
+ sm.unsubscribe(cm.sync_event_handler)
28
+ sm.close() # flush SQLite writer
29
+
30
+
31
+ def create_app(
32
+ session_manager: SessionManager,
33
+ db_path: str = "./users.db",
34
+ msg_db_path: str = "./data/messages.db",
35
+ log_dir: str = "./log",
36
+ data_dir: str = "./data",
37
+ ) -> FastAPI:
38
+ app = FastAPI(
39
+ title="FIXture",
40
+ description="FIX Protocol Testing Tool",
41
+ version="0.1.0",
42
+ lifespan=lifespan,
43
+ )
44
+
45
+ # Store singletons on app state (injected via Depends)
46
+ app.state.session_manager = session_manager
47
+ app.state.connection_manager = ConnectionManager()
48
+ user_store = UserStore(db_path)
49
+ app.state.user_store = user_store
50
+ app.state.housekeeping = HousekeepingService(msg_db_path, log_dir, user_store)
51
+ app.state.template_store = TemplateStore(data_dir)
52
+ app.state.scenario_store = ScenarioStore(data_dir)
53
+ conn_manager: ConnectionManager = app.state.connection_manager
54
+ app.state.scenario_runner = ScenarioRunner(
55
+ session_manager, app.state.template_store, conn_manager.broadcast_scenario_event
56
+ )
57
+
58
+ # API routers
59
+ app.include_router(auth.router, prefix="/api/auth")
60
+ app.include_router(setup.router, prefix="/api/setup")
61
+ app.include_router(admin.router, prefix="/api/admin")
62
+ app.include_router(sessions.router, prefix="/api/sessions")
63
+ app.include_router(messages.router, prefix="/api/sessions")
64
+ app.include_router(fix_spec.router, prefix="/api/fix-spec")
65
+ app.include_router(templates.router, prefix="/api/templates")
66
+ app.include_router(scenarios.router, prefix="/api/scenarios")
67
+ app.include_router(branding.router, prefix="/api")
68
+ app.include_router(ws.router)
69
+
70
+ # Serve built React app from fixture/static/
71
+ static_dir = os.path.join(os.path.dirname(__file__), "..", "static")
72
+ static_dir = os.path.abspath(static_dir)
73
+
74
+ if os.path.isdir(static_dir) and os.listdir(static_dir):
75
+ # Serve hashed JS/CSS bundles
76
+ app.mount("/assets", StaticFiles(directory=os.path.join(static_dir, "assets")), name="assets")
77
+
78
+ # Serve favicon explicitly
79
+ @app.get("/favicon.svg")
80
+ async def favicon():
81
+ return FileResponse(os.path.join(static_dir, "favicon.svg"))
82
+
83
+ # Catch-all: return index.html for all SPA routes (/login, /dashboard, etc.)
84
+ @app.get("/{path_name:path}")
85
+ async def serve_spa(path_name: str):
86
+ return FileResponse(os.path.join(static_dir, "index.html"))
87
+ else:
88
+ # Frontend not built yet — serve a placeholder
89
+ @app.get("/")
90
+ def root():
91
+ return {
92
+ "message": "FIXture API is running. Build the frontend with: cd frontend && npm run build"
93
+ }
94
+
95
+ return app
@@ -0,0 +1,161 @@
1
+ """
2
+ Bridges the SessionManager event system to async WebSocket clients.
3
+
4
+ fixcore callbacks fire on the asyncio event loop — no thread-crossing needed.
5
+ """
6
+
7
+ import asyncio
8
+ from datetime import datetime, timezone
9
+
10
+ from fastapi import WebSocket
11
+ from starlette.websockets import WebSocketState
12
+
13
+ from ..core.events import SessionEvent, EventType
14
+ from ..core.message_log import LogEntry
15
+
16
+
17
+ def _now_iso() -> str:
18
+ return datetime.now(tz=timezone.utc).isoformat()
19
+
20
+
21
+ def _serialize_event(event: SessionEvent) -> dict:
22
+ p = event.payload
23
+ if event.event_type == EventType.SESSION_STATUS_CHANGED:
24
+ return {
25
+ "type": "session_status",
26
+ "session_id": event.session_id,
27
+ "status": p.get("status"),
28
+ "status_changed_at": p.get("status_changed_at"),
29
+ "last_heartbeat_at": p.get("last_heartbeat_at"),
30
+ "ts": _now_iso(),
31
+ }
32
+ elif event.event_type in (EventType.MESSAGE_RECEIVED, EventType.MESSAGE_SENT):
33
+ return {
34
+ "type": "message",
35
+ "session_id": event.session_id,
36
+ "direction": "IN" if event.event_type == EventType.MESSAGE_RECEIVED else "OUT",
37
+ "admin": p.get("admin", False),
38
+ "seq_num": p.get("seq_num", 0),
39
+ "msg_type": p.get("msg_type", ""),
40
+ "msg_type_name": p.get("msg_type_name", ""),
41
+ "ts": _now_iso(),
42
+ "fields": {str(k): v for k, v in p.get("fields", {}).items()},
43
+ "raw": p.get("raw", ""),
44
+ }
45
+ elif event.event_type == EventType.ENGINE_ERROR:
46
+ return {
47
+ "type": "engine_error",
48
+ "session_id": event.session_id,
49
+ "error": p.get("error", ""),
50
+ "ts": _now_iso(),
51
+ }
52
+ return {"type": "unknown", "session_id": event.session_id, "ts": _now_iso()}
53
+
54
+
55
+ class _ConnInfo:
56
+ __slots__ = ("uid", "is_admin")
57
+
58
+ def __init__(self, uid: str, is_admin: bool):
59
+ self.uid = uid
60
+ self.is_admin = is_admin
61
+
62
+
63
+ class ConnectionManager:
64
+ """
65
+ Tracks all active WebSocket connections and fans out events to them.
66
+
67
+ Connections can subscribe to a specific session_id or to all sessions ("*").
68
+ Each connection carries a uid and is_admin flag for broadcast filtering.
69
+ """
70
+
71
+ def __init__(self):
72
+ # session_id (or "*") -> dict[WebSocket, _ConnInfo]
73
+ self._clients: dict[str, dict[WebSocket, _ConnInfo]] = {}
74
+
75
+ # ------------------------------------------------------------------
76
+ # Called from async context (WS handler)
77
+ # ------------------------------------------------------------------
78
+
79
+ async def connect(self, websocket: WebSocket, session_id: str = "*",
80
+ uid: str = "", is_admin: bool = False) -> None:
81
+ await websocket.accept()
82
+ with self._lock:
83
+ self._clients.setdefault(session_id, {})[websocket] = _ConnInfo(uid, is_admin)
84
+
85
+ def disconnect(self, websocket: WebSocket, session_id: str = "*") -> None:
86
+ with self._lock:
87
+ clients = self._clients.get(session_id, {})
88
+ clients.pop(websocket, None)
89
+
90
+ # ------------------------------------------------------------------
91
+ # Called from the asyncio event loop (fixcore callbacks / scenario runner)
92
+ # ------------------------------------------------------------------
93
+
94
+ def sync_event_handler(self, event: SessionEvent) -> None:
95
+ """Registered with SessionManager.subscribe(). Called on the event loop."""
96
+ payload = _serialize_event(event)
97
+ asyncio.create_task(self._broadcast(event.session_id, payload, event.owner_uid))
98
+
99
+ def broadcast_scenario_event(self, uid: str, data: dict) -> None:
100
+ """Schedule delivery to all WS connections for uid."""
101
+ asyncio.create_task(self._broadcast_to_user(uid, data))
102
+
103
+ # ------------------------------------------------------------------
104
+ # Internal async broadcast
105
+ # ------------------------------------------------------------------
106
+
107
+ async def _broadcast(self, session_id: str, payload: dict, owner_uid: str) -> None:
108
+ with self._lock:
109
+ # Specific-session subscribers
110
+ specific = dict(self._clients.get(session_id, {}))
111
+ # Wildcard subscribers
112
+ wildcard = dict(self._clients.get("*", {}))
113
+
114
+ # Build target set: connections that may receive this event
115
+ # A connection receives the event if:
116
+ # - it is an admin, OR
117
+ # - its uid matches the session owner
118
+ targets: dict[WebSocket, _ConnInfo] = {}
119
+ for ws, info in {**specific, **wildcard}.items():
120
+ if info.is_admin or info.uid == owner_uid:
121
+ targets[ws] = info
122
+
123
+ dead: list[tuple[WebSocket, str]] = []
124
+ for ws in targets:
125
+ try:
126
+ if ws.client_state == WebSocketState.CONNECTED:
127
+ await ws.send_json(payload)
128
+ except Exception:
129
+ with self._lock:
130
+ for key, sockets in self._clients.items():
131
+ if ws in sockets:
132
+ dead.append((ws, key))
133
+
134
+ with self._lock:
135
+ for ws, key in dead:
136
+ self._clients.get(key, {}).pop(ws, None)
137
+
138
+ async def _broadcast_to_user(self, uid: str, data: dict) -> None:
139
+ """Send data to all WS connections belonging to uid."""
140
+ with self._lock:
141
+ all_clients = {}
142
+ for sockets in self._clients.values():
143
+ for ws, info in sockets.items():
144
+ all_clients[ws] = info
145
+
146
+ dead: list[tuple[WebSocket, str]] = []
147
+ for ws, info in all_clients.items():
148
+ if info.uid != uid and not info.is_admin:
149
+ continue
150
+ try:
151
+ if ws.client_state == WebSocketState.CONNECTED:
152
+ await ws.send_json(data)
153
+ except Exception:
154
+ with self._lock:
155
+ for key, sockets in self._clients.items():
156
+ if ws in sockets:
157
+ dead.append((ws, key))
158
+
159
+ with self._lock:
160
+ for ws, key in dead:
161
+ self._clients.get(key, {}).pop(ws, None)
@@ -0,0 +1,73 @@
1
+ from fastapi import Depends, HTTPException, status
2
+ from fastapi.security import OAuth2PasswordBearer
3
+ from jose import JWTError
4
+ from starlette.requests import HTTPConnection
5
+
6
+ from ..core.session_manager import SessionManager
7
+ from ..core.user_store import User, UserStore
8
+ from ..core.auth import decode_token
9
+ from ..core.housekeeping import HousekeepingService
10
+ from ..core.template_store import TemplateStore
11
+ from ..core.scenario_store import ScenarioStore
12
+ from ..core.scenario_runner import ScenarioRunner
13
+ from .connection_manager import ConnectionManager
14
+
15
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
16
+
17
+
18
+ def get_session_manager(request: HTTPConnection) -> SessionManager:
19
+ return request.app.state.session_manager
20
+
21
+
22
+ def get_conn_manager(request: HTTPConnection) -> ConnectionManager:
23
+ return request.app.state.connection_manager
24
+
25
+
26
+ def get_user_store(request: HTTPConnection) -> UserStore:
27
+ return request.app.state.user_store
28
+
29
+
30
+ def get_current_user(
31
+ request: HTTPConnection,
32
+ token: str = Depends(oauth2_scheme),
33
+ ) -> User:
34
+ credentials_exception = HTTPException(
35
+ status_code=status.HTTP_401_UNAUTHORIZED,
36
+ detail="Invalid or expired token",
37
+ headers={"WWW-Authenticate": "Bearer"},
38
+ )
39
+ try:
40
+ payload = decode_token(token)
41
+ uid: str = payload.get("sub", "")
42
+ if not uid:
43
+ raise credentials_exception
44
+ except JWTError:
45
+ raise credentials_exception
46
+
47
+ store: UserStore = request.app.state.user_store
48
+ user = store.get_by_uid(uid)
49
+ if user is None or not user.is_active:
50
+ raise credentials_exception
51
+ return user
52
+
53
+
54
+ def require_platform_admin(user: User = Depends(get_current_user)) -> User:
55
+ if user.role != "platform_admin":
56
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
57
+ return user
58
+
59
+
60
+ def get_housekeeping(request: HTTPConnection) -> HousekeepingService:
61
+ return request.app.state.housekeeping
62
+
63
+
64
+ def get_template_store(request: HTTPConnection) -> TemplateStore:
65
+ return request.app.state.template_store
66
+
67
+
68
+ def get_scenario_store(request: HTTPConnection) -> ScenarioStore:
69
+ return request.app.state.scenario_store
70
+
71
+
72
+ def get_scenario_runner(request: HTTPConnection) -> ScenarioRunner:
73
+ return request.app.state.scenario_runner
File without changes