From 54346ff20f5b14a143423bb15fb2c683621975fb Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:13:22 -0500 Subject: [PATCH] feat: implement field commands --- src/cli.rs | 208 +++++++++++++++- src/client.rs | 101 +++++++- src/commands/fields.rs | 539 +++++++++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 4 +- src/output.rs | 66 ++++- 5 files changed, 908 insertions(+), 10 deletions(-) create mode 100644 src/commands/fields.rs diff --git a/src/cli.rs b/src/cli.rs index 38a8750..85bca75 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -36,6 +36,11 @@ pub enum Command { #[command(subcommand)] command: AppsCommand, }, + #[command(about = "Perform operations against the fields in the instance")] + Fields { + #[command(subcommand)] + command: FieldsCommand, + }, } #[derive(Debug, PartialEq, Subcommand)] @@ -61,6 +66,37 @@ pub enum AppsCommand { }, } +#[derive(Debug, PartialEq, Subcommand)] +pub enum FieldsCommand { + #[command(about = "Get information for a list of fields")] + List { + #[arg(long)] + #[arg(short = 'i')] + #[arg(help = "App id to get")] + app_id: i32, + + #[command(flatten)] + paging: PagingArgs, + }, + + #[command(about = "Get information about a field")] + Get { + #[arg(long)] + #[arg(short = 'i')] + #[arg(help = "Field id to get")] + field_id: i32, + }, + + #[command(about = "Get information for a batch of fields")] + BatchGet { + #[arg(long)] + #[arg(value_delimiter = ',')] + #[arg(short = 'i')] + #[arg(help = "Comma-separated list of field ids to get")] + ids: Vec, + }, +} + #[derive(Debug, PartialEq, Clone, Args, Default)] pub struct PagingArgs { #[arg(long)] @@ -79,8 +115,8 @@ where I: IntoIterator, T: Into + Clone, { - use clap::error::ErrorKind; use crate::error::CliError; + use clap::error::ErrorKind; Cli::try_parse_from(args).map_err(|e| match e.kind() { ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => CliError::info(e.to_string()), @@ -206,16 +242,15 @@ mod tests { base_url: None, pretty: false, command: Command::Apps { - command: AppsCommand::BatchGet { - ids: vec![1, 2, 3], - }, + 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() { + 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!( @@ -232,7 +267,8 @@ mod tests { } #[test] - fn parse_cli_from_when_called_with_apps_batch_get_invalid_id_format_it_should_return_usage_error() { + 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()); @@ -284,6 +320,165 @@ mod tests { assert!(err.message.contains(env!("CARGO_PKG_DESCRIPTION"))); } + #[test] + fn parse_cli_from_when_called_with_fields_list_it_should_return_cli() { + let result = parse_cli_from(["test", "fields", "list", "--app-id", "10"]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Fields { + command: FieldsCommand::List { + app_id: 10, + paging: PagingArgs::default(), + }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_fields_list_paging_args_it_should_return_cli() { + let result = parse_cli_from([ + "test", + "fields", + "list", + "-i", + "10", + "--page-number", + "2", + "--page-size", + "25", + ]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Fields { + command: FieldsCommand::List { + app_id: 10, + paging: PagingArgs { + page_number: Some(2), + page_size: Some(25), + }, + }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_fields_list_missing_app_id_it_should_return_usage_error() { + let result = parse_cli_from(["test", "fields", "list"]); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, 2); + } + + #[test] + fn parse_cli_from_when_called_with_fields_get_it_should_return_cli() { + let result = parse_cli_from(["test", "fields", "get", "--field-id", "123"]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Fields { + command: FieldsCommand::Get { field_id: 123 }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_fields_get_short_flag_it_should_return_cli() { + let result = parse_cli_from(["test", "fields", "get", "-i", "123"]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Fields { + command: FieldsCommand::Get { field_id: 123 }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_fields_get_missing_field_id_it_should_return_usage_error() { + let result = parse_cli_from(["test", "fields", "get"]); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, 2); + } + + #[test] + fn parse_cli_from_when_called_with_fields_batch_get_it_should_return_cli() { + let result = parse_cli_from(["test", "fields", "batch-get", "--ids", "1,2,3"]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Fields { + command: FieldsCommand::BatchGet { ids: vec![1, 2, 3] }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_fields_batch_get_without_ids_it_should_return_cli_with_empty_ids() + { + let result = parse_cli_from(["test", "fields", "batch-get"]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Fields { + command: FieldsCommand::BatchGet { ids: vec![] }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_fields_batch_get_invalid_id_format_it_should_return_usage_error() + { + let result = parse_cli_from(["test", "fields", "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_fields_without_subcommand_it_should_return_usage_error() { + let result = parse_cli_from(["test", "fields"]); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, 2); + } + #[test] fn parse_cli_from_when_called_with_version_flag_it_should_return_info_with_code_0() { let result = parse_cli_from(["test", "--version"]); @@ -295,4 +490,3 @@ mod tests { assert!(err.message.contains(env!("CARGO_PKG_VERSION"))); } } - diff --git a/src/client.rs b/src/client.rs index b201c6c..9337125 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,5 +1,5 @@ use onspring::{ - App, CollectionResponse, OnspringClient, OnspringError, PagedResponse, PagingRequest, + App, CollectionResponse, Field, OnspringClient, OnspringError, PagedResponse, PagingRequest, }; pub trait OnspringRunner { @@ -19,6 +19,22 @@ pub trait OnspringRunner { &self, ids: &[i32], ) -> impl std::future::Future, OnspringError>> + Send; + + fn list_fields( + &self, + app_id: i32, + paging: Option, + ) -> impl std::future::Future, OnspringError>> + Send; + + fn get_field( + &self, + field_id: i32, + ) -> impl std::future::Future> + Send; + + fn batch_get_fields( + &self, + ids: &[i32], + ) -> impl std::future::Future, OnspringError>> + Send; } impl OnspringRunner for OnspringClient { @@ -40,6 +56,22 @@ impl OnspringRunner for OnspringClient { async fn batch_get_apps(&self, ids: &[i32]) -> Result, OnspringError> { self.batch_get_apps(ids).await } + + async fn list_fields( + &self, + app_id: i32, + paging: Option, + ) -> Result, OnspringError> { + self.list_fields(app_id, paging).await + } + + async fn get_field(&self, field_id: i32) -> Result { + self.get_field(field_id).await + } + + async fn batch_get_fields(&self, ids: &[i32]) -> Result, OnspringError> { + self.batch_get_fields(ids).await + } } #[cfg(test)] @@ -55,6 +87,14 @@ pub mod testing { pub list_apps_paging: Mutex>>, pub get_app_id: Mutex>, pub batch_get_apps_ids: Mutex>>, + + pub list_fields_result: Result, OnspringError>, + pub get_field_result: Result, + pub batch_get_fields_result: Result, OnspringError>, + pub list_fields_app_id: Mutex>, + pub list_fields_paging: Mutex>>, + pub get_field_id: Mutex>, + pub batch_get_fields_ids: Mutex>>, } impl Default for MockClient { @@ -80,6 +120,36 @@ pub mod testing { list_apps_paging: Mutex::new(None), get_app_id: Mutex::new(None), batch_get_apps_ids: Mutex::new(None), + + list_fields_result: Ok(PagedResponse { + page_number: None, + page_size: None, + total_pages: None, + total_records: None, + items: None, + }), + get_field_result: Ok(Field { + id: 1, + app_id: 1, + name: None, + field_type: None, + status: None, + is_required: false, + is_unique: false, + multiplicity: None, + list_id: None, + values: None, + output_type: None, + referenced_app_id: None, + }), + batch_get_fields_result: Ok(CollectionResponse { + count: None, + items: None, + }), + list_fields_app_id: Mutex::new(None), + list_fields_paging: Mutex::new(None), + get_field_id: Mutex::new(None), + batch_get_fields_ids: Mutex::new(None), } } } @@ -136,5 +206,34 @@ pub mod testing { Err(err) => Err(clone_onspring_error(err)), } } + + async fn list_fields( + &self, + app_id: i32, + paging: Option, + ) -> Result, OnspringError> { + *self.list_fields_app_id.lock().unwrap() = Some(app_id); + *self.list_fields_paging.lock().unwrap() = Some(paging); + match &self.list_fields_result { + Ok(res) => Ok(res.clone()), + Err(err) => Err(clone_onspring_error(err)), + } + } + + async fn get_field(&self, field_id: i32) -> Result { + *self.get_field_id.lock().unwrap() = Some(field_id); + match &self.get_field_result { + Ok(res) => Ok(res.clone()), + Err(err) => Err(clone_onspring_error(err)), + } + } + + async fn batch_get_fields(&self, ids: &[i32]) -> Result, OnspringError> { + *self.batch_get_fields_ids.lock().unwrap() = Some(ids.to_vec()); + match &self.batch_get_fields_result { + Ok(res) => Ok(res.clone()), + Err(err) => Err(clone_onspring_error(err)), + } + } } } diff --git a/src/commands/fields.rs b/src/commands/fields.rs new file mode 100644 index 0000000..c826b92 --- /dev/null +++ b/src/commands/fields.rs @@ -0,0 +1,539 @@ +use std::io::Write; + +use crate::cli::FieldsCommand; +use crate::client::OnspringRunner; +use crate::error::CliResult; +use crate::output::{CollectionOutput, FieldOutput, PagedOutput, write_json}; +use crate::validation::{paging_request, validate_ids, validate_positive_i32}; + +pub async fn handle( + command: &FieldsCommand, + client: &C, + writer: &mut W, + pretty: bool, +) -> CliResult<()> { + match command { + FieldsCommand::List { app_id, paging } => { + validate_positive_i32(*app_id, "app_id")?; + let paging = paging_request(paging)?; + let response = client.list_fields(*app_id, paging).await?; + let output: PagedOutput = response.into(); + write_json(writer, &output, pretty)?; + } + FieldsCommand::Get { field_id } => { + validate_positive_i32(*field_id, "field_id")?; + let response = client.get_field(*field_id).await?; + let output: FieldOutput = response.into(); + write_json(writer, &output, pretty)?; + } + FieldsCommand::BatchGet { ids } => { + validate_ids(ids, "ids", Some(100))?; + let response = client.batch_get_fields(ids).await?; + let output: CollectionOutput = response.into(); + write_json(writer, &output, pretty)?; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use onspring::{ + CollectionResponse, Field, FormulaOutputType, ListFieldValue, Multiplicity, OnspringError, + PagedResponse, + }; + use uuid::Uuid; + + use crate::cli::PagingArgs; + use crate::client::testing::MockClient; + use crate::error::CliError; + + fn test_field(id: i32, app_id: i32, name: &str) -> Field { + Field { + id, + app_id, + name: Some(name.to_string()), + field_type: Some("Text".to_string()), + status: Some("Enabled".to_string()), + is_required: false, + is_unique: false, + multiplicity: None, + list_id: None, + values: None, + output_type: None, + referenced_app_id: None, + } + } + + #[tokio::test] + async fn handle_when_fields_list_succeeds_it_should_write_paged_output() { + let mock_client = MockClient { + list_fields_result: Ok(PagedResponse { + page_number: Some(1), + page_size: Some(50), + total_pages: Some(1), + total_records: Some(1), + items: Some(vec![test_field(1, 10, "Test Field")]), + }), + ..Default::default() + }; + + let command = FieldsCommand::List { + app_id: 10, + paging: 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":[{"id":1,"appId":10,"name":"Test Field","type":"Text","status":"Enabled","isRequired":false,"isUnique":false,"multiplicity":null,"listId":null,"values":null,"outputType":null,"referencedAppId":null}]}"# + ); + assert_eq!(*mock_client.list_fields_app_id.lock().unwrap(), Some(10)); + assert!( + mock_client + .list_fields_paging + .lock() + .unwrap() + .as_ref() + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn handle_when_fields_list_with_paging_args_it_should_pass_paging_request() { + let mock_client = MockClient { + list_fields_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 = FieldsCommand::List { + app_id: 10, + paging: 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(())); + + assert_eq!(*mock_client.list_fields_app_id.lock().unwrap(), Some(10)); + let paging = mock_client + .list_fields_paging + .lock() + .unwrap() + .take() + .unwrap() + .unwrap(); + assert_eq!(paging.page_number, 2); + assert_eq!(paging.page_size, 10); + } + + #[tokio::test] + async fn handle_when_fields_list_succeeds_pretty_it_should_write_pretty_json() { + let mock_client = MockClient { + list_fields_result: Ok(PagedResponse { + page_number: Some(1), + page_size: Some(50), + total_pages: Some(1), + total_records: Some(1), + items: Some(vec![test_field(1, 10, "Test Field")]), + }), + ..Default::default() + }; + + let command = FieldsCommand::List { + app_id: 10, + paging: 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", + " \"id\": 1,\n", + " \"appId\": 10,\n", + " \"name\": \"Test Field\",\n", + " \"type\": \"Text\",\n", + " \"status\": \"Enabled\",\n", + " \"isRequired\": false,\n", + " \"isUnique\": false,\n", + " \"multiplicity\": null,\n", + " \"listId\": null,\n", + " \"values\": null,\n", + " \"outputType\": null,\n", + " \"referencedAppId\": null\n", + " }\n", + " ]\n", + "}" + ); + + assert_eq!(written.trim(), expected); + } + + #[tokio::test] + async fn handle_when_fields_list_fails_it_should_return_mapped_cli_error() { + let mock_client = MockClient { + list_fields_result: Err(OnspringError::Api { + status_code: 401, + message: "Unauthorized".to_string(), + }), + ..Default::default() + }; + + let command = FieldsCommand::List { + app_id: 10, + paging: 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_fields_list_has_invalid_app_id_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = FieldsCommand::List { + app_id: 0, + paging: PagingArgs::default(), + }; + 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!(mock_client.list_fields_app_id.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn handle_when_fields_list_has_invalid_paging_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = FieldsCommand::List { + app_id: 10, + paging: 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_fields_paging.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn handle_when_fields_get_succeeds_it_should_write_field_output() { + let mock_client = MockClient { + get_field_result: Ok(Field { + id: 123, + app_id: 456, + name: Some("Status Field".to_string()), + field_type: Some("List".to_string()), + status: Some("Enabled".to_string()), + is_required: true, + is_unique: false, + multiplicity: Some(Multiplicity::SingleSelect), + list_id: Some(789), + values: Some(vec![ListFieldValue { + id: Uuid::nil(), + name: "Active".to_string(), + sort_order: 1, + numeric_value: Some(1.0), + color: Some("#00ff00".to_string()), + }]), + output_type: Some(FormulaOutputType::Text), + referenced_app_id: Some(999), + }), + ..Default::default() + }; + + let command = FieldsCommand::Get { field_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##"{"id":123,"appId":456,"name":"Status Field","type":"List","status":"Enabled","isRequired":true,"isUnique":false,"multiplicity":"SingleSelect","listId":789,"values":[{"id":"00000000-0000-0000-0000-000000000000","name":"Active","sortOrder":1,"numericValue":1.0,"color":"#00ff00"}],"outputType":"Text","referencedAppId":999}"## + ); + assert_eq!(*mock_client.get_field_id.lock().unwrap(), Some(123)); + } + + #[tokio::test] + async fn handle_when_fields_get_succeeds_pretty_it_should_write_pretty_json() { + let mock_client = MockClient { + get_field_result: Ok(test_field(123, 456, "Test Field")), + ..Default::default() + }; + + let command = FieldsCommand::Get { field_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", + " \"id\": 123,\n", + " \"appId\": 456,\n", + " \"name\": \"Test Field\",\n", + " \"type\": \"Text\",\n", + " \"status\": \"Enabled\",\n", + " \"isRequired\": false,\n", + " \"isUnique\": false,\n", + " \"multiplicity\": null,\n", + " \"listId\": null,\n", + " \"values\": null,\n", + " \"outputType\": null,\n", + " \"referencedAppId\": null\n", + "}" + ); + + assert_eq!(written.trim(), expected); + } + + #[tokio::test] + async fn handle_when_fields_get_with_invalid_id_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = FieldsCommand::Get { field_id: 0 }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::usage("field_id must be greater than 0.")) + ); + assert!(buffer.is_empty()); + assert_eq!(*mock_client.get_field_id.lock().unwrap(), None); + } + + #[tokio::test] + async fn handle_when_fields_get_fails_it_should_return_mapped_cli_error() { + let mock_client = MockClient { + get_field_result: Err(OnspringError::Api { + status_code: 404, + message: "Field not found".to_string(), + }), + ..Default::default() + }; + + let command = FieldsCommand::Get { field_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): Field not found")) + ); + assert!(buffer.is_empty()); + } + + #[tokio::test] + async fn handle_when_fields_batch_get_succeeds_it_should_write_collection_output() { + let mock_client = MockClient { + batch_get_fields_result: Ok(CollectionResponse { + count: Some(2), + items: Some(vec![ + test_field(1, 10, "Field 1"), + test_field(2, 10, "Field 2"), + ]), + }), + ..Default::default() + }; + + let command = FieldsCommand::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":[{"id":1,"appId":10,"name":"Field 1","type":"Text","status":"Enabled","isRequired":false,"isUnique":false,"multiplicity":null,"listId":null,"values":null,"outputType":null,"referencedAppId":null},{"id":2,"appId":10,"name":"Field 2","type":"Text","status":"Enabled","isRequired":false,"isUnique":false,"multiplicity":null,"listId":null,"values":null,"outputType":null,"referencedAppId":null}]}"# + ); + assert_eq!( + *mock_client.batch_get_fields_ids.lock().unwrap(), + Some(vec![1, 2]) + ); + } + + #[tokio::test] + async fn handle_when_fields_batch_get_succeeds_pretty_it_should_write_pretty_json() { + let mock_client = MockClient { + batch_get_fields_result: Ok(CollectionResponse { + count: Some(1), + items: Some(vec![test_field(1, 10, "Field 1")]), + }), + ..Default::default() + }; + + let command = FieldsCommand::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", + " \"id\": 1,\n", + " \"appId\": 10,\n", + " \"name\": \"Field 1\",\n", + " \"type\": \"Text\",\n", + " \"status\": \"Enabled\",\n", + " \"isRequired\": false,\n", + " \"isUnique\": false,\n", + " \"multiplicity\": null,\n", + " \"listId\": null,\n", + " \"values\": null,\n", + " \"outputType\": null,\n", + " \"referencedAppId\": null\n", + " }\n", + " ]\n", + "}" + ); + + assert_eq!(written.trim(), expected); + } + + #[tokio::test] + async fn handle_when_fields_batch_get_with_empty_ids_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = FieldsCommand::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_fields_ids.lock().unwrap(), None); + } + + #[tokio::test] + async fn handle_when_fields_batch_get_with_invalid_ids_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = FieldsCommand::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_fields_ids.lock().unwrap(), None); + } + + #[tokio::test] + async fn handle_when_fields_batch_get_with_too_many_ids_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = FieldsCommand::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_fields_ids.lock().unwrap(), None); + } + + #[tokio::test] + async fn handle_when_fields_batch_get_fails_it_should_return_mapped_cli_error() { + let mock_client = MockClient { + batch_get_fields_result: Err(OnspringError::Api { + status_code: 500, + message: "".to_string(), + }), + ..Default::default() + }; + + let command = FieldsCommand::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 index e5760e8..ce4d408 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod apps; pub mod ping; +pub mod fields; use std::io::Write; @@ -15,5 +16,6 @@ pub async fn handle( match &cli.command { Command::Ping => ping::handle(client, writer, cli.pretty).await, Command::Apps { command } => apps::handle(command, client, writer, cli.pretty).await, - } + Command::Fields { command } => fields::handle(command, client, writer, cli.pretty).await, +} } diff --git a/src/output.rs b/src/output.rs index af4bcd1..d06bc03 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,7 +1,10 @@ use std::io::Write; -use onspring::{App, CollectionResponse, PagedResponse}; +use onspring::{ + App, CollectionResponse, Field, FormulaOutputType, ListFieldValue, Multiplicity, PagedResponse, +}; use serde::Serialize; +use uuid::Uuid; use crate::{CliError, CliResult}; @@ -88,6 +91,67 @@ impl From for AppOutput { } } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListFieldValueOutput { + pub id: Uuid, + pub name: String, + pub sort_order: i32, + pub numeric_value: Option, + pub color: Option, +} + +impl From for ListFieldValueOutput { + fn from(value: ListFieldValue) -> Self { + Self { + id: value.id, + name: value.name, + sort_order: value.sort_order, + numeric_value: value.numeric_value, + color: value.color, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FieldOutput { + pub id: i32, + pub app_id: i32, + pub name: Option, + #[serde(rename = "type")] + pub field_type: Option, + pub status: Option, + pub is_required: bool, + pub is_unique: bool, + pub multiplicity: Option, + pub list_id: Option, + pub values: Option>, + pub output_type: Option, + pub referenced_app_id: Option, +} + +impl From for FieldOutput { + fn from(value: Field) -> Self { + Self { + id: value.id, + app_id: value.app_id, + name: value.name, + field_type: value.field_type, + status: value.status, + is_required: value.is_required, + is_unique: value.is_unique, + multiplicity: value.multiplicity, + list_id: value.list_id, + values: value + .values + .map(|values| values.into_iter().map(ListFieldValueOutput::from).collect()), + output_type: value.output_type, + referenced_app_id: value.referenced_app_id, + } + } +} + 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"))