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.
- fixtureqa-0.1.0/LICENSE +21 -0
- fixtureqa-0.1.0/PKG-INFO +16 -0
- fixtureqa-0.1.0/README.md +96 -0
- fixtureqa-0.1.0/fixture/__init__.py +22 -0
- fixtureqa-0.1.0/fixture/__main__.py +161 -0
- fixtureqa-0.1.0/fixture/api/__init__.py +0 -0
- fixtureqa-0.1.0/fixture/api/app.py +95 -0
- fixtureqa-0.1.0/fixture/api/connection_manager.py +161 -0
- fixtureqa-0.1.0/fixture/api/deps.py +73 -0
- fixtureqa-0.1.0/fixture/api/routers/__init__.py +0 -0
- fixtureqa-0.1.0/fixture/api/routers/admin.py +178 -0
- fixtureqa-0.1.0/fixture/api/routers/auth.py +74 -0
- fixtureqa-0.1.0/fixture/api/routers/branding.py +33 -0
- fixtureqa-0.1.0/fixture/api/routers/fix_spec.py +41 -0
- fixtureqa-0.1.0/fixture/api/routers/messages.py +137 -0
- fixtureqa-0.1.0/fixture/api/routers/scenarios.py +65 -0
- fixtureqa-0.1.0/fixture/api/routers/sessions.py +272 -0
- fixtureqa-0.1.0/fixture/api/routers/setup.py +42 -0
- fixtureqa-0.1.0/fixture/api/routers/templates.py +36 -0
- fixtureqa-0.1.0/fixture/api/routers/ws.py +129 -0
- fixtureqa-0.1.0/fixture/api/schemas.py +289 -0
- fixtureqa-0.1.0/fixture/config/__init__.py +0 -0
- fixtureqa-0.1.0/fixture/core/__init__.py +0 -0
- fixtureqa-0.1.0/fixture/core/auth.py +68 -0
- fixtureqa-0.1.0/fixture/core/config_store.py +85 -0
- fixtureqa-0.1.0/fixture/core/events.py +22 -0
- fixtureqa-0.1.0/fixture/core/fix_application.py +67 -0
- fixtureqa-0.1.0/fixture/core/fix_parser.py +79 -0
- fixtureqa-0.1.0/fixture/core/fix_spec_parser.py +172 -0
- fixtureqa-0.1.0/fixture/core/fix_tags.py +297 -0
- fixtureqa-0.1.0/fixture/core/housekeeping.py +107 -0
- fixtureqa-0.1.0/fixture/core/message_log.py +115 -0
- fixtureqa-0.1.0/fixture/core/message_store.py +246 -0
- fixtureqa-0.1.0/fixture/core/models.py +87 -0
- fixtureqa-0.1.0/fixture/core/scenario_runner.py +331 -0
- fixtureqa-0.1.0/fixture/core/scenario_store.py +70 -0
- fixtureqa-0.1.0/fixture/core/session.py +278 -0
- fixtureqa-0.1.0/fixture/core/session_manager.py +173 -0
- fixtureqa-0.1.0/fixture/core/template_store.py +70 -0
- fixtureqa-0.1.0/fixture/core/user_store.py +186 -0
- fixtureqa-0.1.0/fixture/core/venue_responses.py +94 -0
- fixtureqa-0.1.0/fixture/fix_specs/FIX42.xml +2746 -0
- fixtureqa-0.1.0/fixture/fix_specs/FIX44.xml +6593 -0
- fixtureqa-0.1.0/fixture/server.py +37 -0
- fixtureqa-0.1.0/fixture/static/assets/ag-grid-_QKprVdm.js +326 -0
- fixtureqa-0.1.0/fixture/static/assets/index-B31-1dt-.css +1 -0
- fixtureqa-0.1.0/fixture/static/assets/index-CTsKxGdI.js +87 -0
- fixtureqa-0.1.0/fixture/static/assets/react-vendor-2eF0YfZT.js +1 -0
- fixtureqa-0.1.0/fixture/static/favicon.svg +12 -0
- fixtureqa-0.1.0/fixture/static/index.html +15 -0
- fixtureqa-0.1.0/fixture/ui/__init__.py +0 -0
- fixtureqa-0.1.0/fixtureqa.egg-info/PKG-INFO +16 -0
- fixtureqa-0.1.0/fixtureqa.egg-info/SOURCES.txt +57 -0
- fixtureqa-0.1.0/fixtureqa.egg-info/dependency_links.txt +1 -0
- fixtureqa-0.1.0/fixtureqa.egg-info/entry_points.txt +2 -0
- fixtureqa-0.1.0/fixtureqa.egg-info/requires.txt +10 -0
- fixtureqa-0.1.0/fixtureqa.egg-info/top_level.txt +1 -0
- fixtureqa-0.1.0/pyproject.toml +34 -0
- fixtureqa-0.1.0/setup.cfg +4 -0
fixtureqa-0.1.0/LICENSE
ADDED
|
@@ -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.
|
fixtureqa-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+

|
|
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
|