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::OutboundMessage;
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<Outbound: OutboundMessage>(session_handle: SessionHandle<Outbound>) -> Router {
25 build_router_with_config(session_handle, RouterConfig::default())
26}
27
28pub fn build_router_with_config<Outbound: OutboundMessage>(
30 session_handle: SessionHandle<Outbound>,
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 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(); 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}