1mod endpoints;
2mod error;
3mod session_controller;
4
5use crate::endpoints::build_api_router;
6use crate::session_controller::{HttpSessionController, SessionController};
7use axum::Router;
8use hotfix::message::FixMessage;
9use hotfix::session::SessionHandle;
10
11#[derive(Clone)]
12pub(crate) struct AppState<C> {
13 pub(crate) controller: C,
14}
15
16#[derive(Clone, Debug, Default)]
18pub struct RouterConfig {
19 pub enable_admin_endpoints: bool,
21}
22
23pub fn build_router<M: FixMessage>(session_handle: SessionHandle<M>) -> Router {
25 build_router_with_config(session_handle, RouterConfig::default())
26}
27
28pub fn build_router_with_config<M: FixMessage>(
30 session_handle: SessionHandle<M>,
31 config: RouterConfig,
32) -> Router {
33 let controller = HttpSessionController { session_handle };
34 build_router_with_controller(controller, config)
35}
36
37#[cfg(feature = "ui")]
38fn build_router_with_controller<C>(controller: C, config: RouterConfig) -> Router
39where
40 C: SessionController + hotfix_web_ui::SessionInfoProvider + 'static,
41 C: axum::extract::FromRef<AppState<C>>,
42{
43 let state = AppState { controller };
44 Router::new()
45 .nest("/api", build_api_router(config))
46 .merge(hotfix_web_ui::build_ui_router::<AppState<C>, C>())
47 .with_state(state)
48}
49
50#[cfg(not(feature = "ui"))]
51fn build_router_with_controller(
52 controller: impl SessionController + 'static,
53 config: RouterConfig,
54) -> Router {
55 let state = AppState { controller };
56 Router::new()
57 .nest("/api", build_api_router(config))
58 .with_state(state)
59}
60
61#[cfg(test)]
62mod tests {
63 #[cfg(feature = "ui")]
64 use crate::AppState;
65 use crate::RouterConfig;
66 use crate::build_router_with_controller;
67 use crate::session_controller::SessionController;
68 use axum::Router;
69 use axum::body::Body;
70 use axum::http::{Method, Request, StatusCode};
71 use hotfix::session::{SessionInfo, Status};
72 use serde_json::Value;
73 use std::sync::{Arc, Mutex};
74 use tower::ServiceExt;
75
76 #[derive(Clone, Debug)]
77 struct FakeDataState {
78 session_info: SessionInfo,
79 reset_requested: bool,
80 shutdown_called: bool,
81 shutdown_reconnect: Option<bool>,
82 }
83
84 impl Default for FakeDataState {
85 fn default() -> Self {
86 Self {
87 session_info: SessionInfo {
88 next_sender_seq_number: 3,
89 next_target_seq_number: 5,
90 status: Status::AwaitingLogon,
91 },
92 reset_requested: false,
93 shutdown_called: false,
94 shutdown_reconnect: None,
95 }
96 }
97 }
98
99 #[derive(Clone)]
100 struct FakeSessionController {
101 state: Arc<Mutex<FakeDataState>>,
102 }
103
104 impl FakeSessionController {
105 fn new() -> Self {
106 Self {
107 state: Arc::new(Mutex::new(FakeDataState::default())),
108 }
109 }
110
111 fn with_session_info(self, session_info: SessionInfo) -> Self {
112 self.state.lock().unwrap().session_info = session_info;
113 self
114 }
115
116 fn get_state(&self) -> FakeDataState {
117 self.state.lock().unwrap().clone()
118 }
119 }
120
121 #[async_trait::async_trait]
122 impl SessionController for FakeSessionController {
123 async fn get_session_info(&self) -> anyhow::Result<SessionInfo> {
124 let state = self.state.lock().unwrap();
125 Ok(state.session_info.clone())
126 }
127
128 async fn request_reset_on_next_logon(&self) -> anyhow::Result<()> {
129 let mut state = self.state.lock().unwrap();
130 state.reset_requested = true;
131 Ok(())
132 }
133
134 async fn shutdown(&self, reconnect: bool) -> anyhow::Result<()> {
135 let mut state = self.state.lock().unwrap();
136 state.shutdown_called = true;
137 state.shutdown_reconnect = Some(reconnect);
138 Ok(())
139 }
140 }
141
142 #[cfg(feature = "ui")]
144 #[async_trait::async_trait]
145 impl hotfix_web_ui::SessionInfoProvider for FakeSessionController {
146 async fn get_session_info(&self) -> anyhow::Result<SessionInfo> {
147 SessionController::get_session_info(self).await
149 }
150 }
151
152 #[cfg(feature = "ui")]
154 impl axum::extract::FromRef<AppState<FakeSessionController>> for FakeSessionController {
155 fn from_ref(state: &AppState<FakeSessionController>) -> Self {
156 state.controller.clone()
157 }
158 }
159
160 struct TestContext {
161 router: Router,
162 controller: FakeSessionController,
163 config: RouterConfig,
164 }
165
166 impl TestContext {
167 fn new() -> Self {
168 Self::with_config(RouterConfig::default())
169 }
170
171 fn with_config(config: RouterConfig) -> Self {
172 let controller = FakeSessionController::new();
173 let router = build_router_with_controller(controller.clone(), config.clone());
174 Self {
175 router,
176 controller,
177 config,
178 }
179 }
180
181 fn with_session_info(mut self, session_info: SessionInfo) -> Self {
182 self.controller = self.controller.with_session_info(session_info);
183 self.router =
184 build_router_with_controller(self.controller.clone(), self.config.clone());
185 self
186 }
187
188 async fn get(&mut self, path: &str) -> TestResponse {
189 self.request(Method::GET, path).await
190 }
191
192 async fn post(&mut self, path: &str) -> TestResponse {
193 self.request(Method::POST, path).await
194 }
195
196 async fn request(&mut self, method: Method, path: &str) -> TestResponse {
197 let request = Request::builder()
198 .method(method)
199 .uri(path)
200 .body(Body::empty())
201 .unwrap();
202
203 let response = self.router.clone().oneshot(request).await.unwrap();
204 TestResponse::new(response).await
205 }
206
207 fn get_state(&self) -> FakeDataState {
208 self.controller.get_state()
209 }
210 }
211
212 struct TestResponse {
213 status: StatusCode,
214 body: Vec<u8>,
215 }
216
217 impl TestResponse {
218 async fn new(response: axum::response::Response) -> Self {
219 let status = response.status();
220 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
221 .await
222 .unwrap()
223 .to_vec();
224 Self { status, body }
225 }
226
227 fn assert_status(&self, expected: StatusCode) -> &Self {
228 assert_eq!(
229 self.status,
230 expected,
231 "Expected status {}, got {}. Body: {}",
232 expected,
233 self.status,
234 String::from_utf8_lossy(&self.body)
235 );
236 self
237 }
238
239 fn json_body(&self) -> Value {
240 serde_json::from_slice(&self.body).unwrap()
241 }
242 }
243
244 #[tokio::test]
245 async fn test_health_endpoint_returns_healthy_status() {
246 let mut ctx = TestContext::new();
247
248 let response = ctx.get("/api/health").await;
249
250 response.assert_status(StatusCode::OK);
251 let body = response.json_body();
252 assert_eq!(body["status"], "healthy");
253 }
254
255 #[tokio::test]
256 async fn test_session_info_endpoint_returns_session_data() {
257 let session_info = SessionInfo {
258 next_sender_seq_number: 42,
259 next_target_seq_number: 99,
260 status: Status::Active,
261 };
262
263 let mut ctx = TestContext::new().with_session_info(session_info);
264
265 let response = ctx.get("/api/session-info").await;
266
267 response.assert_status(StatusCode::OK);
268 let body = response.json_body();
269 assert_eq!(body["session_info"]["next_sender_seq_number"], 42);
270 assert_eq!(body["session_info"]["next_target_seq_number"], 99);
271 assert_eq!(body["session_info"]["status"], "Active");
272 }
273
274 #[tokio::test]
275 async fn test_session_info_with_awaiting_logon_status() {
276 let session_info = SessionInfo {
277 next_sender_seq_number: 1,
278 next_target_seq_number: 1,
279 status: Status::AwaitingLogon,
280 };
281
282 let mut ctx = TestContext::new().with_session_info(session_info);
283
284 let response = ctx.get("/api/session-info").await;
285
286 response.assert_status(StatusCode::OK);
287 let body = response.json_body();
288 assert_eq!(body["session_info"]["status"], "AwaitingLogon");
289 }
290
291 #[tokio::test]
292 async fn test_reset_endpoint_triggers_reset_request() {
293 let config = RouterConfig {
294 enable_admin_endpoints: true,
295 };
296 let mut ctx = TestContext::with_config(config);
297
298 let response = ctx.post("/api/reset").await;
299
300 response.assert_status(StatusCode::OK);
301 let state = ctx.get_state();
302 assert!(state.reset_requested, "Reset should have been requested");
303 }
304
305 #[tokio::test]
306 async fn test_shutdown_endpoint_calls_shutdown_with_reconnect() {
307 let config = RouterConfig {
308 enable_admin_endpoints: true,
309 };
310 let mut ctx = TestContext::with_config(config);
311
312 let response = ctx.post("/api/shutdown").await;
313
314 response.assert_status(StatusCode::OK);
315 let state = ctx.get_state();
316 assert!(state.shutdown_called, "Shutdown should have been called");
317 assert_eq!(
318 state.shutdown_reconnect,
319 Some(true),
320 "Shutdown should be called with reconnect=true"
321 );
322 }
323
324 #[tokio::test]
325 async fn test_admin_endpoints_disabled_by_default() {
326 let mut ctx = TestContext::new(); let response = ctx.post("/api/reset").await;
329 response.assert_status(StatusCode::NOT_FOUND);
330
331 let response = ctx.post("/api/shutdown").await;
332 response.assert_status(StatusCode::NOT_FOUND);
333 }
334}