From 30a6826f469a49db26886057f03e1c78c1d0cf24 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:58:22 -0500 Subject: [PATCH] feat: implement app commands refactored to command modules with handler funcs --- src/cli.rs | 268 ++++++++++++++++++++++++- src/client.rs | 140 +++++++++++++ src/commands/apps.rs | 461 +++++++++++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 19 ++ src/commands/ping.rs | 75 +++++++ src/error.rs | 140 +++++++++++++ src/lib.rs | 320 ++---------------------------- src/output.rs | 67 +++++++ src/validation.rs | 210 ++++++++++++++++++++ 9 files changed, 1390 insertions(+), 310 deletions(-) create mode 100644 src/client.rs create mode 100644 src/commands/apps.rs create mode 100644 src/commands/mod.rs create mode 100644 src/commands/ping.rs create mode 100644 src/error.rs create mode 100644 src/validation.rs diff --git a/src/cli.rs b/src/cli.rs index 903ef1e..38a8750 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,4 @@ -use clap::{Parser, Subcommand}; +use clap::{Args, Parser, Subcommand}; #[derive(Debug, PartialEq, Parser)] #[command(name = "onx")] @@ -28,7 +28,271 @@ pub struct Cli { } #[derive(Debug, PartialEq, Subcommand)] -pub enum Command { +pub enum Command { #[command(about = "Check connectivity to the Onspring API")] Ping, + #[command(about = "Perform operations against the apps in the instance")] + Apps { + #[command(subcommand)] + command: AppsCommand, + }, } + +#[derive(Debug, PartialEq, Subcommand)] +pub enum AppsCommand { + #[command(about = "Get information for a list of apps")] + List(PagingArgs), + + #[command(about = "Get information about an app")] + Get { + #[arg(long)] + #[arg(short = 'i')] + #[arg(help = "App id to get")] + app_id: i32, + }, + + #[command(about = "Get information for a batch of apps")] + BatchGet { + #[arg(long)] + #[arg(value_delimiter = ',')] + #[arg(short = 'i')] + #[arg(help = "Comma-separated list of app ids to get")] + ids: Vec, + }, +} + +#[derive(Debug, PartialEq, Clone, Args, Default)] +pub struct PagingArgs { + #[arg(long)] + #[arg(short = 'n')] + #[arg(help = "The page number to retrieve")] + pub page_number: Option, + + #[arg(long)] + #[arg(short = 's')] + #[arg(help = "The size of the page retrieve")] + pub page_size: Option, +} + +pub fn parse_cli_from(args: I) -> crate::error::CliResult +where + I: IntoIterator, + T: Into + Clone, +{ + use clap::error::ErrorKind; + use crate::error::CliError; + + Cli::try_parse_from(args).map_err(|e| match e.kind() { + ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => CliError::info(e.to_string()), + _ => CliError::usage(e.to_string()), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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, + command: Command::Ping, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_apps_list_it_should_return_cli() { + let result = parse_cli_from(["test", "apps", "list"]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Apps { + command: AppsCommand::List(PagingArgs::default()), + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_apps_list_paging_args_it_should_return_cli() { + let result = parse_cli_from([ + "test", + "apps", + "list", + "--page-number", + "2", + "--page-size", + "25", + ]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Apps { + command: AppsCommand::List(PagingArgs { + page_number: Some(2), + page_size: Some(25), + }), + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_apps_get_it_should_return_cli() { + let result = parse_cli_from(["test", "apps", "get", "--app-id", "123"]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Apps { + command: AppsCommand::Get { app_id: 123 }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_apps_get_short_flag_it_should_return_cli() { + let result = parse_cli_from(["test", "apps", "get", "-i", "123"]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Apps { + command: AppsCommand::Get { app_id: 123 }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_apps_get_missing_app_id_it_should_return_usage_error() { + let result = parse_cli_from(["test", "apps", "get"]); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, 2); + } + + #[test] + fn parse_cli_from_when_called_with_apps_batch_get_it_should_return_cli() { + let result = parse_cli_from(["test", "apps", "batch-get", "--ids", "1,2,3"]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Apps { + command: AppsCommand::BatchGet { + ids: vec![1, 2, 3], + }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_apps_batch_get_without_ids_it_should_return_cli_with_empty_ids() { + let result = parse_cli_from(["test", "apps", "batch-get"]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Apps { + command: AppsCommand::BatchGet { ids: vec![] }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_apps_batch_get_invalid_id_format_it_should_return_usage_error() { + let result = parse_cli_from(["test", "apps", "batch-get", "--ids", "invalid"]); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, 2); + } + + #[test] + fn parse_cli_from_when_called_with_apps_without_subcommand_it_should_return_usage_error() { + let result = parse_cli_from(["test", "apps"]); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, 2); + } + + #[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] ")); + } + + #[test] + fn parse_cli_from_when_called_with_unknown_flag_it_should_return_usage_error() { + let result = parse_cli_from(["test", "--bogus"]); + + assert!(result.is_err()); + assert_eq!(result.as_ref().unwrap_err().code, 2); + assert!( + result + .as_ref() + .unwrap_err() + .message + .contains("unexpected argument") + ); + } + + #[test] + fn parse_cli_from_when_called_with_help_flag_it_should_return_info_with_code_0() { + let result = parse_cli_from(["test", "--help"]); + + let err = result.unwrap_err(); + + assert_eq!(err.code, 0); + assert!(err.message.contains(env!("CARGO_PKG_DESCRIPTION"))); + } + + #[test] + fn parse_cli_from_when_called_with_version_flag_it_should_return_info_with_code_0() { + let result = parse_cli_from(["test", "--version"]); + + let err = result.unwrap_err(); + + assert_eq!(err.code, 0); + assert!(err.message.contains(env!("CARGO_PKG_NAME"))); + assert!(err.message.contains(env!("CARGO_PKG_VERSION"))); + } +} + diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..b201c6c --- /dev/null +++ b/src/client.rs @@ -0,0 +1,140 @@ +use onspring::{ + App, CollectionResponse, OnspringClient, OnspringError, PagedResponse, PagingRequest, +}; + +pub trait OnspringRunner { + fn ping(&self) -> impl std::future::Future> + Send; + + fn list_apps( + &self, + paging: Option, + ) -> impl std::future::Future, OnspringError>> + Send; + + fn get_app( + &self, + app_id: i32, + ) -> impl std::future::Future> + Send; + + fn batch_get_apps( + &self, + ids: &[i32], + ) -> impl std::future::Future, OnspringError>> + Send; +} + +impl OnspringRunner for OnspringClient { + async fn ping(&self) -> Result<(), OnspringError> { + self.ping().await + } + + async fn list_apps( + &self, + paging: Option, + ) -> Result, OnspringError> { + self.list_apps(paging).await + } + + async fn get_app(&self, app_id: i32) -> Result { + self.get_app(app_id).await + } + + async fn batch_get_apps(&self, ids: &[i32]) -> Result, OnspringError> { + self.batch_get_apps(ids).await + } +} + +#[cfg(test)] +pub mod testing { + use super::*; + use std::sync::Mutex; + + pub struct MockClient { + pub ping_result: Result<(), OnspringError>, + pub list_apps_result: Result, OnspringError>, + pub get_app_result: Result, + pub batch_get_apps_result: Result, OnspringError>, + pub list_apps_paging: Mutex>>, + pub get_app_id: Mutex>, + pub batch_get_apps_ids: Mutex>>, + } + + impl Default for MockClient { + fn default() -> Self { + Self { + ping_result: Ok(()), + list_apps_result: Ok(PagedResponse { + page_number: None, + page_size: None, + total_pages: None, + total_records: None, + items: None, + }), + get_app_result: Ok(App { + href: None, + id: 1, + name: None, + }), + batch_get_apps_result: Ok(CollectionResponse { + count: None, + items: None, + }), + list_apps_paging: Mutex::new(None), + get_app_id: Mutex::new(None), + batch_get_apps_ids: Mutex::new(None), + } + } + } + + fn clone_onspring_error(err: &OnspringError) -> OnspringError { + match err { + OnspringError::InvalidArgument(msg) => OnspringError::InvalidArgument(msg.clone()), + OnspringError::Api { + status_code, + message, + } => OnspringError::Api { + status_code: *status_code, + message: message.clone(), + }, + OnspringError::Serialization(_) => { + let serde_err = serde_json::from_str::("invalid").unwrap_err(); + OnspringError::Serialization(serde_err) + } + OnspringError::Http(_) => OnspringError::InvalidArgument("http error".to_string()), + } + } + + impl OnspringRunner for MockClient { + async fn ping(&self) -> Result<(), OnspringError> { + match &self.ping_result { + Ok(()) => Ok(()), + Err(err) => Err(clone_onspring_error(err)), + } + } + + async fn list_apps( + &self, + paging: Option, + ) -> Result, OnspringError> { + *self.list_apps_paging.lock().unwrap() = Some(paging); + match &self.list_apps_result { + Ok(res) => Ok(res.clone()), + Err(err) => Err(clone_onspring_error(err)), + } + } + + async fn get_app(&self, app_id: i32) -> Result { + *self.get_app_id.lock().unwrap() = Some(app_id); + match &self.get_app_result { + Ok(res) => Ok(res.clone()), + Err(err) => Err(clone_onspring_error(err)), + } + } + + async fn batch_get_apps(&self, ids: &[i32]) -> Result, OnspringError> { + *self.batch_get_apps_ids.lock().unwrap() = Some(ids.to_vec()); + match &self.batch_get_apps_result { + Ok(res) => Ok(res.clone()), + Err(err) => Err(clone_onspring_error(err)), + } + } + } +} diff --git a/src/commands/apps.rs b/src/commands/apps.rs new file mode 100644 index 0000000..589534a --- /dev/null +++ b/src/commands/apps.rs @@ -0,0 +1,461 @@ +use std::io::Write; + +use crate::cli::AppsCommand; +use crate::client::OnspringRunner; +use crate::error::CliResult; +use crate::output::{AppOutput, CollectionOutput, PagedOutput, write_json}; +use crate::validation::{paging_request, validate_ids, validate_positive_i32}; + +pub async fn handle( + command: &AppsCommand, + client: &C, + writer: &mut W, + pretty: bool, +) -> CliResult<()> { + match command { + AppsCommand::List(paging_args) => { + let paging = paging_request(paging_args)?; + let response = client.list_apps(paging).await?; + let output: PagedOutput = response.into(); + write_json(writer, &output, pretty)?; + } + AppsCommand::Get { app_id } => { + validate_positive_i32(*app_id, "app_id")?; + let response = client.get_app(*app_id).await?; + let output: AppOutput = response.into(); + write_json(writer, &output, pretty)?; + } + AppsCommand::BatchGet { ids } => { + validate_ids(ids, "ids", Some(100))?; + let response = client.batch_get_apps(ids).await?; + let output: CollectionOutput = response.into(); + write_json(writer, &output, pretty)?; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use onspring::{App, CollectionResponse, OnspringError, PagedResponse}; + + use crate::cli::PagingArgs; + use crate::client::testing::MockClient; + use crate::error::CliError; + + #[tokio::test] + async fn handle_when_apps_list_succeeds_it_should_write_paged_output() { + let mock_client = MockClient { + list_apps_result: Ok(PagedResponse { + page_number: Some(1), + page_size: Some(50), + total_pages: Some(1), + total_records: Some(1), + items: Some(vec![App { + href: Some("https://api.onspring.com/apps/1".to_string()), + id: 1, + name: Some("Test App".to_string()), + }]), + }), + ..Default::default() + }; + + let command = AppsCommand::List(PagingArgs::default()); + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!(result, Ok(())); + + let written = String::from_utf8(buffer).unwrap(); + + assert_eq!( + written.trim(), + r#"{"pageNumber":1,"pageSize":50,"totalPages":1,"totalRecords":1,"items":[{"href":"https://api.onspring.com/apps/1","id":1,"name":"Test App"}]}"# + ); + assert!( + mock_client + .list_apps_paging + .lock() + .unwrap() + .as_ref() + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn handle_when_apps_list_with_paging_args_it_should_pass_paging_request() { + let mock_client = MockClient { + list_apps_result: Ok(PagedResponse { + page_number: Some(2), + page_size: Some(10), + total_pages: Some(5), + total_records: Some(50), + items: Some(vec![]), + }), + ..Default::default() + }; + + let command = AppsCommand::List(PagingArgs { + page_number: Some(2), + page_size: Some(10), + }); + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!(result, Ok(())); + + let paging = mock_client + .list_apps_paging + .lock() + .unwrap() + .take() + .unwrap() + .unwrap(); + assert_eq!(paging.page_number, 2); + assert_eq!(paging.page_size, 10); + } + + #[tokio::test] + async fn handle_when_apps_list_succeeds_pretty_it_should_write_pretty_json() { + let mock_client = MockClient { + list_apps_result: Ok(PagedResponse { + page_number: Some(1), + page_size: Some(50), + total_pages: Some(1), + total_records: Some(1), + items: Some(vec![App { + href: Some("https://api.onspring.com/apps/1".to_string()), + id: 1, + name: Some("Test App".to_string()), + }]), + }), + ..Default::default() + }; + + let command = AppsCommand::List(PagingArgs::default()); + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, true).await; + + assert_eq!(result, Ok(())); + + let written = String::from_utf8(buffer).unwrap(); + + let expected = concat!( + "{\n", + " \"pageNumber\": 1,\n", + " \"pageSize\": 50,\n", + " \"totalPages\": 1,\n", + " \"totalRecords\": 1,\n", + " \"items\": [\n", + " {\n", + " \"href\": \"https://api.onspring.com/apps/1\",\n", + " \"id\": 1,\n", + " \"name\": \"Test App\"\n", + " }\n", + " ]\n", + "}" + ); + + assert_eq!(written.trim(), expected); + } + + #[tokio::test] + async fn handle_when_apps_list_fails_it_should_return_mapped_cli_error() { + let mock_client = MockClient { + list_apps_result: Err(OnspringError::Api { + status_code: 401, + message: "Unauthorized".to_string(), + }), + ..Default::default() + }; + + let command = AppsCommand::List(PagingArgs::default()); + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::runtime("API request failed (401): Unauthorized")) + ); + assert!(buffer.is_empty()); + } + + #[tokio::test] + async fn handle_when_apps_list_has_invalid_paging_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = AppsCommand::List(PagingArgs { + page_number: Some(0), + page_size: None, + }); + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::usage("page_number must be greater than 0.")) + ); + assert!(buffer.is_empty()); + assert!(mock_client.list_apps_paging.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn handle_when_apps_get_succeeds_it_should_write_app_output() { + let mock_client = MockClient { + get_app_result: Ok(App { + href: Some("https://api.onspring.com/apps/123".to_string()), + id: 123, + name: Some("Test App".to_string()), + }), + ..Default::default() + }; + + let command = AppsCommand::Get { app_id: 123 }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!(result, Ok(())); + + let written = String::from_utf8(buffer).unwrap(); + + assert_eq!( + written.trim(), + r#"{"href":"https://api.onspring.com/apps/123","id":123,"name":"Test App"}"# + ); + assert_eq!(*mock_client.get_app_id.lock().unwrap(), Some(123)); + } + + #[tokio::test] + async fn handle_when_apps_get_succeeds_pretty_it_should_write_pretty_json() { + let mock_client = MockClient { + get_app_result: Ok(App { + href: Some("https://api.onspring.com/apps/123".to_string()), + id: 123, + name: Some("Test App".to_string()), + }), + ..Default::default() + }; + + let command = AppsCommand::Get { app_id: 123 }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, true).await; + + assert_eq!(result, Ok(())); + + let written = String::from_utf8(buffer).unwrap(); + + let expected = concat!( + "{\n", + " \"href\": \"https://api.onspring.com/apps/123\",\n", + " \"id\": 123,\n", + " \"name\": \"Test App\"\n", + "}" + ); + + assert_eq!(written.trim(), expected); + } + + #[tokio::test] + async fn handle_when_apps_get_with_invalid_id_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = AppsCommand::Get { app_id: 0 }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::usage("app_id must be greater than 0.")) + ); + assert!(buffer.is_empty()); + assert_eq!(*mock_client.get_app_id.lock().unwrap(), None); + } + + #[tokio::test] + async fn handle_when_apps_get_fails_it_should_return_mapped_cli_error() { + let mock_client = MockClient { + get_app_result: Err(OnspringError::Api { + status_code: 404, + message: "App not found".to_string(), + }), + ..Default::default() + }; + + let command = AppsCommand::Get { app_id: 123 }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::runtime("API request failed (404): App not found")) + ); + assert!(buffer.is_empty()); + } + + #[tokio::test] + async fn handle_when_apps_batch_get_succeeds_it_should_write_collection_output() { + let mock_client = MockClient { + batch_get_apps_result: Ok(CollectionResponse { + count: Some(2), + items: Some(vec![ + App { + href: Some("https://api.onspring.com/apps/1".to_string()), + id: 1, + name: Some("App 1".to_string()), + }, + App { + href: Some("https://api.onspring.com/apps/2".to_string()), + id: 2, + name: Some("App 2".to_string()), + }, + ]), + }), + ..Default::default() + }; + + let command = AppsCommand::BatchGet { ids: vec![1, 2] }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!(result, Ok(())); + + let written = String::from_utf8(buffer).unwrap(); + + assert_eq!( + written.trim(), + r#"{"count":2,"items":[{"href":"https://api.onspring.com/apps/1","id":1,"name":"App 1"},{"href":"https://api.onspring.com/apps/2","id":2,"name":"App 2"}]}"# + ); + assert_eq!( + *mock_client.batch_get_apps_ids.lock().unwrap(), + Some(vec![1, 2]) + ); + } + + #[tokio::test] + async fn handle_when_apps_batch_get_succeeds_pretty_it_should_write_pretty_json() { + let mock_client = MockClient { + batch_get_apps_result: Ok(CollectionResponse { + count: Some(1), + items: Some(vec![App { + href: Some("https://api.onspring.com/apps/1".to_string()), + id: 1, + name: Some("App 1".to_string()), + }]), + }), + ..Default::default() + }; + + let command = AppsCommand::BatchGet { ids: vec![1] }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, true).await; + + assert_eq!(result, Ok(())); + + let written = String::from_utf8(buffer).unwrap(); + + let expected = concat!( + "{\n", + " \"count\": 1,\n", + " \"items\": [\n", + " {\n", + " \"href\": \"https://api.onspring.com/apps/1\",\n", + " \"id\": 1,\n", + " \"name\": \"App 1\"\n", + " }\n", + " ]\n", + "}" + ); + + assert_eq!(written.trim(), expected); + } + + #[tokio::test] + async fn handle_when_apps_batch_get_with_empty_ids_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = AppsCommand::BatchGet { ids: vec![] }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!(result, Err(CliError::usage("ids cannot be empty."))); + assert!(buffer.is_empty()); + assert_eq!(*mock_client.batch_get_apps_ids.lock().unwrap(), None); + } + + #[tokio::test] + async fn handle_when_apps_batch_get_with_invalid_ids_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = AppsCommand::BatchGet { + ids: vec![1, 0, 2], + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::usage("ids must only contain values > 0.")) + ); + assert!(buffer.is_empty()); + assert_eq!(*mock_client.batch_get_apps_ids.lock().unwrap(), None); + } + + #[tokio::test] + async fn handle_when_apps_batch_get_with_too_many_ids_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = AppsCommand::BatchGet { + ids: vec![1; 101], + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::usage( + "ids cannot contain more than 100 values." + )) + ); + assert!(buffer.is_empty()); + assert_eq!(*mock_client.batch_get_apps_ids.lock().unwrap(), None); + } + + #[tokio::test] + async fn handle_when_apps_batch_get_fails_it_should_return_mapped_cli_error() { + let mock_client = MockClient { + batch_get_apps_result: Err(OnspringError::Api { + status_code: 500, + message: "".to_string(), + }), + ..Default::default() + }; + + let command = AppsCommand::BatchGet { ids: vec![1, 2] }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::runtime("API request failed with status 500.")) + ); + assert!(buffer.is_empty()); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..e5760e8 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,19 @@ +pub mod apps; +pub mod ping; + +use std::io::Write; + +use crate::cli::{Cli, Command}; +use crate::client::OnspringRunner; +use crate::error::CliResult; + +pub async fn handle( + cli: &Cli, + client: &C, + writer: &mut W, +) -> CliResult<()> { + match &cli.command { + Command::Ping => ping::handle(client, writer, cli.pretty).await, + Command::Apps { command } => apps::handle(command, client, writer, cli.pretty).await, + } +} diff --git a/src/commands/ping.rs b/src/commands/ping.rs new file mode 100644 index 0000000..aeea4a0 --- /dev/null +++ b/src/commands/ping.rs @@ -0,0 +1,75 @@ +use std::io::Write; + +use crate::client::OnspringRunner; +use crate::error::CliResult; +use crate::output::{SuccessResponse, write_json}; + +pub async fn handle( + client: &C, + writer: &mut W, + pretty: bool, +) -> CliResult<()> { + client.ping().await?; + write_json(writer, &SuccessResponse { ok: true }, pretty)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use onspring::OnspringError; + + use crate::client::testing::MockClient; + use crate::error::CliError; + + #[tokio::test] + async fn handle_when_ping_succeeds_it_should_write_success_response() { + let mock_client = MockClient { + ping_result: Ok(()), + ..Default::default() + }; + + let mut buffer = Vec::new(); + + let result = handle(&mock_client, &mut buffer, false).await; + + assert_eq!(result, Ok(())); + + let written = String::from_utf8(buffer).unwrap(); + + assert_eq!(written.trim(), r#"{"ok":true}"#); + } + + #[tokio::test] + async fn handle_when_ping_succeeds_pretty_it_should_write_pretty_json() { + let mock_client = MockClient { + ping_result: Ok(()), + ..Default::default() + }; + + let mut buffer = Vec::new(); + + let result = handle(&mock_client, &mut buffer, true).await; + + assert_eq!(result, Ok(())); + + let written = String::from_utf8(buffer).unwrap(); + + assert_eq!(written.trim(), "{\n \"ok\": true\n}"); + } + + #[tokio::test] + async fn handle_when_ping_fails_it_should_return_mapped_cli_error() { + let mock_client = MockClient { + ping_result: Err(OnspringError::InvalidArgument("Invalid Key".to_string())), + ..Default::default() + }; + + let mut buffer = Vec::new(); + + let result = handle(&mock_client, &mut buffer, false).await; + + assert_eq!(result, Err(CliError::usage("Invalid request: Invalid Key"))); + assert!(buffer.is_empty()); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..8874fb5 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,140 @@ +use onspring::OnspringError; + +use crate::output::render_error; + +pub type CliResult = Result; + +#[derive(Debug, PartialEq)] +pub struct CliError { + pub code: i32, + pub message: String, +} + +impl CliError { + pub fn info(message: impl Into) -> Self { + Self { + code: 0, + message: message.into(), + } + } + + pub fn runtime(message: impl Into) -> Self { + Self { + code: 1, + message: message.into(), + } + } + + pub fn usage(message: impl Into) -> Self { + Self { + code: 2, + message: message.into(), + } + } +} + +impl From 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 fn render_cli_error(err: &CliError, pretty: bool) -> String { + render_error(err, pretty) + .unwrap_or_else(|_| "{\"error\":{\"code\":1,\"message\":\"Unexpected error.\"}}".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_cli_error_when_called_with_error_it_should_render_json() { + let error = CliError { + code: 1, + message: String::from("Something went wrong"), + }; + + let result = render_cli_error(&error, false); + + assert_eq!( + result, + 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::("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."); + } +} diff --git a/src/lib.rs b/src/lib.rs index 8e810a4..fa54b83 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,72 +1,15 @@ mod cli; +mod client; +mod commands; +mod error; mod output; +mod validation; -use clap::{Parser, error::ErrorKind}; -use cli::{Cli, Command}; +use onspring::OnspringClient; -use onspring::{OnspringClient, OnspringError}; -use output::{SuccessResponse, render_error, write_json}; - -pub type CliResult = Result; - -pub trait OnspringRunner { - fn ping(&self) -> impl std::future::Future> + Send; -} - -impl OnspringRunner for OnspringClient { - async fn ping(&self) -> Result<(), OnspringError> { - self.ping().await - } -} - -#[derive(Debug, PartialEq)] -pub struct CliError { - pub code: i32, - pub message: String, -} - -impl CliError { - pub fn info(message: impl Into) -> Self { - Self { - code: 0, - message: message.into(), - } - } - - pub fn runtime(message: impl Into) -> Self { - Self { - code: 1, - message: message.into(), - } - } - - pub fn usage(message: impl Into) -> Self { - Self { - code: 2, - message: message.into(), - } - } -} - -impl From 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 use cli::{Cli, Command, parse_cli_from}; +pub use client::OnspringRunner; +pub use error::{CliError, CliResult, render_cli_error}; pub async fn run(cli: Cli) -> CliResult<()> { let api_key = cli @@ -94,202 +37,13 @@ pub async fn run_with_client( client: &C, writer: &mut W, ) -> CliResult<()> { - match cli.command { - Command::Ping => { - client.ping().await?; - write_json(writer, &SuccessResponse { ok: true }, cli.pretty)?; - } - } - - Ok(()) -} - -pub fn parse_cli_from(args: I) -> CliResult -where - I: IntoIterator, - T: Into + Clone, -{ - Cli::try_parse_from(args).map_err(|e| match e.kind() { - ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => CliError::info(e.to_string()), - _ => CliError::usage(e.to_string()), - }) -} - -pub fn render_cli_error(err: &CliError, pretty: bool) -> String { - render_error(err, pretty) - .unwrap_or_else(|_| "{\"error\":{\"code\":1,\"message\":\"Unexpected error.\"}}".to_string()) + commands::handle(cli, client, writer).await } #[cfg(test)] 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::("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_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, - 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] ")); - } - - #[test] - fn parse_cli_from_when_called_with_unknown_flag_it_should_return_usage_error() { - let result = parse_cli_from(["test", "--bogus"]); - - assert!(result.is_err()); - assert_eq!(result.as_ref().unwrap_err().code, 2); - assert!( - result - .as_ref() - .unwrap_err() - .message - .contains("unexpected argument") - ); - } - - #[test] - fn parse_cli_from_when_called_with_help_flag_it_should_return_info_with_code_0() { - let result = parse_cli_from(["test", "--help"]); - - let err = result.unwrap_err(); - - assert_eq!(err.code, 0); - assert!(err.message.contains(env!("CARGO_PKG_DESCRIPTION"))); - } - - #[test] - fn parse_cli_from_when_called_with_version_flag_it_should_return_info_with_code_0() { - let result = parse_cli_from(["test", "--version"]); - - let err = result.unwrap_err(); - - assert_eq!(err.code, 0); - assert!(err.message.contains(env!("CARGO_PKG_NAME"))); - assert!(err.message.contains(env!("CARGO_PKG_VERSION"))); - } - - #[test] - fn render_cli_error_when_called_with_error_it_should_render_json() { - let error = CliError { - code: 1, - message: String::from("Something went wrong"), - }; - - let result = render_cli_error(&error, false); - - assert_eq!( - result, - 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::("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 { @@ -324,71 +78,21 @@ mod tests { } #[tokio::test] - async fn run_with_client_when_ping_succeeds_it_should_write_success_response() { - let mock_client = MockClient { - ping_result: Ok(()), - }; - + async fn run_with_client_it_should_delegate_to_commands() { + let mock_client = client::testing::MockClient::default(); 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()); - } } + diff --git a/src/output.rs b/src/output.rs index 6751114..af4bcd1 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,5 +1,6 @@ use std::io::Write; +use onspring::{App, CollectionResponse, PagedResponse}; use serde::Serialize; use crate::{CliError, CliResult}; @@ -21,6 +22,72 @@ pub struct SuccessResponse { pub ok: bool, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PagedOutput { + pub page_number: Option, + pub page_size: Option, + pub total_pages: Option, + pub total_records: Option, + pub items: Option>, +} + +impl From> for PagedOutput +where + T: From, +{ + fn from(value: PagedResponse) -> Self { + Self { + page_number: value.page_number, + page_size: value.page_size, + total_pages: value.total_pages, + total_records: value.total_records, + items: value + .items + .map(|items| items.into_iter().map(T::from).collect()), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CollectionOutput { + pub count: Option, + pub items: Option>, +} + +impl From> for CollectionOutput +where + T: From, +{ + fn from(value: CollectionResponse) -> Self { + Self { + count: value.count, + items: value + .items + .map(|items| items.into_iter().map(T::from).collect()), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AppOutput { + pub href: Option, + pub id: i32, + pub name: Option, +} + +impl From for AppOutput { + fn from(value: App) -> Self { + Self { + href: value.href, + id: value.id, + name: value.name, + } + } +} + pub fn render_json(value: &T, pretty: bool) -> CliResult { if pretty { serde_json::to_string_pretty(value).map_err(|_| CliError::runtime("Failed to serialize output")) diff --git a/src/validation.rs b/src/validation.rs new file mode 100644 index 0000000..e04a4e2 --- /dev/null +++ b/src/validation.rs @@ -0,0 +1,210 @@ +use onspring::PagingRequest; + +use crate::cli::PagingArgs; +use crate::error::{CliError, CliResult}; + +pub fn paging_request(args: &PagingArgs) -> CliResult> { + if args.page_number.is_none() && args.page_size.is_none() { + return Ok(None); + } + + let page_number = args.page_number.unwrap_or(1); + let page_size = args.page_size.unwrap_or(50); + + if page_number <= 0 { + return Err(CliError::usage("page_number must be greater than 0.")); + } + if page_size <= 0 { + return Err(CliError::usage("page_size must be greater than 0.")); + } + + Ok(Some(PagingRequest { + page_number, + page_size, + })) +} + +pub fn validate_positive_i32(value: i32, field: &str) -> CliResult<()> { + if value <= 0 { + return Err(CliError::usage(format!("{field} must be greater than 0."))); + } + + Ok(()) +} + +pub fn validate_ids(ids: &[i32], name: &str, max_len: Option) -> CliResult<()> { + if ids.is_empty() { + return Err(CliError::usage(format!("{name} cannot be empty."))); + } + + if let Some(max_len) = max_len + && ids.len() > max_len + { + return Err(CliError::usage(format!( + "{name} cannot contain more than {max_len} values." + ))); + } + + if ids.iter().any(|id| *id <= 0) { + return Err(CliError::usage(format!( + "{name} must only contain values > 0." + ))); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paging_request_when_no_args_it_should_return_none() { + let args = PagingArgs { + page_number: None, + page_size: None, + }; + + let result = paging_request(&args); + + assert!(matches!(result, Ok(None))); + } + + #[test] + fn paging_request_when_page_number_provided_it_should_default_page_size() { + let args = PagingArgs { + page_number: Some(3), + page_size: None, + }; + + let result = paging_request(&args).unwrap(); + let paging = result.unwrap(); + + assert_eq!(paging.page_number, 3); + assert_eq!(paging.page_size, 50); + } + + #[test] + fn paging_request_when_page_size_provided_it_should_default_page_number() { + let args = PagingArgs { + page_number: None, + page_size: Some(25), + }; + + let result = paging_request(&args).unwrap(); + let paging = result.unwrap(); + + assert_eq!(paging.page_number, 1); + assert_eq!(paging.page_size, 25); + } + + #[test] + fn paging_request_when_both_args_provided_it_should_return_paging_request() { + let args = PagingArgs { + page_number: Some(2), + page_size: Some(10), + }; + + let result = paging_request(&args).unwrap(); + let paging = result.unwrap(); + + assert_eq!(paging.page_number, 2); + assert_eq!(paging.page_size, 10); + } + + #[test] + fn paging_request_when_page_number_zero_or_negative_it_should_return_usage_error() { + let args_zero = PagingArgs { + page_number: Some(0), + page_size: Some(10), + }; + assert_eq!( + paging_request(&args_zero).unwrap_err(), + CliError::usage("page_number must be greater than 0.") + ); + + let args_neg = PagingArgs { + page_number: Some(-1), + page_size: Some(10), + }; + assert_eq!( + paging_request(&args_neg).unwrap_err(), + CliError::usage("page_number must be greater than 0.") + ); + } + + #[test] + fn paging_request_when_page_size_zero_or_negative_it_should_return_usage_error() { + let args_zero = PagingArgs { + page_number: Some(1), + page_size: Some(0), + }; + assert_eq!( + paging_request(&args_zero).unwrap_err(), + CliError::usage("page_size must be greater than 0.") + ); + + let args_neg = PagingArgs { + page_number: Some(1), + page_size: Some(-5), + }; + assert_eq!( + paging_request(&args_neg).unwrap_err(), + CliError::usage("page_size must be greater than 0.") + ); + } + + #[test] + fn validate_positive_i32_when_valid_it_should_return_ok() { + assert_eq!(validate_positive_i32(1, "app_id"), Ok(())); + assert_eq!(validate_positive_i32(100, "app_id"), Ok(())); + } + + #[test] + fn validate_positive_i32_when_zero_or_negative_it_should_return_usage_error() { + assert_eq!( + validate_positive_i32(0, "app_id"), + Err(CliError::usage("app_id must be greater than 0.")) + ); + assert_eq!( + validate_positive_i32(-1, "app_id"), + Err(CliError::usage("app_id must be greater than 0.")) + ); + } + + #[test] + fn validate_ids_when_valid_it_should_return_ok() { + assert_eq!(validate_ids(&[1, 2, 3], "ids", Some(100)), Ok(())); + } + + #[test] + fn validate_ids_when_empty_it_should_return_usage_error() { + assert_eq!( + validate_ids(&[], "ids", Some(100)), + Err(CliError::usage("ids cannot be empty.")) + ); + } + + #[test] + fn validate_ids_when_contains_zero_or_negative_it_should_return_usage_error() { + assert_eq!( + validate_ids(&[1, 0, 3], "ids", Some(100)), + Err(CliError::usage("ids must only contain values > 0.")) + ); + assert_eq!( + validate_ids(&[1, -2, 3], "ids", Some(100)), + Err(CliError::usage("ids must only contain values > 0.")) + ); + } + + #[test] + fn validate_ids_when_exceeds_max_len_it_should_return_usage_error() { + let ids = vec![1; 101]; + assert_eq!( + validate_ids(&ids, "ids", Some(100)), + Err(CliError::usage( + "ids cannot contain more than 100 values." + )) + ); + } +}