hotfix_web/
lib.rs

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/// Configuration for the HTTP router
17#[derive(Clone, Debug, Default)]
18pub struct RouterConfig {
19    /// Enable admin endpoints (/api/shutdown, /api/reset)
20    pub enable_admin_endpoints: bool,
21}
22
23/// Build a router with default configuration (admin endpoints disabled)
24pub fn build_router<M: FixMessage>(session_handle: SessionHandle<M>) -> Router {
25    build_router_with_config(session_handle, RouterConfig::default())
26}
27
28/// Build a router with custom configuration
29pub 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    // Implement SessionInfoProvider for the test controller
143    #[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            // Reuse the SessionController implementation
148            SessionController::get_session_info(self).await
149        }
150    }
151
152    // Allow extracting FakeSessionController from AppState for hotfix-web-ui
153    #[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 post_json(&mut self, path: &str, json: Value) -> TestResponse {
197            let body = serde_json::to_string(&json).unwrap();
198            let request = Request::builder()
199                .method(Method::POST)
200                .uri(path)
201                .header("Content-Type", "application/json")
202                .body(Body::from(body))
203                .unwrap();
204
205            let response = self.router.clone().oneshot(request).await.unwrap();
206            TestResponse::new(response).await
207        }
208
209        async fn request(&mut self, method: Method, path: &str) -> TestResponse {
210            let request = Request::builder()
211                .method(method)
212                .uri(path)
213                .body(Body::empty())
214                .unwrap();
215
216            let response = self.router.clone().oneshot(request).await.unwrap();
217            TestResponse::new(response).await
218        }
219
220        fn get_state(&self) -> FakeDataState {
221            self.controller.get_state()
222        }
223    }
224
225    struct TestResponse {
226        status: StatusCode,
227        body: Vec<u8>,
228    }
229
230    impl TestResponse {
231        async fn new(response: axum::response::Response) -> Self {
232            let status = response.status();
233            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
234                .await
235                .unwrap()
236                .to_vec();
237            Self { status, body }
238        }
239
240        fn assert_status(&self, expected: StatusCode) -> &Self {
241            assert_eq!(
242                self.status,
243                expected,
244                "Expected status {}, got {}. Body: {}",
245                expected,
246                self.status,
247                String::from_utf8_lossy(&self.body)
248            );
249            self
250        }
251
252        fn json_body(&self) -> Value {
253            serde_json::from_slice(&self.body).unwrap()
254        }
255    }
256
257    #[tokio::test]
258    async fn test_health_endpoint_returns_healthy_status() {
259        let mut ctx = TestContext::new();
260
261        let response = ctx.get("/api/health").await;
262
263        response.assert_status(StatusCode::OK);
264        let body = response.json_body();
265        assert_eq!(body["status"], "healthy");
266    }
267
268    #[tokio::test]
269    async fn test_session_info_endpoint_returns_session_data() {
270        let session_info = SessionInfo {
271            next_sender_seq_number: 42,
272            next_target_seq_number: 99,
273            status: Status::Active,
274        };
275
276        let mut ctx = TestContext::new().with_session_info(session_info);
277
278        let response = ctx.get("/api/session-info").await;
279
280        response.assert_status(StatusCode::OK);
281        let body = response.json_body();
282        assert_eq!(body["session_info"]["next_sender_seq_number"], 42);
283        assert_eq!(body["session_info"]["next_target_seq_number"], 99);
284        assert_eq!(body["session_info"]["status"], "Active");
285    }
286
287    #[tokio::test]
288    async fn test_session_info_with_awaiting_logon_status() {
289        let session_info = SessionInfo {
290            next_sender_seq_number: 1,
291            next_target_seq_number: 1,
292            status: Status::AwaitingLogon,
293        };
294
295        let mut ctx = TestContext::new().with_session_info(session_info);
296
297        let response = ctx.get("/api/session-info").await;
298
299        response.assert_status(StatusCode::OK);
300        let body = response.json_body();
301        assert_eq!(body["session_info"]["status"], "AwaitingLogon");
302    }
303
304    #[tokio::test]
305    async fn test_reset_endpoint_triggers_reset_request() {
306        let config = RouterConfig {
307            enable_admin_endpoints: true,
308        };
309        let mut ctx = TestContext::with_config(config);
310
311        let response = ctx.post("/api/reset").await;
312
313        response.assert_status(StatusCode::OK);
314        let state = ctx.get_state();
315        assert!(state.reset_requested, "Reset should have been requested");
316    }
317
318    #[tokio::test]
319    async fn test_shutdown_endpoint_calls_shutdown_with_reconnect() {
320        let config = RouterConfig {
321            enable_admin_endpoints: true,
322        };
323        let mut ctx = TestContext::with_config(config);
324
325        let response = ctx
326            .post_json("/api/shutdown", serde_json::json!({"reconnect": true}))
327            .await;
328
329        response.assert_status(StatusCode::OK);
330        let state = ctx.get_state();
331        assert!(state.shutdown_called, "Shutdown should have been called");
332        assert_eq!(
333            state.shutdown_reconnect,
334            Some(true),
335            "Shutdown should be called with reconnect=true"
336        );
337    }
338
339    #[tokio::test]
340    async fn test_shutdown_endpoint_calls_shutdown_without_reconnect() {
341        let config = RouterConfig {
342            enable_admin_endpoints: true,
343        };
344        let mut ctx = TestContext::with_config(config);
345
346        let response = ctx
347            .post_json("/api/shutdown", serde_json::json!({"reconnect": false}))
348            .await;
349
350        response.assert_status(StatusCode::OK);
351        let state = ctx.get_state();
352        assert!(state.shutdown_called, "Shutdown should have been called");
353        assert_eq!(
354            state.shutdown_reconnect,
355            Some(false),
356            "Shutdown should be called with reconnect=false"
357        );
358    }
359
360    #[tokio::test]
361    async fn test_admin_endpoints_disabled_by_default() {
362        let mut ctx = TestContext::new(); // Default config has admin disabled
363
364        let response = ctx.post("/api/reset").await;
365        response.assert_status(StatusCode::NOT_FOUND);
366
367        let response = ctx.post("/api/shutdown").await;
368        response.assert_status(StatusCode::NOT_FOUND);
369    }
370}