feat: implement ping command"
This commit is contained in:
1 parent
fb0bbae1f0
commit
8045814b3b
3 files changed
+312
-9
No files matched your search
+10
-1
@@ -1,4 +1,4 @@
|
||||
use clap::Parser;
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
#[derive(Debug, PartialEq, Parser)]
|
||||
#[command(name = "onx")]
|
||||
@@ -22,4 +22,13 @@ pub struct Cli {
|
||||
#[arg(help = "Pretty-print JSON output")]
|
||||
#[arg(default_value_t = false)]
|
||||
pub pretty: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Subcommand)]
|
||||
pub enum Command {
|
||||
#[command(about = "Check connectivity to the Onspring API")]
|
||||
Ping,
|
||||
}
|
||||
+272
-7
@@ -2,9 +2,22 @@ mod cli;
|
||||
mod output;
|
||||
|
||||
use clap::{Parser, error::ErrorKind};
|
||||
use cli::Cli;
|
||||
use cli::{Cli, Command};
|
||||
|
||||
use output::render_error;
|
||||
use onspring::{OnspringClient, OnspringError};
|
||||
use output::{SuccessResponse, render_error, write_json};
|
||||
|
||||
pub type CliResult<T> = Result<T, CliError>;
|
||||
|
||||
pub trait OnspringRunner {
|
||||
fn ping(&self) -> impl std::future::Future<Output = Result<(), OnspringError>> + Send;
|
||||
}
|
||||
|
||||
impl OnspringRunner for OnspringClient {
|
||||
async fn ping(&self) -> Result<(), OnspringError> {
|
||||
self.ping().await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct CliError {
|
||||
@@ -35,9 +48,59 @@ impl CliError {
|
||||
}
|
||||
}
|
||||
|
||||
pub type CliResult<T> = Result<T, CliError>;
|
||||
impl From<OnspringError> for CliError {
|
||||
fn from(value: OnspringError) -> Self {
|
||||
match value {
|
||||
OnspringError::InvalidArgument(message) => Self::usage(format!("Invalid request: {message}")),
|
||||
OnspringError::Api {
|
||||
status_code,
|
||||
message,
|
||||
} => {
|
||||
if message.is_empty() {
|
||||
Self::runtime(format!("API request failed with status {status_code}."))
|
||||
} else {
|
||||
Self::runtime(format!("API request failed ({status_code}): {message}"))
|
||||
}
|
||||
}
|
||||
OnspringError::Http(_) => Self::runtime("Network request failed."),
|
||||
OnspringError::Serialization(_) => Self::runtime("Failed to parse API response."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(cli: Cli) -> CliResult<()> {
|
||||
let api_key = cli
|
||||
.api_key
|
||||
.as_deref()
|
||||
.ok_or_else(|| CliError::usage("Missing API Key. Set --api-key or ONSPRING_API_KEY."))?;
|
||||
|
||||
let mut builder = OnspringClient::builder(api_key.to_string());
|
||||
|
||||
if let Some(base_url) = &cli.base_url {
|
||||
if base_url.trim().is_empty() {
|
||||
return Err(CliError::usage("Base URL cannot be empty."));
|
||||
}
|
||||
|
||||
builder = builder.base_url(base_url);
|
||||
}
|
||||
|
||||
let client = builder.build();
|
||||
let mut stdout = std::io::stdout();
|
||||
run_with_client(&cli, &client, &mut stdout).await
|
||||
}
|
||||
|
||||
pub async fn run_with_client<C: OnspringRunner, W: std::io::Write>(
|
||||
cli: &Cli,
|
||||
client: &C,
|
||||
writer: &mut W,
|
||||
) -> CliResult<()> {
|
||||
match cli.command {
|
||||
Command::Ping => {
|
||||
client.ping().await?;
|
||||
write_json(writer, &SuccessResponse { ok: true }, cli.pretty)?;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(_cli: Cli) -> CliResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -61,20 +124,60 @@ pub fn render_cli_error(err: &CliError, pretty: bool) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct MockClient {
|
||||
ping_result: Result<(), OnspringError>,
|
||||
}
|
||||
|
||||
impl OnspringRunner for MockClient {
|
||||
async fn ping(&self) -> Result<(), OnspringError> {
|
||||
match &self.ping_result {
|
||||
Ok(()) => Ok(()),
|
||||
Err(OnspringError::InvalidArgument(msg)) => {
|
||||
Err(OnspringError::InvalidArgument(msg.clone()))
|
||||
}
|
||||
Err(OnspringError::Api {
|
||||
status_code,
|
||||
message,
|
||||
}) => Err(OnspringError::Api {
|
||||
status_code: *status_code,
|
||||
message: message.clone(),
|
||||
}),
|
||||
Err(OnspringError::Serialization(_)) => {
|
||||
let serde_err = serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
|
||||
Err(OnspringError::Serialization(serde_err))
|
||||
}
|
||||
Err(OnspringError::Http(_)) => {
|
||||
Err(OnspringError::InvalidArgument("http error".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cli_from_when_called_with_valid_program_name_it_should_return_cli() {
|
||||
let result = parse_cli_from(["test"]);
|
||||
fn parse_cli_from_when_called_with_valid_subcommand_it_should_return_cli() {
|
||||
let result = parse_cli_from(["test", "ping"]);
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
Ok(Cli {
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
pretty: false
|
||||
pretty: false,
|
||||
command: Command::Ping,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cli_from_when_called_without_subcommand_it_should_return_usage_error() {
|
||||
let result = parse_cli_from(["test"]);
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert_eq!(err.code, 2);
|
||||
assert!(err.message.contains("Usage: test [OPTIONS] <COMMAND>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cli_from_when_called_with_unknown_flag_it_should_return_usage_error() {
|
||||
let result = parse_cli_from(["test", "--bogus"]);
|
||||
@@ -125,5 +228,167 @@ mod tests {
|
||||
r#"{"error":{"code":1,"message":"Something went wrong"}}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_error_constructors_should_set_expected_codes_and_messages() {
|
||||
let info = CliError::info("info message");
|
||||
assert_eq!(info.code, 0);
|
||||
assert_eq!(info.message, "info message");
|
||||
|
||||
let runtime = CliError::runtime("runtime message");
|
||||
assert_eq!(runtime.code, 1);
|
||||
assert_eq!(runtime.message, "runtime message");
|
||||
|
||||
let usage = CliError::usage("usage message");
|
||||
assert_eq!(usage.code, 2);
|
||||
assert_eq!(usage.message, "usage message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_error_from_onspring_error_invalid_argument() {
|
||||
let err = OnspringError::InvalidArgument("bad param".to_string());
|
||||
let cli_err = CliError::from(err);
|
||||
|
||||
assert_eq!(cli_err.code, 2);
|
||||
assert_eq!(cli_err.message, "Invalid request: bad param");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_error_from_onspring_error_api_with_message() {
|
||||
let err = OnspringError::Api {
|
||||
status_code: 404,
|
||||
message: "Resource not found".to_string(),
|
||||
};
|
||||
let cli_err = CliError::from(err);
|
||||
|
||||
assert_eq!(cli_err.code, 1);
|
||||
assert_eq!(
|
||||
cli_err.message,
|
||||
"API request failed (404): Resource not found"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_error_from_onspring_error_api_with_empty_message() {
|
||||
let err = OnspringError::Api {
|
||||
status_code: 500,
|
||||
message: String::new(),
|
||||
};
|
||||
let cli_err = CliError::from(err);
|
||||
|
||||
assert_eq!(cli_err.code, 1);
|
||||
assert_eq!(cli_err.message, "API request failed with status 500.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_error_from_onspring_error_serialization() {
|
||||
let serde_err = serde_json::from_str::<serde_json::Value>("invalid json").unwrap_err();
|
||||
let err = OnspringError::Serialization(serde_err);
|
||||
let cli_err = CliError::from(err);
|
||||
|
||||
assert_eq!(cli_err.code, 1);
|
||||
assert_eq!(cli_err.message, "Failed to parse API response.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_when_missing_api_key_it_should_return_usage_error() {
|
||||
let cli = Cli {
|
||||
api_key: None,
|
||||
base_url: None,
|
||||
pretty: false,
|
||||
command: Command::Ping,
|
||||
};
|
||||
|
||||
let result = run(cli).await;
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
Err(CliError::usage(
|
||||
"Missing API Key. Set --api-key or ONSPRING_API_KEY."
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_when_empty_base_url_it_should_return_usage_error() {
|
||||
let cli = Cli {
|
||||
api_key: Some("test-api-key".to_string()),
|
||||
base_url: Some(" ".to_string()),
|
||||
pretty: false,
|
||||
command: Command::Ping,
|
||||
};
|
||||
|
||||
let result = run(cli).await;
|
||||
|
||||
assert_eq!(result, Err(CliError::usage("Base URL cannot be empty.")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_with_client_when_ping_succeeds_it_should_write_success_response() {
|
||||
let mock_client = MockClient {
|
||||
ping_result: Ok(()),
|
||||
};
|
||||
|
||||
let cli = Cli {
|
||||
api_key: Some("key".to_string()),
|
||||
base_url: None,
|
||||
pretty: false,
|
||||
command: Command::Ping,
|
||||
};
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
|
||||
let result = run_with_client(&cli, &mock_client, &mut buffer).await;
|
||||
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let written = String::from_utf8(buffer).unwrap();
|
||||
|
||||
assert_eq!(written.trim(), r#"{"ok":true}"#);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_with_client_when_ping_succeeds_pretty_it_should_write_pretty_json() {
|
||||
let mock_client = MockClient {
|
||||
ping_result: Ok(()),
|
||||
};
|
||||
|
||||
let cli = Cli {
|
||||
api_key: Some("key".to_string()),
|
||||
base_url: None,
|
||||
pretty: true,
|
||||
command: Command::Ping,
|
||||
};
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
|
||||
let result = run_with_client(&cli, &mock_client, &mut buffer).await;
|
||||
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let written = String::from_utf8(buffer).unwrap();
|
||||
|
||||
assert_eq!(written.trim(), "{\n \"ok\": true\n}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_with_client_when_ping_fails_it_should_return_mapped_cli_error() {
|
||||
let mock_client = MockClient {
|
||||
ping_result: Err(OnspringError::InvalidArgument("Invalid Key".to_string())),
|
||||
};
|
||||
|
||||
let cli = Cli {
|
||||
api_key: Some("key".to_string()),
|
||||
base_url: None,
|
||||
pretty: false,
|
||||
command: Command::Ping,
|
||||
};
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
|
||||
let result = run_with_client(&cli, &mock_client, &mut buffer).await;
|
||||
|
||||
assert_eq!(result, Err(CliError::usage("Invalid request: Invalid Key")));
|
||||
assert!(buffer.is_empty());
|
||||
}
|
||||
}
|
||||
+30
-1
@@ -1,3 +1,5 @@
|
||||
use std::io::Write;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{CliError, CliResult};
|
||||
@@ -13,6 +15,12 @@ struct ErrorEnvelope<'a> {
|
||||
pub error: ErrorBody<'a>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SuccessResponse {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
pub fn render_json<T: Serialize>(value: &T, pretty: bool) -> CliResult<String> {
|
||||
if pretty {
|
||||
serde_json::to_string_pretty(value).map_err(|_| CliError::runtime("Failed to serialize output"))
|
||||
@@ -32,6 +40,16 @@ pub fn render_error(err: &CliError, pretty: bool) -> CliResult<String> {
|
||||
render_json(&envelope, pretty)
|
||||
}
|
||||
|
||||
pub fn write_json<W: Write, T: Serialize>(
|
||||
writer: &mut W,
|
||||
value: &T,
|
||||
pretty: bool,
|
||||
) -> CliResult<()> {
|
||||
let rendered = render_json(value, pretty)?;
|
||||
writeln!(writer, "{rendered}").map_err(|_| CliError::runtime("Failed to write output"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -75,5 +93,16 @@ mod tests {
|
||||
));
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_json_when_called_with_writer_it_should_write_formatted_string() {
|
||||
let data = json!({"ok": true});
|
||||
let mut buffer = Vec::new();
|
||||
|
||||
let result = write_json(&mut buffer, &data, false);
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let output_str = String::from_utf8(buffer).unwrap();
|
||||
assert_eq!(output_str, "{\"ok\":true}\n");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user