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 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(); // Default config has admin disabled
327
328        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}