fixtureqa 0.1.0__py3-none-any.whl

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 (54) hide show
  1. fixture/__init__.py +22 -0
  2. fixture/__main__.py +161 -0
  3. fixture/api/__init__.py +0 -0
  4. fixture/api/app.py +95 -0
  5. fixture/api/connection_manager.py +161 -0
  6. fixture/api/deps.py +73 -0
  7. fixture/api/routers/__init__.py +0 -0
  8. fixture/api/routers/admin.py +178 -0
  9. fixture/api/routers/auth.py +74 -0
  10. fixture/api/routers/branding.py +33 -0
  11. fixture/api/routers/fix_spec.py +41 -0
  12. fixture/api/routers/messages.py +137 -0
  13. fixture/api/routers/scenarios.py +65 -0
  14. fixture/api/routers/sessions.py +272 -0
  15. fixture/api/routers/setup.py +42 -0
  16. fixture/api/routers/templates.py +36 -0
  17. fixture/api/routers/ws.py +129 -0
  18. fixture/api/schemas.py +289 -0
  19. fixture/config/__init__.py +0 -0
  20. fixture/core/__init__.py +0 -0
  21. fixture/core/auth.py +68 -0
  22. fixture/core/config_store.py +85 -0
  23. fixture/core/events.py +22 -0
  24. fixture/core/fix_application.py +67 -0
  25. fixture/core/fix_parser.py +79 -0
  26. fixture/core/fix_spec_parser.py +172 -0
  27. fixture/core/fix_tags.py +297 -0
  28. fixture/core/housekeeping.py +107 -0
  29. fixture/core/message_log.py +115 -0
  30. fixture/core/message_store.py +246 -0
  31. fixture/core/models.py +87 -0
  32. fixture/core/scenario_runner.py +331 -0
  33. fixture/core/scenario_store.py +70 -0
  34. fixture/core/session.py +278 -0
  35. fixture/core/session_manager.py +173 -0
  36. fixture/core/template_store.py +70 -0
  37. fixture/core/user_store.py +186 -0
  38. fixture/core/venue_responses.py +94 -0
  39. fixture/fix_specs/FIX42.xml +2746 -0
  40. fixture/fix_specs/FIX44.xml +6593 -0
  41. fixture/server.py +37 -0
  42. fixture/static/assets/ag-grid-_QKprVdm.js +326 -0
  43. fixture/static/assets/index-B31-1dt-.css +1 -0
  44. fixture/static/assets/index-CTsKxGdI.js +87 -0
  45. fixture/static/assets/react-vendor-2eF0YfZT.js +1 -0
  46. fixture/static/favicon.svg +12 -0
  47. fixture/static/index.html +15 -0
  48. fixture/ui/__init__.py +0 -0
  49. fixtureqa-0.1.0.dist-info/METADATA +16 -0
  50. fixtureqa-0.1.0.dist-info/RECORD +54 -0
  51. fixtureqa-0.1.0.dist-info/WHEEL +5 -0
  52. fixtureqa-0.1.0.dist-info/entry_points.txt +2 -0
  53. fixtureqa-0.1.0.dist-info/licenses/LICENSE +21 -0
  54. fixtureqa-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,272 @@
1
+ from typing import Annotated, Optional
2
+ from fastapi import APIRouter, Depends, HTTPException, Query
3
+
4
+ from ...core.session_manager import SessionManager
5
+ from ...core.models import SessionConfig, ConnectionType, SessionRole
6
+ from ...core.user_store import User, UserStore
7
+ from ..schemas import SessionConfigRequest, SessionResponse, UpdateSessionRequest, SendMessageRequest, SeqNumRequest, SeqNumResponse
8
+ from ..deps import get_session_manager, get_current_user, get_user_store
9
+
10
+ router = APIRouter(tags=["sessions"])
11
+
12
+ SM = Annotated[SessionManager, Depends(get_session_manager)]
13
+ US = Annotated[UserStore, Depends(get_user_store)]
14
+ CurrentUser = Annotated[User, Depends(get_current_user)]
15
+
16
+
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:
20
+ raise HTTPException(status_code=404, detail="Session not found")
21
+ cfg = cfg_list[0]
22
+ state = sm.get_state(session_id)
23
+ log = state.message_log
24
+ owner_username: Optional[str] = None
25
+ if us is not None:
26
+ owner = us.get_by_uid(cfg.owner_uid)
27
+ owner_username = owner.username if owner else None
28
+ return SessionResponse(
29
+ session_id=cfg.session_id,
30
+ display_name=cfg.display_name,
31
+ begin_string=cfg.begin_string,
32
+ sender_comp_id=cfg.sender_comp_id,
33
+ target_comp_id=cfg.target_comp_id,
34
+ connection_type=cfg.connection_type.value,
35
+ host=cfg.host,
36
+ port=cfg.port,
37
+ heartbeat_interval=cfg.heartbeat_interval,
38
+ reconnect_interval=cfg.reconnect_interval,
39
+ use_file_log=cfg.use_file_log,
40
+ start_time=cfg.start_time,
41
+ end_time=cfg.end_time,
42
+ reset_on_logon=cfg.reset_on_logon,
43
+ reset_on_logout=cfg.reset_on_logout,
44
+ session_role=cfg.session_role.value,
45
+ auto_ack=cfg.auto_ack,
46
+ auto_ack_delay_ms=cfg.auto_ack_delay_ms,
47
+ auto_fill=cfg.auto_fill,
48
+ auto_fill_delay_ms=cfg.auto_fill_delay_ms,
49
+ data_dictionary_path=cfg.data_dictionary_path,
50
+ status=state.status.name,
51
+ last_sent_seq_num=state.last_sent_seq_num,
52
+ last_recv_seq_num=state.last_recv_seq_num,
53
+ message_count=len(log) if log else 0,
54
+ status_changed_at=state.status_changed_at,
55
+ last_heartbeat_at=state.last_heartbeat_at,
56
+ owner_username=owner_username,
57
+ )
58
+
59
+
60
+ def _check_ownership(sm: SessionManager, session_id: str, user: User) -> SessionConfig:
61
+ """Return the SessionConfig if the user owns it (or is admin). Raises 404/403 otherwise."""
62
+ configs = sm.list_sessions()
63
+ cfg = next((c for c in configs if c.session_id == session_id), None)
64
+ if cfg is None:
65
+ raise HTTPException(status_code=404, detail="Session not found")
66
+ if not user.is_admin and cfg.owner_uid != user.uid:
67
+ raise HTTPException(status_code=403, detail="Access denied")
68
+ return cfg
69
+
70
+
71
+ @router.get("/", response_model=list[SessionResponse])
72
+ def list_sessions(sm: SM, us: US, user: CurrentUser):
73
+ configs = sm.list_sessions_for_user(user.uid, is_admin=user.is_admin)
74
+ user_store = us if user.is_admin else None
75
+ return [_session_response(sm, cfg.session_id, user_store) for cfg in configs]
76
+
77
+
78
+ @router.post("/", response_model=SessionResponse, status_code=201)
79
+ async def create_session(body: SessionConfigRequest, sm: SM, user: CurrentUser):
80
+ try:
81
+ ct = ConnectionType(body.connection_type)
82
+ except ValueError:
83
+ raise HTTPException(status_code=422, detail=f"Invalid connection_type: {body.connection_type!r}")
84
+ try:
85
+ role = SessionRole(body.session_role)
86
+ except ValueError:
87
+ raise HTTPException(status_code=422, detail=f"Invalid session_role: {body.session_role!r}")
88
+
89
+ cfg = SessionConfig(
90
+ display_name=body.display_name,
91
+ begin_string=body.begin_string,
92
+ sender_comp_id=body.sender_comp_id,
93
+ target_comp_id=body.target_comp_id,
94
+ connection_type=ct,
95
+ host=body.host,
96
+ port=body.port,
97
+ heartbeat_interval=body.heartbeat_interval,
98
+ reconnect_interval=body.reconnect_interval,
99
+ data_dictionary_path=body.data_dictionary_path,
100
+ use_file_log=body.use_file_log,
101
+ start_time=body.start_time,
102
+ end_time=body.end_time,
103
+ reset_on_logon=body.reset_on_logon,
104
+ reset_on_logout=body.reset_on_logout,
105
+ session_role=role,
106
+ auto_ack=body.auto_ack,
107
+ auto_ack_delay_ms=body.auto_ack_delay_ms,
108
+ auto_fill=body.auto_fill,
109
+ auto_fill_delay_ms=body.auto_fill_delay_ms,
110
+ owner_uid=user.uid,
111
+ )
112
+
113
+ try:
114
+ session_id = sm.add_session(cfg)
115
+ except (ValueError, KeyError) as e:
116
+ raise HTTPException(status_code=422, detail=str(e))
117
+
118
+ if body.auto_start:
119
+ try:
120
+ await sm.start_session(session_id)
121
+ except Exception as e:
122
+ raise HTTPException(status_code=500, detail=f"Session added but failed to start: {e}")
123
+
124
+ return _session_response(sm, session_id)
125
+
126
+
127
+ @router.post("/import", response_model=list[SessionResponse], status_code=201)
128
+ def import_sessions(body: list[SessionConfigRequest], sm: SM, user: CurrentUser):
129
+ """
130
+ Bulk-create sessions from an exported config file.
131
+ Each entry is processed independently; invalid entries are skipped.
132
+ Returns the list of successfully created sessions.
133
+ """
134
+ created = []
135
+ for item in body:
136
+ try:
137
+ ct = ConnectionType(item.connection_type)
138
+ role = SessionRole(item.session_role)
139
+ cfg = SessionConfig(
140
+ display_name=item.display_name,
141
+ begin_string=item.begin_string,
142
+ sender_comp_id=item.sender_comp_id,
143
+ target_comp_id=item.target_comp_id,
144
+ connection_type=ct,
145
+ host=item.host,
146
+ port=item.port,
147
+ heartbeat_interval=item.heartbeat_interval,
148
+ reconnect_interval=item.reconnect_interval,
149
+ data_dictionary_path=item.data_dictionary_path,
150
+ use_file_log=item.use_file_log,
151
+ start_time=item.start_time,
152
+ end_time=item.end_time,
153
+ reset_on_logon=item.reset_on_logon,
154
+ reset_on_logout=item.reset_on_logout,
155
+ session_role=role,
156
+ auto_ack=item.auto_ack,
157
+ auto_ack_delay_ms=item.auto_ack_delay_ms,
158
+ auto_fill=item.auto_fill,
159
+ auto_fill_delay_ms=item.auto_fill_delay_ms,
160
+ owner_uid=user.uid,
161
+ )
162
+ session_id = sm.add_session(cfg)
163
+ created.append(_session_response(sm, session_id))
164
+ except Exception:
165
+ pass # skip invalid / conflicting entries
166
+ return created
167
+
168
+
169
+ @router.get("/{session_id}", response_model=SessionResponse)
170
+ def get_session(session_id: str, sm: SM, user: CurrentUser):
171
+ _check_ownership(sm, session_id, user)
172
+ return _session_response(sm, session_id)
173
+
174
+
175
+ @router.patch("/{session_id}", response_model=SessionResponse)
176
+ def update_session(session_id: str, body: UpdateSessionRequest, sm: SM, user: CurrentUser):
177
+ _check_ownership(sm, session_id, user)
178
+ try:
179
+ sm.update_session(session_id, body)
180
+ except RuntimeError as e:
181
+ raise HTTPException(status_code=409, detail=str(e))
182
+ except (ValueError, KeyError) as e:
183
+ raise HTTPException(status_code=422, detail=str(e))
184
+ return _session_response(sm, session_id)
185
+
186
+
187
+ @router.delete("/{session_id}", status_code=204)
188
+ async def remove_session(session_id: str, sm: SM, user: CurrentUser):
189
+ _check_ownership(sm, session_id, user)
190
+ try:
191
+ await sm.remove_session(session_id)
192
+ except KeyError:
193
+ raise HTTPException(status_code=404, detail="Session not found")
194
+
195
+
196
+ @router.post("/{session_id}/start", response_model=SessionResponse)
197
+ async def start_session(session_id: str, sm: SM, user: CurrentUser):
198
+ _check_ownership(sm, session_id, user)
199
+ try:
200
+ await sm.start_session(session_id)
201
+ except RuntimeError as e:
202
+ raise HTTPException(status_code=409, detail=str(e))
203
+ return _session_response(sm, session_id)
204
+
205
+
206
+ @router.post("/{session_id}/stop", response_model=SessionResponse)
207
+ async def stop_session(
208
+ session_id: str,
209
+ sm: SM,
210
+ user: CurrentUser,
211
+ force: bool = Query(default=False),
212
+ ):
213
+ _check_ownership(sm, session_id, user)
214
+ await sm.stop_session(session_id, force=force)
215
+ return _session_response(sm, session_id)
216
+
217
+
218
+ @router.post("/{session_id}/send")
219
+ async def send_message(session_id: str, body: SendMessageRequest, sm: SM, user: CurrentUser):
220
+ _check_ownership(sm, session_id, user)
221
+ from ...core.fix_parser import parse_raw
222
+ from fixcore.message.message import Message
223
+ from datetime import datetime, timezone
224
+
225
+ raw = body.raw
226
+ if "\x01" not in raw:
227
+ raw = raw.replace("|", "\x01")
228
+
229
+ fields = parse_raw(raw) # {tag_int: value_str}
230
+ msg_type = fields.get(35)
231
+ if not msg_type:
232
+ raise HTTPException(status_code=422, detail="Missing MsgType (tag 35)")
233
+
234
+ if not body.no_auto_ts and 60 not in fields:
235
+ fields[60] = datetime.now(timezone.utc).strftime("%Y%m%d-%H:%M:%S")
236
+
237
+ SKIP = {8, 9, 10, 34, 35, 49, 52, 56}
238
+ msg = Message()
239
+ msg.header.set(35, msg_type)
240
+ for tag, value in fields.items():
241
+ if tag not in SKIP:
242
+ try:
243
+ msg.set_field(int(tag), str(value))
244
+ except Exception:
245
+ pass
246
+
247
+ ok = await sm.send_message(session_id, msg)
248
+ if not ok:
249
+ raise HTTPException(status_code=409, detail="Session not logged on")
250
+ return {"ok": True}
251
+
252
+
253
+ @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)
256
+ try:
257
+ sender, target = sm.get_seqnums(session_id)
258
+ except KeyError:
259
+ raise HTTPException(status_code=404, detail="Session not found")
260
+ return SeqNumResponse(sender=sender, target=target)
261
+
262
+
263
+ @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)
266
+ if body.sender is None and body.target is None:
267
+ raise HTTPException(status_code=422, detail="Provide sender, target, or both")
268
+ try:
269
+ sender, target = sm.set_seqnums(session_id, body.sender, body.target)
270
+ except KeyError:
271
+ raise HTTPException(status_code=404, detail="Session not found")
272
+ return SeqNumResponse(sender=sender, target=target)
@@ -0,0 +1,42 @@
1
+ from typing import Annotated
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, status
4
+
5
+ from ...core.auth import hash_password
6
+ from ...core.user_store import UserStore
7
+ from ..deps import get_user_store
8
+ from ..schemas import CreateAdminRequest, SetupStatusResponse, TokenResponse, UserResponse
9
+ from ...core.auth import create_token
10
+
11
+ router = APIRouter(tags=["setup"])
12
+
13
+ US = Annotated[UserStore, Depends(get_user_store)]
14
+
15
+
16
+ @router.get("/status", response_model=SetupStatusResponse)
17
+ def setup_status(us: US):
18
+ return SetupStatusResponse(setup_required=not us.has_any_users())
19
+
20
+
21
+ @router.post("/create-admin", response_model=TokenResponse, status_code=201)
22
+ def create_admin(body: CreateAdminRequest, us: US):
23
+ if us.has_any_users():
24
+ raise HTTPException(
25
+ status_code=status.HTTP_403_FORBIDDEN,
26
+ detail="Setup already complete",
27
+ )
28
+ if not body.username or not body.password:
29
+ raise HTTPException(status_code=422, detail="Username and password are required")
30
+ if len(body.password) < 8:
31
+ raise HTTPException(status_code=422, detail="Password must be at least 8 characters")
32
+
33
+ user = us.create_user(
34
+ username=body.username,
35
+ password_hash=hash_password(body.password),
36
+ role="platform_admin",
37
+ )
38
+ token = create_token(user)
39
+ return TokenResponse(
40
+ access_token=token,
41
+ user=UserResponse(uid=user.uid, username=user.username, role=user.role),
42
+ )
@@ -0,0 +1,36 @@
1
+ from fastapi import APIRouter, Depends, HTTPException, Response, status
2
+
3
+ from ...core.template_store import TemplateStore
4
+ from ...core.user_store import User
5
+ from ..deps import get_current_user, get_template_store
6
+ from ..schemas import TemplateRequest, TemplateResponse
7
+
8
+ router = APIRouter(tags=["templates"])
9
+
10
+ TS = Depends(get_template_store)
11
+ CurrentUser = Depends(get_current_user)
12
+
13
+
14
+ @router.get("", response_model=list[TemplateResponse])
15
+ def list_templates(store: TemplateStore = TS, user: User = CurrentUser):
16
+ return store.list_templates(user.uid)
17
+
18
+
19
+ @router.post("", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED)
20
+ def create_template(body: TemplateRequest, store: TemplateStore = TS, user: User = CurrentUser):
21
+ return store.create_template(user.uid, body.model_dump())
22
+
23
+
24
+ @router.put("/{template_id}", response_model=TemplateResponse)
25
+ def update_template(template_id: str, body: TemplateRequest, store: TemplateStore = TS, user: User = CurrentUser):
26
+ updated = store.update_template(user.uid, template_id, body.model_dump())
27
+ if updated is None:
28
+ raise HTTPException(status_code=404, detail="Template not found")
29
+ return updated
30
+
31
+
32
+ @router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
33
+ def delete_template(template_id: str, store: TemplateStore = TS, user: User = CurrentUser):
34
+ if not store.delete_template(user.uid, template_id):
35
+ raise HTTPException(status_code=404, detail="Template not found")
36
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -0,0 +1,129 @@
1
+ import asyncio
2
+ from typing import Annotated, Optional
3
+
4
+ from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
5
+ from jose import JWTError
6
+
7
+ from ...core.session_manager import SessionManager
8
+ from ..connection_manager import ConnectionManager
9
+ from ..deps import get_session_manager, get_conn_manager
10
+ from ...core.auth import decode_token
11
+ from ...core.user_store import UserStore
12
+
13
+ router = APIRouter(tags=["websocket"])
14
+
15
+ SM = Annotated[SessionManager, Depends(get_session_manager)]
16
+ CM = Annotated[ConnectionManager, Depends(get_conn_manager)]
17
+
18
+
19
+ async def _send_snapshot(websocket: WebSocket, sm: SessionManager, uid: str, is_admin: bool, session_id: str = "*") -> None:
20
+ """Send current state of all (or one) session(s) immediately on connect."""
21
+ configs = sm.list_sessions_for_user(uid, is_admin=is_admin)
22
+ if session_id != "*":
23
+ configs = [c for c in configs if c.session_id == session_id]
24
+
25
+ for cfg in configs:
26
+ state = sm.get_state(cfg.session_id)
27
+ log = state.message_log
28
+ await websocket.send_json({
29
+ "type": "session_status",
30
+ "session_id": cfg.session_id,
31
+ "status": state.status.name,
32
+ "last_sent_seq_num": state.last_sent_seq_num,
33
+ "last_recv_seq_num": state.last_recv_seq_num,
34
+ "message_count": len(log) if log else 0,
35
+ "status_changed_at": state.status_changed_at,
36
+ "last_heartbeat_at": state.last_heartbeat_at,
37
+ })
38
+
39
+
40
+ def _authenticate_ws(websocket: WebSocket, token: Optional[str]) -> tuple[str, bool]:
41
+ """Validate JWT token from query param. Returns (uid, is_admin) or closes with 403."""
42
+ if not token:
43
+ return None, False
44
+ try:
45
+ payload = decode_token(token)
46
+ uid = payload.get("sub", "")
47
+ role = payload.get("role", "")
48
+ if not uid:
49
+ return None, False
50
+ return uid, role == "platform_admin"
51
+ except JWTError:
52
+ return None, False
53
+
54
+
55
+ @router.websocket("/ws")
56
+ async def ws_all_sessions(
57
+ websocket: WebSocket,
58
+ sm: SM,
59
+ conn_mgr: CM,
60
+ token: Optional[str] = Query(default=None),
61
+ ):
62
+ """Subscribe to events from all sessions."""
63
+ uid, is_admin = _authenticate_ws(websocket, token)
64
+ if not uid:
65
+ await websocket.close(code=4003, reason="Unauthorized")
66
+ return
67
+
68
+ # Also validate user is still active
69
+ store: UserStore = websocket.app.state.user_store
70
+ user = store.get_by_uid(uid)
71
+ if user is None or not user.is_active:
72
+ await websocket.close(code=4003, reason="Unauthorized")
73
+ return
74
+
75
+ await conn_mgr.connect(websocket, session_id="*", uid=uid, is_admin=is_admin)
76
+ try:
77
+ await _send_snapshot(websocket, sm, uid, is_admin)
78
+ while True:
79
+ await asyncio.sleep(15)
80
+ try:
81
+ await websocket.send_json({"type": "ping"})
82
+ except Exception:
83
+ break
84
+ except WebSocketDisconnect:
85
+ pass
86
+ finally:
87
+ conn_mgr.disconnect(websocket, session_id="*")
88
+
89
+
90
+ @router.websocket("/ws/{session_id}")
91
+ async def ws_single_session(
92
+ websocket: WebSocket,
93
+ session_id: str,
94
+ sm: SM,
95
+ conn_mgr: CM,
96
+ token: Optional[str] = Query(default=None),
97
+ ):
98
+ """Subscribe to events from a single session."""
99
+ uid, is_admin = _authenticate_ws(websocket, token)
100
+ if not uid:
101
+ await websocket.close(code=4003, reason="Unauthorized")
102
+ return
103
+
104
+ store: UserStore = websocket.app.state.user_store
105
+ user = store.get_by_uid(uid)
106
+ if user is None or not user.is_active:
107
+ await websocket.close(code=4003, reason="Unauthorized")
108
+ return
109
+
110
+ # Check ownership of this specific session
111
+ configs = sm.list_sessions()
112
+ cfg = next((c for c in configs if c.session_id == session_id), None)
113
+ if cfg is None or (not is_admin and cfg.owner_uid != uid):
114
+ await websocket.close(code=4003, reason="Access denied")
115
+ return
116
+
117
+ await conn_mgr.connect(websocket, session_id=session_id, uid=uid, is_admin=is_admin)
118
+ try:
119
+ await _send_snapshot(websocket, sm, uid, is_admin, session_id=session_id)
120
+ while True:
121
+ await asyncio.sleep(15)
122
+ try:
123
+ await websocket.send_json({"type": "ping"})
124
+ except Exception:
125
+ break
126
+ except WebSocketDisconnect:
127
+ pass
128
+ finally:
129
+ conn_mgr.disconnect(websocket, session_id=session_id)