Skip to content

Commit df26500

Browse files
authored
feat: support reconnects in shutdowns initiated via CLI tool (#250)
* Create hotfix-cli readme * Rename executable from hotfix-cli to hotfix * Fix issue with session not reconnecting correctly even though it was explicitly requested * Support the explicit reconnect flag in shutdown API
1 parent cb74c57 commit df26500

8 files changed

Lines changed: 172 additions & 65 deletions

File tree

crates/hotfix-cli/Cargo.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
[package]
22
name = "hotfix-cli"
3+
description = "CLI tool for the HotFIX engine"
34
version = "0.1.0"
45
authors.workspace = true
56
edition.workspace = true
67
license.workspace = true
7-
readme.workspace = true
8+
readme = "README.md"
89
homepage.workspace = true
910
repository.workspace = true
1011
keywords.workspace = true
1112
categories.workspace = true
1213

14+
[[bin]]
15+
name = "hotfix"
16+
path = "src/main.rs"
17+
1318
[dependencies]
1419
anyhow.workspace = true
1520
clap = { workspace = true, features = ["derive", "env"] }

crates/hotfix-cli/README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<div align="center">
2+
3+
# hotfix-cli
4+
5+
**CLI tool for the HotFIX engine.**
6+
7+
</div>
8+
9+
This crate is part of the [hotfix](https://github.com/hotfix-rs/hotfix) project.
10+
11+
It provides a CLI client for the [web interface](https://crates.io/crates/hotfix-web) of the hotfix FIX engine
12+
which supports fetching session state and sending admin commands to the running session.
13+
14+
## Installation
15+
16+
You can either install the tool using `cargo install hotfix-cli` or use it as a library in your own project.
17+
18+
## How to use it
19+
20+
You need to have a running hotfix FIX engine instance with the web interface exposed using the
21+
[hotfix-web](https://crates.io/crates/hotfix-web) crate.
22+
23+
The tool tries to connect to the web interface using the default address `http://localhost:9881`.
24+
You can override this using either the explicit CLI argument `--url` or the environment variable `HOTFIX_CLI_URL`.
25+
26+
With everything set up, you can use the tool to run commands, e.g.
27+
28+
```shell
29+
hotfix session-info
30+
```
31+
32+
For the full list of available commands, run `hotfix --help`.

crates/hotfix-cli/src/lib.rs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use anyhow::{Context, Result};
22
use clap::{Parser, Subcommand};
33
use owo_colors::OwoColorize;
4-
use serde::Deserialize;
4+
use serde::{Deserialize, Serialize};
55

66
#[derive(Parser)]
77
#[command(name = "hotfix")]
@@ -29,7 +29,11 @@ pub enum Command {
2929
/// Request a reset on next logon
3030
Reset,
3131
/// Shutdown the session
32-
Shutdown,
32+
Shutdown {
33+
/// Whether to reconnect after shutdown
34+
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
35+
reconnect: bool,
36+
},
3337
}
3438

3539
#[derive(Debug, Deserialize)]
@@ -49,6 +53,11 @@ pub struct SessionInfo {
4953
pub status: String,
5054
}
5155

56+
#[derive(Debug, Serialize)]
57+
pub struct ShutdownRequest {
58+
pub reconnect: bool,
59+
}
60+
5261
pub async fn run(cli: Cli) -> Result<()> {
5362
let client = reqwest::Client::new();
5463
let base_url = cli.url.trim_end_matches('/');
@@ -125,10 +134,12 @@ pub async fn run(cli: Cli) -> Result<()> {
125134
);
126135
}
127136
}
128-
Command::Shutdown => {
137+
Command::Shutdown { reconnect } => {
129138
let url = format!("{}/api/shutdown", base_url);
139+
let body = ShutdownRequest { reconnect };
130140
let response = client
131141
.post(&url)
142+
.json(&body)
132143
.send()
133144
.await
134145
.context("Failed to send shutdown request")?;
@@ -235,7 +246,7 @@ mod tests {
235246

236247
let cli = Cli {
237248
url: mock_server.uri(),
238-
command: Command::Shutdown,
249+
command: Command::Shutdown { reconnect: true },
239250
};
240251

241252
let result = run(cli).await;
@@ -279,7 +290,7 @@ mod tests {
279290

280291
let cli = Cli {
281292
url: mock_server.uri(),
282-
command: Command::Shutdown,
293+
command: Command::Shutdown { reconnect: true },
283294
};
284295

285296
let result = run(cli).await;

crates/hotfix-cli/tests/integration.rs

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use assert_cmd::assert::OutputAssertExt;
22
use assert_cmd::cargo_bin;
33
use predicates::prelude::*;
44
use std::process::Command;
5-
use wiremock::matchers::{method, path};
5+
use wiremock::matchers::{body_json, method, path};
66
use wiremock::{Mock, MockServer, ResponseTemplate};
77

88
#[tokio::test]
@@ -17,7 +17,7 @@ async fn test_cli_health_command_integration() {
1717
.mount(&mock_server)
1818
.await;
1919

20-
let mut cmd = Command::new(cargo_bin!());
20+
let mut cmd = Command::new(cargo_bin!("hotfix"));
2121
cmd.arg("--url")
2222
.arg(mock_server.uri())
2323
.arg("health")
@@ -43,7 +43,7 @@ async fn test_cli_session_info_command_integration() {
4343
.mount(&mock_server)
4444
.await;
4545

46-
let mut cmd = Command::new(cargo_bin!());
46+
let mut cmd = Command::new(cargo_bin!("hotfix"));
4747
cmd.arg("--url")
4848
.arg(mock_server.uri())
4949
.arg("session-info")
@@ -67,7 +67,7 @@ async fn test_cli_reset_command_integration() {
6767
.mount(&mock_server)
6868
.await;
6969

70-
let mut cmd = Command::new(cargo_bin!());
70+
let mut cmd = Command::new(cargo_bin!("hotfix"));
7171
cmd.arg("--url")
7272
.arg(mock_server.uri())
7373
.arg("reset")
@@ -82,11 +82,12 @@ async fn test_cli_shutdown_command_integration() {
8282

8383
Mock::given(method("POST"))
8484
.and(path("/api/shutdown"))
85+
.and(body_json(serde_json::json!({"reconnect": true})))
8586
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
8687
.mount(&mock_server)
8788
.await;
8889

89-
let mut cmd = Command::new(cargo_bin!());
90+
let mut cmd = Command::new(cargo_bin!("hotfix"));
9091
cmd.arg("--url")
9192
.arg(mock_server.uri())
9293
.arg("shutdown")
@@ -95,9 +96,31 @@ async fn test_cli_shutdown_command_integration() {
9596
.stdout(predicate::str::contains("Shutdown requested successfully"));
9697
}
9798

99+
#[tokio::test]
100+
async fn test_cli_shutdown_command_with_reconnect_false() {
101+
let mock_server = MockServer::start().await;
102+
103+
Mock::given(method("POST"))
104+
.and(path("/api/shutdown"))
105+
.and(body_json(serde_json::json!({"reconnect": false})))
106+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
107+
.mount(&mock_server)
108+
.await;
109+
110+
let mut cmd = Command::new(cargo_bin!("hotfix"));
111+
cmd.arg("--url")
112+
.arg(mock_server.uri())
113+
.arg("shutdown")
114+
.arg("--reconnect")
115+
.arg("false")
116+
.assert()
117+
.success()
118+
.stdout(predicate::str::contains("Shutdown requested successfully"));
119+
}
120+
98121
#[tokio::test]
99122
async fn test_cli_help_command() {
100-
let mut cmd = Command::new(cargo_bin!());
123+
let mut cmd = Command::new(cargo_bin!("hotfix"));
101124
cmd.arg("--help")
102125
.assert()
103126
.success()
@@ -120,7 +143,7 @@ async fn test_cli_error_handling() {
120143
.mount(&mock_server)
121144
.await;
122145

123-
let mut cmd = Command::new(cargo_bin!());
146+
let mut cmd = Command::new(cargo_bin!("hotfix"));
124147
cmd.arg("--url")
125148
.arg(mock_server.uri())
126149
.arg("reset")
@@ -141,7 +164,7 @@ async fn test_cli_with_env_var() {
141164
.mount(&mock_server)
142165
.await;
143166

144-
let mut cmd = Command::new(cargo_bin!());
167+
let mut cmd = Command::new(cargo_bin!("hotfix"));
145168
cmd.env("HOTFIX_WEB_URL", mock_server.uri())
146169
.arg("health")
147170
.assert()

crates/hotfix-web/src/endpoints/admin/shutdown.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,18 @@ use crate::error::AppResult;
33
use crate::session_controller::SessionController;
44
use axum::Json;
55
use axum::extract::State;
6+
use serde::Deserialize;
7+
8+
#[derive(Deserialize)]
9+
pub(crate) struct ShutdownRequest {
10+
pub reconnect: bool,
11+
}
612

713
pub(crate) async fn shutdown<C: SessionController>(
814
State(state): State<AppState<C>>,
15+
Json(payload): Json<ShutdownRequest>,
916
) -> AppResult<Json<()>> {
10-
state.controller.shutdown(true).await?;
17+
state.controller.shutdown(payload.reconnect).await?;
1118

1219
Ok(Json(()))
1320
}

crates/hotfix-web/src/lib.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,19 @@ mod tests {
193193
self.request(Method::POST, path).await
194194
}
195195

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+
196209
async fn request(&mut self, method: Method, path: &str) -> TestResponse {
197210
let request = Request::builder()
198211
.method(method)
@@ -309,7 +322,9 @@ mod tests {
309322
};
310323
let mut ctx = TestContext::with_config(config);
311324

312-
let response = ctx.post("/api/shutdown").await;
325+
let response = ctx
326+
.post_json("/api/shutdown", serde_json::json!({"reconnect": true}))
327+
.await;
313328

314329
response.assert_status(StatusCode::OK);
315330
let state = ctx.get_state();
@@ -321,6 +336,27 @@ mod tests {
321336
);
322337
}
323338

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+
324360
#[tokio::test]
325361
async fn test_admin_endpoints_disabled_by_default() {
326362
let mut ctx = TestContext::new(); // Default config has admin disabled

0 commit comments

Comments
 (0)