From 56b5abc1d1dfe309434bcf955c50debe66d5bd7e Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:53:56 -0500 Subject: [PATCH] feat: implement record commands --- src/cli.rs | 364 +++++++++++- src/client.rs | 263 ++++++++- src/commands/mod.rs | 6 +- src/commands/records.rs | 1247 +++++++++++++++++++++++++++++++++++++++ src/output.rs | 56 ++ src/validation.rs | 180 +++++- 6 files changed, 2101 insertions(+), 15 deletions(-) create mode 100644 src/commands/records.rs diff --git a/src/cli.rs b/src/cli.rs index 85bca75..d36aa55 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,6 @@ -use clap::{Args, Parser, Subcommand}; +use std::path::PathBuf; + +use clap::{Args, Parser, Subcommand, ValueEnum}; #[derive(Debug, PartialEq, Parser)] #[command(name = "onx")] @@ -31,16 +33,24 @@ pub struct Cli { 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, }, + #[command(about = "Perform operations against the fields in the instance")] Fields { #[command(subcommand)] command: FieldsCommand, }, + + #[command(about = "Perform operations against the records in the instance")] + Records { + #[command(subcommand)] + command: RecordsCommand, + }, } #[derive(Debug, PartialEq, Subcommand)] @@ -50,17 +60,19 @@ pub enum AppsCommand { #[command(about = "Get information about an app")] Get { - #[arg(long)] + #[arg(long = "app-id")] #[arg(short = 'i')] + #[arg(alias = "app")] #[arg(help = "App id to get")] app_id: i32, }, #[command(about = "Get information for a batch of apps")] BatchGet { - #[arg(long)] + #[arg(long = "ids")] #[arg(value_delimiter = ',')] #[arg(short = 'i')] + #[arg(alias = "apps")] #[arg(help = "Comma-separated list of app ids to get")] ids: Vec, }, @@ -70,33 +82,124 @@ pub enum AppsCommand { pub enum FieldsCommand { #[command(about = "Get information for a list of fields")] List { - #[arg(long)] + #[arg(long = "app-id")] #[arg(short = 'i')] - #[arg(help = "App id to get")] + #[arg(alias = "app")] + #[arg(help = "App id whose fields will be retrieved")] app_id: i32, - + #[command(flatten)] paging: PagingArgs, }, #[command(about = "Get information about a field")] Get { - #[arg(long)] + #[arg(long = "field-id")] #[arg(short = 'i')] + #[arg(alias = "field")] #[arg(help = "Field id to get")] field_id: i32, }, #[command(about = "Get information for a batch of fields")] BatchGet { - #[arg(long)] + #[arg(long = "ids")] #[arg(value_delimiter = ',')] #[arg(short = 'i')] + #[arg(alias = "fields")] #[arg(help = "Comma-separated list of field ids to get")] ids: Vec, }, } +#[derive(Debug, PartialEq, Subcommand)] +pub enum RecordsCommand { + #[command(about = "Get information for a list of records")] + List { + #[arg(long = "app")] + #[arg(short = 'a')] + #[arg(help = "App id whose records will be retrieved")] + app_id: i32, + + #[command(flatten)] + paging: PagingArgs, + + #[arg(long = "fields")] + #[arg(value_delimiter = ',')] + #[arg(short = 'f')] + #[arg(help = "Comma-separated list of fields whose data will be included in the response")] + field_ids: Vec, + + #[arg(long)] + data_format: Option, + }, + + #[command(about = "Get information for a record")] + Get { + #[arg(long = "app")] + #[arg(short = 'a')] + #[arg(help = "App id for record to be retrieved")] + app_id: i32, + + #[arg(long = "record")] + #[arg(short = 'r')] + #[arg(help = "Record id for record to be retrieved")] + record_id: i32, + + #[arg(long = "fields")] + #[arg(value_delimiter = ',')] + #[arg(short = 'f')] + #[arg(help = "Comma-separated list of fields whose data will be included in the response")] + field_ids: Vec, + + #[arg(long = "format")] + #[arg(short = 'd')] + #[arg(help = "Format of data in response")] + data_format: Option, + }, + + #[command(about = "Add or update a record")] + #[command()] + Save { + #[command(flatten)] + body: BodySource, + }, + + #[command(about = "Delete a record")] + Delete { + #[arg(long = "app")] + #[arg(short = 'a')] + #[arg(help = "App id for record to delete")] + app_id: i32, + + #[arg(long = "record")] + #[arg(short = 'r')] + #[arg(help = "Record id for record to delete")] + record_id: i32, + }, + + #[command(about = "Get information for a batch of records")] + BatchGet { + #[command(flatten)] + body: BodySource, + }, + + #[command(about = "Get information for a list of records based on a query")] + Query { + #[command(flatten)] + body: BodySource, + + #[command(flatten)] + paging: PagingArgs, + }, + + #[command(about = "Delete a batch of records")] + BatchDelete { + #[command(flatten)] + body: BodySource, + }, +} + #[derive(Debug, PartialEq, Clone, Args, Default)] pub struct PagingArgs { #[arg(long)] @@ -110,6 +213,29 @@ pub struct PagingArgs { pub page_size: Option, } +#[derive(Debug, PartialEq, Clone, Args, Default)] +pub struct BodySource { + #[arg(long)] + #[arg(short = 'j')] + #[arg(help = "Inline JSON request body")] + pub json: Option, + + #[arg(long)] + #[arg(short = 'f')] + #[arg(help = "Path to a JSON request body file")] + pub file: Option, + + #[arg(long)] + #[arg(help = "Read JSON request body from stdin")] + pub stdin: bool, +} + +#[derive(Debug, PartialEq, Clone, Copy, ValueEnum)] +pub enum DataFormatArg { + Raw, + Formatted, +} + pub fn parse_cli_from(args: I) -> crate::error::CliResult where I: IntoIterator, @@ -444,7 +570,7 @@ mod tests { #[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!( @@ -462,7 +588,7 @@ mod tests { #[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()); @@ -489,4 +615,222 @@ mod tests { assert!(err.message.contains(env!("CARGO_PKG_NAME"))); assert!(err.message.contains(env!("CARGO_PKG_VERSION"))); } + + #[test] + fn parse_cli_from_when_called_with_records_list_it_should_return_cli() { + let result = parse_cli_from(["test", "records", "list", "--app", "1"]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Records { + command: RecordsCommand::List { + app_id: 1, + paging: PagingArgs::default(), + field_ids: vec![], + data_format: None, + }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_records_list_all_flags_it_should_return_cli() { + let result = parse_cli_from([ + "test", + "records", + "list", + "-a", + "1", + "-n", + "2", + "-s", + "10", + "-f", + "100,200", + "--data-format", + "raw", + ]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Records { + command: RecordsCommand::List { + app_id: 1, + paging: PagingArgs { + page_number: Some(2), + page_size: Some(10), + }, + field_ids: vec![100, 200], + data_format: Some(DataFormatArg::Raw), + }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_records_get_it_should_return_cli() { + let result = parse_cli_from([ + "test", "records", "get", "-a", "1", "-r", "10", "-f", "100", "-d", "formatted", + ]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Records { + command: RecordsCommand::Get { + app_id: 1, + record_id: 10, + field_ids: vec![100], + data_format: Some(DataFormatArg::Formatted), + }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_records_save_json_it_should_return_cli() { + let result = parse_cli_from([ + "test", "records", "save", "--json", r#"{"appId":1}"#, + ]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Records { + command: RecordsCommand::Save { + body: BodySource { + json: Some(r#"{"appId":1}"#.to_string()), + file: None, + stdin: false, + }, + }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_records_delete_it_should_return_cli() { + let result = parse_cli_from(["test", "records", "delete", "-a", "1", "-r", "10"]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Records { + command: RecordsCommand::Delete { + app_id: 1, + record_id: 10, + }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_records_batch_get_it_should_return_cli() { + let result = parse_cli_from([ + "test", "records", "batch-get", "-f", "input.json", + ]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Records { + command: RecordsCommand::BatchGet { + body: BodySource { + json: None, + file: Some(std::path::PathBuf::from("input.json")), + stdin: false, + }, + }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_records_query_it_should_return_cli() { + let result = parse_cli_from([ + "test", "records", "query", "--stdin", "-n", "1", "-s", "50", + ]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Records { + command: RecordsCommand::Query { + body: BodySource { + json: None, + file: None, + stdin: true, + }, + paging: PagingArgs { + page_number: Some(1), + page_size: Some(50), + }, + }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_records_batch_delete_it_should_return_cli() { + let result = parse_cli_from([ + "test", "records", "batch-delete", "-j", r#"{"appId":1,"recordIds":[10]}"#, + ]); + + assert_eq!( + result, + Ok(Cli { + api_key: None, + base_url: None, + pretty: false, + command: Command::Records { + command: RecordsCommand::BatchDelete { + body: BodySource { + json: Some(r#"{"appId":1,"recordIds":[10]}"#.to_string()), + file: None, + stdin: false, + }, + }, + }, + }) + ); + } + + #[test] + fn parse_cli_from_when_called_with_records_without_subcommand_it_should_return_usage_error() { + let result = parse_cli_from(["test", "records"]); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, 2); + } } diff --git a/src/client.rs b/src/client.rs index 9337125..3093e34 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,5 +1,7 @@ use onspring::{ - App, CollectionResponse, Field, OnspringClient, OnspringError, PagedResponse, PagingRequest, + App, BatchDeleteRecordsRequest, BatchGetRecordsRequest, CollectionResponse, DataFormat, Field, + OnspringClient, OnspringError, PagedResponse, PagingRequest, QueryRecordsRequest, Record, + SaveRecordRequest, SaveRecordResponse, }; pub trait OnspringRunner { @@ -35,6 +37,49 @@ pub trait OnspringRunner { &self, ids: &[i32], ) -> impl std::future::Future, OnspringError>> + Send; + + fn list_records( + &self, + app_id: i32, + paging: Option, + field_ids: Option<&[i32]>, + data_format: Option, + ) -> impl std::future::Future, OnspringError>> + Send; + + fn get_record( + &self, + app_id: i32, + record_id: i32, + field_ids: Option<&[i32]>, + data_format: Option, + ) -> impl std::future::Future> + Send; + + fn save_record( + &self, + request: SaveRecordRequest, + ) -> impl std::future::Future> + Send; + + fn delete_record( + &self, + app_id: i32, + record_id: i32, + ) -> impl std::future::Future> + Send; + + fn batch_get_records( + &self, + request: BatchGetRecordsRequest, + ) -> impl std::future::Future, OnspringError>> + Send; + + fn query_records( + &self, + request: QueryRecordsRequest, + paging: Option, + ) -> impl std::future::Future, OnspringError>> + Send; + + fn batch_delete_records( + &self, + request: BatchDeleteRecordsRequest, + ) -> impl std::future::Future> + Send; } impl OnspringRunner for OnspringClient { @@ -72,6 +117,59 @@ impl OnspringRunner for OnspringClient { async fn batch_get_fields(&self, ids: &[i32]) -> Result, OnspringError> { self.batch_get_fields(ids).await } + + async fn list_records( + &self, + app_id: i32, + paging: Option, + field_ids: Option<&[i32]>, + data_format: Option, + ) -> Result, OnspringError> { + self.list_records(app_id, paging, field_ids, data_format).await + } + + async fn get_record( + &self, + app_id: i32, + record_id: i32, + field_ids: Option<&[i32]>, + data_format: Option, + ) -> Result { + self.get_record(app_id, record_id, field_ids, data_format).await + } + + async fn save_record( + &self, + request: SaveRecordRequest, + ) -> Result { + self.save_record(request).await + } + + async fn delete_record(&self, app_id: i32, record_id: i32) -> Result<(), OnspringError> { + self.delete_record(app_id, record_id).await + } + + async fn batch_get_records( + &self, + request: BatchGetRecordsRequest, + ) -> Result, OnspringError> { + self.batch_get_records(request).await + } + + async fn query_records( + &self, + request: QueryRecordsRequest, + paging: Option, + ) -> Result, OnspringError> { + self.query_records(request, paging).await + } + + async fn batch_delete_records( + &self, + request: BatchDeleteRecordsRequest, + ) -> Result<(), OnspringError> { + self.batch_delete_records(request).await + } } #[cfg(test)] @@ -95,6 +193,32 @@ pub mod testing { pub list_fields_paging: Mutex>>, pub get_field_id: Mutex>, pub batch_get_fields_ids: Mutex>>, + + pub list_records_result: Result, OnspringError>, + pub get_record_result: Result, + pub save_record_result: Result, + pub delete_record_result: Result<(), OnspringError>, + pub batch_get_records_result: Result, OnspringError>, + pub query_records_result: Result, OnspringError>, + pub batch_delete_records_result: Result<(), OnspringError>, + + pub list_records_app_id: Mutex>, + pub list_records_paging: Mutex>>, + pub list_records_field_ids: Mutex>>>, + pub list_records_data_format: Mutex>>, + + pub get_record_app_id: Mutex>, + pub get_record_record_id: Mutex>, + pub get_record_field_ids: Mutex>>>, + pub get_record_data_format: Mutex>>, + + pub save_record_request: Mutex>, + pub delete_record_app_id: Mutex>, + pub delete_record_record_id: Mutex>, + pub batch_get_records_request: Mutex>, + pub query_records_request: Mutex>, + pub query_records_paging: Mutex>>, + pub batch_delete_records_request: Mutex>, } impl Default for MockClient { @@ -150,6 +274,54 @@ pub mod testing { list_fields_paging: Mutex::new(None), get_field_id: Mutex::new(None), batch_get_fields_ids: Mutex::new(None), + + list_records_result: Ok(PagedResponse { + page_number: None, + page_size: None, + total_pages: None, + total_records: None, + items: None, + }), + get_record_result: Ok(Record { + app_id: 1, + record_id: 1, + field_data: None, + }), + save_record_result: Ok(SaveRecordResponse { + id: 1, + warnings: None, + }), + delete_record_result: Ok(()), + batch_get_records_result: Ok(CollectionResponse { + count: None, + items: None, + }), + query_records_result: Ok(PagedResponse { + page_number: None, + page_size: None, + total_pages: None, + total_records: None, + items: None, + }), + batch_delete_records_result: Ok(()), + + list_records_app_id: Mutex::new(None), + list_records_paging: Mutex::new(None), + list_records_field_ids: Mutex::new(None), + list_records_data_format: Mutex::new(None), + + get_record_app_id: Mutex::new(None), + get_record_record_id: Mutex::new(None), + get_record_field_ids: Mutex::new(None), + get_record_data_format: Mutex::new(None), + + save_record_request: Mutex::new(None), + delete_record_app_id: Mutex::new(None), + delete_record_record_id: Mutex::new(None), + batch_get_records_request: Mutex::new(None), + query_records_request: Mutex::new(None), + query_records_paging: Mutex::new(None), + batch_delete_records_request: Mutex::new(None), } } } @@ -235,5 +407,94 @@ pub mod testing { Err(err) => Err(clone_onspring_error(err)), } } + + async fn list_records( + &self, + app_id: i32, + paging: Option, + field_ids: Option<&[i32]>, + data_format: Option, + ) -> Result, OnspringError> { + *self.list_records_app_id.lock().unwrap() = Some(app_id); + *self.list_records_paging.lock().unwrap() = Some(paging); + *self.list_records_field_ids.lock().unwrap() = Some(field_ids.map(|ids| ids.to_vec())); + *self.list_records_data_format.lock().unwrap() = Some(data_format); + match &self.list_records_result { + Ok(res) => Ok(res.clone()), + Err(err) => Err(clone_onspring_error(err)), + } + } + + async fn get_record( + &self, + app_id: i32, + record_id: i32, + field_ids: Option<&[i32]>, + data_format: Option, + ) -> Result { + *self.get_record_app_id.lock().unwrap() = Some(app_id); + *self.get_record_record_id.lock().unwrap() = Some(record_id); + *self.get_record_field_ids.lock().unwrap() = Some(field_ids.map(|ids| ids.to_vec())); + *self.get_record_data_format.lock().unwrap() = Some(data_format); + match &self.get_record_result { + Ok(res) => Ok(res.clone()), + Err(err) => Err(clone_onspring_error(err)), + } + } + + async fn save_record( + &self, + request: SaveRecordRequest, + ) -> Result { + *self.save_record_request.lock().unwrap() = Some(request); + match &self.save_record_result { + Ok(res) => Ok(res.clone()), + Err(err) => Err(clone_onspring_error(err)), + } + } + + async fn delete_record(&self, app_id: i32, record_id: i32) -> Result<(), OnspringError> { + *self.delete_record_app_id.lock().unwrap() = Some(app_id); + *self.delete_record_record_id.lock().unwrap() = Some(record_id); + match &self.delete_record_result { + Ok(()) => Ok(()), + Err(err) => Err(clone_onspring_error(err)), + } + } + + async fn batch_get_records( + &self, + request: BatchGetRecordsRequest, + ) -> Result, OnspringError> { + *self.batch_get_records_request.lock().unwrap() = Some(request); + match &self.batch_get_records_result { + Ok(res) => Ok(res.clone()), + Err(err) => Err(clone_onspring_error(err)), + } + } + + async fn query_records( + &self, + request: QueryRecordsRequest, + paging: Option, + ) -> Result, OnspringError> { + *self.query_records_request.lock().unwrap() = Some(request); + *self.query_records_paging.lock().unwrap() = Some(paging); + match &self.query_records_result { + Ok(res) => Ok(res.clone()), + Err(err) => Err(clone_onspring_error(err)), + } + } + + async fn batch_delete_records( + &self, + request: BatchDeleteRecordsRequest, + ) -> Result<(), OnspringError> { + *self.batch_delete_records_request.lock().unwrap() = Some(request); + match &self.batch_delete_records_result { + Ok(()) => Ok(()), + Err(err) => Err(clone_onspring_error(err)), + } + } } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index ce4d408..f0879fe 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod apps; -pub mod ping; pub mod fields; +pub mod ping; +pub mod records; use std::io::Write; @@ -17,5 +18,6 @@ pub async fn handle( 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, -} + Command::Records { command } => records::handle(command, client, writer, cli.pretty).await + } } diff --git a/src/commands/records.rs b/src/commands/records.rs new file mode 100644 index 0000000..8f75bca --- /dev/null +++ b/src/commands/records.rs @@ -0,0 +1,1247 @@ +use std::collections::HashMap; +use std::io::{Read, Write}; + +use onspring::{ + BatchDeleteRecordsRequest, BatchGetRecordsRequest, DataFormat, QueryRecordsRequest, + SaveRecordRequest, +}; +use serde::Deserialize; + +use crate::cli::{DataFormatArg, RecordsCommand}; +use crate::client::OnspringRunner; +use crate::error::CliResult; +use crate::output::{ + CollectionOutput, PagedOutput, RecordOutput, SaveRecordResponseOutput, SuccessResponse, + write_json, +}; +use crate::validation::{parse_body, paging_request, validate_positive_i32}; + +impl From for DataFormat { + fn from(value: DataFormatArg) -> Self { + match value { + DataFormatArg::Raw => DataFormat::Raw, + DataFormatArg::Formatted => DataFormat::Formatted, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveRecordInput { + pub app_id: i32, + pub record_id: Option, + pub fields: HashMap, +} + +impl From for SaveRecordRequest { + fn from(value: SaveRecordInput) -> Self { + Self { + app_id: value.app_id, + record_id: value.record_id, + fields: value.fields, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BatchGetRecordsInput { + pub app_id: i32, + pub record_ids: Vec, + pub field_ids: Option>, + pub data_format: Option, +} + +impl From for BatchGetRecordsRequest { + fn from(value: BatchGetRecordsInput) -> Self { + Self { + app_id: value.app_id, + record_ids: value.record_ids, + field_ids: value.field_ids, + data_format: value.data_format, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueryRecordsInput { + pub app_id: i32, + pub filter: String, + pub field_ids: Option>, + pub data_format: Option, +} + +impl From for QueryRecordsRequest { + fn from(value: QueryRecordsInput) -> Self { + Self { + app_id: value.app_id, + filter: value.filter, + field_ids: value.field_ids, + data_format: value.data_format, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BatchDeleteRecordsInput { + pub app_id: i32, + pub record_ids: Vec, +} + +impl From for BatchDeleteRecordsRequest { + fn from(value: BatchDeleteRecordsInput) -> Self { + Self { + app_id: value.app_id, + record_ids: value.record_ids, + } + } +} + +pub async fn handle( + command: &RecordsCommand, + client: &C, + writer: &mut W, + pretty: bool, +) -> CliResult<()> { + handle_with_reader(command, client, writer, std::io::stdin(), pretty).await +} + +pub async fn handle_with_reader( + command: &RecordsCommand, + client: &C, + writer: &mut W, + reader: R, + pretty: bool, +) -> CliResult<()> { + match command { + RecordsCommand::List { + app_id, + paging, + field_ids, + data_format, + } => { + validate_positive_i32(*app_id, "app_id")?; + let paging = paging_request(paging)?; + let field_ids_slice = if field_ids.is_empty() { + None + } else { + Some(field_ids.as_slice()) + }; + let data_format = data_format.map(Into::into); + + let response = client + .list_records(*app_id, paging, field_ids_slice, data_format) + .await?; + let output: PagedOutput = response.into(); + write_json(writer, &output, pretty)?; + } + RecordsCommand::Get { + app_id, + record_id, + field_ids, + data_format, + } => { + validate_positive_i32(*app_id, "app_id")?; + validate_positive_i32(*record_id, "record_id")?; + let field_ids_slice = if field_ids.is_empty() { + None + } else { + Some(field_ids.as_slice()) + }; + let data_format = data_format.map(Into::into); + + let response = client + .get_record(*app_id, *record_id, field_ids_slice, data_format) + .await?; + let output: RecordOutput = response.into(); + write_json(writer, &output, pretty)?; + } + RecordsCommand::Save { body } => { + let input: SaveRecordInput = parse_body(body, reader)?; + let request: SaveRecordRequest = input.into(); + let response = client.save_record(request).await?; + let output: SaveRecordResponseOutput = response.into(); + write_json(writer, &output, pretty)?; + } + RecordsCommand::Delete { app_id, record_id } => { + validate_positive_i32(*app_id, "app_id")?; + validate_positive_i32(*record_id, "record_id")?; + client.delete_record(*app_id, *record_id).await?; + write_json(writer, &SuccessResponse { ok: true }, pretty)?; + } + RecordsCommand::BatchGet { body } => { + let input: BatchGetRecordsInput = parse_body(body, reader)?; + let request: BatchGetRecordsRequest = input.into(); + let response = client.batch_get_records(request).await?; + let output: CollectionOutput = response.into(); + write_json(writer, &output, pretty)?; + } + RecordsCommand::Query { body, paging } => { + let input: QueryRecordsInput = parse_body(body, reader)?; + let request: QueryRecordsRequest = input.into(); + let paging = paging_request(paging)?; + let response = client.query_records(request, paging).await?; + let output: PagedOutput = response.into(); + write_json(writer, &output, pretty)?; + } + RecordsCommand::BatchDelete { body } => { + let input: BatchDeleteRecordsInput = parse_body(body, reader)?; + let request: BatchDeleteRecordsRequest = input.into(); + client.batch_delete_records(request).await?; + write_json(writer, &SuccessResponse { ok: true }, pretty)?; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use onspring::{ + CollectionResponse, OnspringError, PagedResponse, Record, RecordFieldValue, SaveRecordResponse, + ValueType, + }; + use serde_json::json; + + use crate::cli::{BodySource, PagingArgs}; + use crate::client::testing::MockClient; + use crate::error::CliError; + + fn test_record(app_id: i32, record_id: i32) -> Record { + Record { + app_id, + record_id, + field_data: Some(vec![RecordFieldValue { + value_type: ValueType::String, + field_id: 100, + value: json!("Test Value"), + }]), + } + } + + // --- List tests --- + + #[tokio::test] + async fn handle_when_records_list_succeeds_it_should_write_paged_output() { + let mock_client = MockClient { + list_records_result: Ok(PagedResponse { + page_number: Some(1), + page_size: Some(50), + total_pages: Some(1), + total_records: Some(1), + items: Some(vec![test_record(1, 10)]), + }), + ..Default::default() + }; + + let command = RecordsCommand::List { + app_id: 1, + paging: PagingArgs::default(), + field_ids: vec![], + data_format: None, + }; + 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":[{"appId":1,"recordId":10,"fieldData":[{"type":"String","fieldId":100,"value":"Test Value"}]}]}"# + ); + assert_eq!(*mock_client.list_records_app_id.lock().unwrap(), Some(1)); + assert!( + mock_client + .list_records_paging + .lock() + .unwrap() + .as_ref() + .unwrap() + .is_none() + ); + assert!( + mock_client + .list_records_field_ids + .lock() + .unwrap() + .as_ref() + .unwrap() + .is_none() + ); + assert!( + mock_client + .list_records_data_format + .lock() + .unwrap() + .as_ref() + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn handle_when_records_list_with_args_it_should_pass_all_args() { + let mock_client = MockClient { + list_records_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 = RecordsCommand::List { + app_id: 1, + paging: PagingArgs { + page_number: Some(2), + page_size: Some(10), + }, + field_ids: vec![100, 200], + data_format: Some(DataFormatArg::Formatted), + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!(result, Ok(())); + + assert_eq!(*mock_client.list_records_app_id.lock().unwrap(), Some(1)); + let paging = mock_client + .list_records_paging + .lock() + .unwrap() + .as_ref() + .unwrap() + .clone() + .unwrap(); + assert_eq!(paging.page_number, 2); + assert_eq!(paging.page_size, 10); + assert_eq!( + *mock_client.list_records_field_ids.lock().unwrap(), + Some(Some(vec![100, 200])) + ); + assert_eq!( + *mock_client.list_records_data_format.lock().unwrap(), + Some(Some(DataFormat::Formatted)) + ); + } + + #[tokio::test] + async fn handle_when_records_list_succeeds_pretty_it_should_write_pretty_json() { + let mock_client = MockClient { + list_records_result: Ok(PagedResponse { + page_number: Some(1), + page_size: Some(50), + total_pages: Some(1), + total_records: Some(1), + items: Some(vec![test_record(1, 10)]), + }), + ..Default::default() + }; + + let command = RecordsCommand::List { + app_id: 1, + paging: PagingArgs::default(), + field_ids: vec![], + data_format: None, + }; + 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", + " \"appId\": 1,\n", + " \"recordId\": 10,\n", + " \"fieldData\": [\n", + " {\n", + " \"type\": \"String\",\n", + " \"fieldId\": 100,\n", + " \"value\": \"Test Value\"\n", + " }\n", + " ]\n", + " }\n", + " ]\n", + "}" + ); + + assert_eq!(written.trim(), expected); + } + + #[tokio::test] + async fn handle_when_records_list_fails_it_should_return_mapped_cli_error() { + let mock_client = MockClient { + list_records_result: Err(OnspringError::Api { + status_code: 401, + message: "Unauthorized".to_string(), + }), + ..Default::default() + }; + + let command = RecordsCommand::List { + app_id: 1, + paging: PagingArgs::default(), + field_ids: vec![], + data_format: None, + }; + 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_records_list_has_invalid_app_id_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = RecordsCommand::List { + app_id: 0, + paging: PagingArgs::default(), + field_ids: vec![], + data_format: None, + }; + 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_records_app_id.lock().unwrap().is_none()); + } + + #[tokio::test] + async fn handle_when_records_list_has_invalid_paging_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = RecordsCommand::List { + app_id: 1, + paging: PagingArgs { + page_number: Some(-1), + page_size: None, + }, + field_ids: vec![], + data_format: 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()); + } + + // --- Get tests --- + + #[tokio::test] + async fn handle_when_records_get_succeeds_it_should_write_record_output() { + let mock_client = MockClient { + get_record_result: Ok(test_record(1, 10)), + ..Default::default() + }; + + let command = RecordsCommand::Get { + app_id: 1, + record_id: 10, + field_ids: vec![], + data_format: None, + }; + 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#"{"appId":1,"recordId":10,"fieldData":[{"type":"String","fieldId":100,"value":"Test Value"}]}"# + ); + assert_eq!(*mock_client.get_record_app_id.lock().unwrap(), Some(1)); + assert_eq!(*mock_client.get_record_record_id.lock().unwrap(), Some(10)); + assert!( + mock_client + .get_record_field_ids + .lock() + .unwrap() + .as_ref() + .unwrap() + .is_none() + ); + assert!( + mock_client + .get_record_data_format + .lock() + .unwrap() + .as_ref() + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn handle_when_records_get_with_args_it_should_pass_all_args() { + let mock_client = MockClient { + get_record_result: Ok(test_record(1, 10)), + ..Default::default() + }; + + let command = RecordsCommand::Get { + app_id: 1, + record_id: 10, + field_ids: vec![100], + data_format: Some(DataFormatArg::Raw), + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!(result, Ok(())); + + assert_eq!(*mock_client.get_record_app_id.lock().unwrap(), Some(1)); + assert_eq!(*mock_client.get_record_record_id.lock().unwrap(), Some(10)); + assert_eq!( + *mock_client.get_record_field_ids.lock().unwrap(), + Some(Some(vec![100])) + ); + assert_eq!( + *mock_client.get_record_data_format.lock().unwrap(), + Some(Some(DataFormat::Raw)) + ); + } + + #[tokio::test] + async fn handle_when_records_get_succeeds_pretty_it_should_write_pretty_json() { + let mock_client = MockClient { + get_record_result: Ok(test_record(1, 10)), + ..Default::default() + }; + + let command = RecordsCommand::Get { + app_id: 1, + record_id: 10, + field_ids: vec![], + data_format: None, + }; + 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", + " \"appId\": 1,\n", + " \"recordId\": 10,\n", + " \"fieldData\": [\n", + " {\n", + " \"type\": \"String\",\n", + " \"fieldId\": 100,\n", + " \"value\": \"Test Value\"\n", + " }\n", + " ]\n", + "}" + ); + + assert_eq!(written.trim(), expected); + } + + #[tokio::test] + async fn handle_when_records_get_fails_it_should_return_mapped_cli_error() { + let mock_client = MockClient { + get_record_result: Err(OnspringError::Api { + status_code: 404, + message: "Record not found".to_string(), + }), + ..Default::default() + }; + + let command = RecordsCommand::Get { + app_id: 1, + record_id: 999, + field_ids: vec![], + data_format: None, + }; + 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): Record not found" + )) + ); + assert!(buffer.is_empty()); + } + + #[tokio::test] + async fn handle_when_records_get_with_invalid_app_id_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = RecordsCommand::Get { + app_id: -1, + record_id: 10, + field_ids: vec![], + data_format: None, + }; + 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()); + } + + #[tokio::test] + async fn handle_when_records_get_with_invalid_record_id_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = RecordsCommand::Get { + app_id: 1, + record_id: 0, + field_ids: vec![], + data_format: None, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::usage("record_id must be greater than 0.")) + ); + assert!(buffer.is_empty()); + } + + // --- Save tests --- + + #[tokio::test] + async fn handle_when_records_save_succeeds_it_should_write_save_response() { + let mock_client = MockClient { + save_record_result: Ok(SaveRecordResponse { + id: 42, + warnings: Some(vec!["Test warning".to_string()]), + }), + ..Default::default() + }; + + let command = RecordsCommand::Save { + body: BodySource { + json: Some(r#"{"appId":1,"fields":{"100":"New Value"}}"#.to_string()), + file: None, + stdin: false, + }, + }; + 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":42,"warnings":["Test warning"]}"# + ); + let req = mock_client.save_record_request.lock().unwrap().clone().unwrap(); + assert_eq!(req.app_id, 1); + assert_eq!(req.fields.get("100"), Some(&json!("New Value"))); + } + + #[tokio::test] + async fn handle_when_records_save_succeeds_pretty_it_should_write_pretty_json() { + let mock_client = MockClient { + save_record_result: Ok(SaveRecordResponse { + id: 42, + warnings: None, + }), + ..Default::default() + }; + + let command = RecordsCommand::Save { + body: BodySource { + json: Some(r#"{"appId":1,"fields":{}}"#.to_string()), + file: None, + stdin: false, + }, + }; + 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\": 42,\n", + " \"warnings\": null\n", + "}" + ); + assert_eq!(written.trim(), expected); + } + + #[tokio::test] + async fn handle_when_records_save_fails_it_should_return_mapped_cli_error() { + let mock_client = MockClient { + save_record_result: Err(OnspringError::Api { + status_code: 400, + message: "Invalid field value".to_string(), + }), + ..Default::default() + }; + + let command = RecordsCommand::Save { + body: BodySource { + json: Some(r#"{"appId":1,"fields":{}}"#.to_string()), + file: None, + stdin: false, + }, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::runtime( + "API request failed (400): Invalid field value" + )) + ); + } + + #[tokio::test] + async fn handle_when_records_save_has_missing_body_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = RecordsCommand::Save { + body: BodySource::default(), + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::usage( + "A request body is required. Provide --json, --file, or --stdin." + )) + ); + } + + #[tokio::test] + async fn handle_when_records_save_reads_from_stdin() { + let mock_client = MockClient { + save_record_result: Ok(SaveRecordResponse { + id: 77, + warnings: None, + }), + ..Default::default() + }; + + let command = RecordsCommand::Save { + body: BodySource { + json: None, + file: None, + stdin: true, + }, + }; + let mut buffer = Vec::new(); + let stdin_data = r#"{"appId":2,"fields":{"100":"From Stdin"}}"#; + + let result = handle_with_reader( + &command, + &mock_client, + &mut buffer, + stdin_data.as_bytes(), + false, + ) + .await; + + assert_eq!(result, Ok(())); + let written = String::from_utf8(buffer).unwrap(); + assert_eq!(written.trim(), r#"{"id":77,"warnings":null}"#); + let req = mock_client.save_record_request.lock().unwrap().clone().unwrap(); + assert_eq!(req.app_id, 2); + } + + // --- Delete tests --- + + #[tokio::test] + async fn handle_when_records_delete_succeeds_it_should_write_success_response() { + let mock_client = MockClient { + delete_record_result: Ok(()), + ..Default::default() + }; + + let command = RecordsCommand::Delete { + app_id: 1, + record_id: 10, + }; + 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#"{"ok":true}"#); + assert_eq!(*mock_client.delete_record_app_id.lock().unwrap(), Some(1)); + assert_eq!(*mock_client.delete_record_record_id.lock().unwrap(), Some(10)); + } + + #[tokio::test] + async fn handle_when_records_delete_succeeds_pretty_it_should_write_pretty_json() { + let mock_client = MockClient { + delete_record_result: Ok(()), + ..Default::default() + }; + + let command = RecordsCommand::Delete { + app_id: 1, + record_id: 10, + }; + 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", " \"ok\": true\n", "}"); + assert_eq!(written.trim(), expected); + } + + #[tokio::test] + async fn handle_when_records_delete_fails_it_should_return_mapped_cli_error() { + let mock_client = MockClient { + delete_record_result: Err(OnspringError::Api { + status_code: 404, + message: "Record not found".to_string(), + }), + ..Default::default() + }; + + let command = RecordsCommand::Delete { + app_id: 1, + record_id: 999, + }; + 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): Record not found" + )) + ); + } + + #[tokio::test] + async fn handle_when_records_delete_with_invalid_app_id_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = RecordsCommand::Delete { + app_id: 0, + record_id: 10, + }; + 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.")) + ); + } + + #[tokio::test] + async fn handle_when_records_delete_with_invalid_record_id_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = RecordsCommand::Delete { + app_id: 1, + record_id: -5, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::usage("record_id must be greater than 0.")) + ); + } + + // --- BatchGet tests --- + + #[tokio::test] + async fn handle_when_records_batch_get_succeeds_it_should_write_collection_output() { + let mock_client = MockClient { + batch_get_records_result: Ok(CollectionResponse { + count: Some(1), + items: Some(vec![test_record(1, 10)]), + }), + ..Default::default() + }; + + let command = RecordsCommand::BatchGet { + body: BodySource { + json: Some(r#"{"appId":1,"recordIds":[10]}"#.to_string()), + file: None, + stdin: false, + }, + }; + 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":1,"items":[{"appId":1,"recordId":10,"fieldData":[{"type":"String","fieldId":100,"value":"Test Value"}]}]}"# + ); + let req = mock_client + .batch_get_records_request + .lock() + .unwrap() + .clone() + .unwrap(); + assert_eq!(req.app_id, 1); + assert_eq!(req.record_ids, vec![10]); + } + + #[tokio::test] + async fn handle_when_records_batch_get_succeeds_pretty_it_should_write_pretty_json() { + let mock_client = MockClient { + batch_get_records_result: Ok(CollectionResponse { + count: Some(1), + items: Some(vec![test_record(1, 10)]), + }), + ..Default::default() + }; + + let command = RecordsCommand::BatchGet { + body: BodySource { + json: Some(r#"{"appId":1,"recordIds":[10]}"#.to_string()), + file: None, + stdin: false, + }, + }; + 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 val: serde_json::Value = serde_json::from_str(&written).unwrap(); + assert_eq!(val["count"], 1); + assert!(written.contains(" \"count\": 1")); + } + + #[tokio::test] + async fn handle_when_records_batch_get_fails_it_should_return_mapped_cli_error() { + let mock_client = MockClient { + batch_get_records_result: Err(OnspringError::Api { + status_code: 500, + message: "Internal Server Error".to_string(), + }), + ..Default::default() + }; + + let command = RecordsCommand::BatchGet { + body: BodySource { + json: Some(r#"{"appId":1,"recordIds":[10]}"#.to_string()), + file: None, + stdin: false, + }, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::runtime( + "API request failed (500): Internal Server Error" + )) + ); + } + + // --- Query tests --- + + #[tokio::test] + async fn handle_when_records_query_succeeds_it_should_write_paged_output() { + let mock_client = MockClient { + query_records_result: Ok(PagedResponse { + page_number: Some(1), + page_size: Some(50), + total_pages: Some(1), + total_records: Some(1), + items: Some(vec![test_record(1, 10)]), + }), + ..Default::default() + }; + + let command = RecordsCommand::Query { + body: BodySource { + json: Some(r#"{"appId":1,"filter":"100 eq 'Test'"}"#.to_string()), + file: None, + stdin: false, + }, + 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":[{"appId":1,"recordId":10,"fieldData":[{"type":"String","fieldId":100,"value":"Test Value"}]}]}"# + ); + let req = mock_client + .query_records_request + .lock() + .unwrap() + .clone() + .unwrap(); + assert_eq!(req.app_id, 1); + assert_eq!(req.filter, "100 eq 'Test'"); + } + + #[tokio::test] + async fn handle_when_records_query_with_paging_it_should_pass_paging_request() { + let mock_client = MockClient { + query_records_result: Ok(PagedResponse { + page_number: Some(3), + page_size: Some(25), + total_pages: Some(4), + total_records: Some(100), + items: Some(vec![]), + }), + ..Default::default() + }; + + let command = RecordsCommand::Query { + body: BodySource { + json: Some(r#"{"appId":1,"filter":"100 eq 'Test'"}"#.to_string()), + file: None, + stdin: false, + }, + paging: PagingArgs { + page_number: Some(3), + page_size: Some(25), + }, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!(result, Ok(())); + + let paging = mock_client + .query_records_paging + .lock() + .unwrap() + .as_ref() + .unwrap() + .clone() + .unwrap(); + assert_eq!(paging.page_number, 3); + assert_eq!(paging.page_size, 25); + } + + #[tokio::test] + async fn handle_when_records_query_fails_it_should_return_mapped_cli_error() { + let mock_client = MockClient { + query_records_result: Err(OnspringError::Api { + status_code: 400, + message: "Invalid filter expression".to_string(), + }), + ..Default::default() + }; + + let command = RecordsCommand::Query { + body: BodySource { + json: Some(r#"{"appId":1,"filter":"invalid"}"#.to_string()), + file: None, + stdin: false, + }, + 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 (400): Invalid filter expression" + )) + ); + } + + #[tokio::test] + async fn handle_when_records_query_has_invalid_paging_it_should_return_usage_error() { + let mock_client = MockClient::default(); + + let command = RecordsCommand::Query { + body: BodySource { + json: Some(r#"{"appId":1,"filter":"100 eq 'Test'"}"#.to_string()), + file: None, + stdin: false, + }, + 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.")) + ); + } + + // --- BatchDelete tests --- + + #[tokio::test] + async fn handle_when_records_batch_delete_succeeds_it_should_write_success_response() { + let mock_client = MockClient { + batch_delete_records_result: Ok(()), + ..Default::default() + }; + + let command = RecordsCommand::BatchDelete { + body: BodySource { + json: Some(r#"{"appId":1,"recordIds":[10,20]}"#.to_string()), + file: None, + stdin: false, + }, + }; + 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#"{"ok":true}"#); + let req = mock_client + .batch_delete_records_request + .lock() + .unwrap() + .clone() + .unwrap(); + assert_eq!(req.app_id, 1); + assert_eq!(req.record_ids, vec![10, 20]); + } + + #[tokio::test] + async fn handle_when_records_batch_delete_succeeds_pretty_it_should_write_pretty_json() { + let mock_client = MockClient { + batch_delete_records_result: Ok(()), + ..Default::default() + }; + + let command = RecordsCommand::BatchDelete { + body: BodySource { + json: Some(r#"{"appId":1,"recordIds":[10,20]}"#.to_string()), + file: None, + stdin: false, + }, + }; + 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", " \"ok\": true\n", "}"); + assert_eq!(written.trim(), expected); + } + + #[tokio::test] + async fn handle_when_records_batch_delete_fails_it_should_return_mapped_cli_error() { + let mock_client = MockClient { + batch_delete_records_result: Err(OnspringError::Api { + status_code: 500, + message: "Failed to delete records".to_string(), + }), + ..Default::default() + }; + + let command = RecordsCommand::BatchDelete { + body: BodySource { + json: Some(r#"{"appId":1,"recordIds":[10,20]}"#.to_string()), + file: None, + stdin: false, + }, + }; + let mut buffer = Vec::new(); + + let result = handle(&command, &mock_client, &mut buffer, false).await; + + assert_eq!( + result, + Err(CliError::runtime( + "API request failed (500): Failed to delete records" + )) + ); + } +} diff --git a/src/output.rs b/src/output.rs index d06bc03..f3d5ca0 100644 --- a/src/output.rs +++ b/src/output.rs @@ -2,6 +2,7 @@ use std::io::Write; use onspring::{ App, CollectionResponse, Field, FormulaOutputType, ListFieldValue, Multiplicity, PagedResponse, + Record, RecordFieldValue, SaveRecordResponse, ValueType, }; use serde::Serialize; use uuid::Uuid; @@ -152,6 +153,61 @@ impl From for FieldOutput { } } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RecordFieldValueOutput { + #[serde(rename = "type")] + pub value_type: ValueType, + pub field_id: i32, + pub value: serde_json::Value, +} + +impl From for RecordFieldValueOutput { + fn from(value: RecordFieldValue) -> Self { + Self { + value_type: value.value_type, + field_id: value.field_id, + value: value.value, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RecordOutput { + pub app_id: i32, + pub record_id: i32, + pub field_data: Option>, +} + +impl From for RecordOutput { + fn from(value: Record) -> Self { + Self { + app_id: value.app_id, + record_id: value.record_id, + field_data: value + .field_data + .map(|fields| fields.into_iter().map(RecordFieldValueOutput::from).collect()), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveRecordResponseOutput { + pub id: i32, + pub warnings: Option>, +} + +impl From for SaveRecordResponseOutput { + fn from(value: SaveRecordResponse) -> Self { + Self { + id: value.id, + warnings: value.warnings, + } + } +} + 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 index e04a4e2..63c4c39 100644 --- a/src/validation.rs +++ b/src/validation.rs @@ -1,6 +1,9 @@ -use onspring::PagingRequest; +use std::io::Read; -use crate::cli::PagingArgs; +use onspring::PagingRequest; +use serde::de::DeserializeOwned; + +use crate::cli::{BodySource, PagingArgs}; use crate::error::{CliError, CliResult}; pub fn paging_request(args: &PagingArgs) -> CliResult> { @@ -54,6 +57,62 @@ pub fn validate_ids(ids: &[i32], name: &str, max_len: Option) -> CliResul Ok(()) } +pub fn read_body(source: &BodySource, mut reader: R) -> CliResult { + let count = [ + source.json.is_some(), + source.file.is_some(), + source.stdin, + ] + .into_iter() + .filter(|&b| b) + .count(); + + if count == 0 { + return Err(CliError::usage( + "A request body is required. Provide --json, --file, or --stdin.", + )); + } + + if count > 1 { + return Err(CliError::usage( + "Only one request body source may be specified (--json, --file, or --stdin).", + )); + } + + if let Some(ref json) = source.json { + return Ok(json.clone()); + } + + if let Some(ref path) = source.file { + return std::fs::read_to_string(path).map_err(|e| { + CliError::usage(format!( + "Failed to read file '{}': {}", + path.display(), + e + )) + }); + } + + if source.stdin { + let mut buffer = String::new(); + reader + .read_to_string(&mut buffer) + .map_err(|e| CliError::runtime(format!("Failed to read from stdin: {e}")))?; + return Ok(buffer); + } + + unreachable!() +} + +pub fn parse_body( + source: &BodySource, + reader: R, +) -> CliResult { + let content = read_body(source, reader)?; + serde_json::from_str(&content) + .map_err(|e| CliError::usage(format!("Invalid JSON request body: {e}"))) +} + #[cfg(test)] mod tests { use super::*; @@ -207,4 +266,121 @@ mod tests { )) ); } + + #[test] + fn read_body_when_no_source_provided_it_should_return_usage_error() { + let source = BodySource::default(); + let result = read_body(&source, std::io::empty()); + assert_eq!( + result, + Err(CliError::usage( + "A request body is required. Provide --json, --file, or --stdin." + )) + ); + } + + #[test] + fn read_body_when_multiple_sources_provided_it_should_return_usage_error() { + let source = BodySource { + json: Some("{}".to_string()), + stdin: true, + file: None, + }; + let result = read_body(&source, std::io::empty()); + assert_eq!( + result, + Err(CliError::usage( + "Only one request body source may be specified (--json, --file, or --stdin)." + )) + ); + } + + #[test] + fn read_body_when_json_provided_it_should_return_json_string() { + let source = BodySource { + json: Some(r#"{"appId":1}"#.to_string()), + file: None, + stdin: false, + }; + let result = read_body(&source, std::io::empty()); + assert_eq!(result, Ok(r#"{"appId":1}"#.to_string())); + } + + #[test] + fn read_body_when_stdin_provided_it_should_read_from_reader() { + let source = BodySource { + json: None, + file: None, + stdin: true, + }; + let input = r#"{"appId":2}"#; + let result = read_body(&source, input.as_bytes()); + assert_eq!(result, Ok(input.to_string())); + } + + #[test] + fn read_body_when_file_provided_it_should_read_from_file() { + use std::io::Write; + let mut temp = tempfile::NamedTempFile::new().unwrap(); + write!(temp, r#"{{"appId":3}}"#).unwrap(); + + let source = BodySource { + json: None, + file: Some(temp.path().to_path_buf()), + stdin: false, + }; + let result = read_body(&source, std::io::empty()); + assert_eq!(result, Ok(r#"{"appId":3}"#.to_string())); + } + + #[test] + fn read_body_when_file_not_found_it_should_return_usage_error() { + let source = BodySource { + json: None, + file: Some(std::path::PathBuf::from("nonexistent_file_path_12345.json")), + stdin: false, + }; + let result = read_body(&source, std::io::empty()); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, 2); + assert!(err.message.contains("Failed to read file")); + } + + #[test] + fn parse_body_when_valid_it_should_deserialize() { + #[derive(Debug, PartialEq, serde::Deserialize)] + struct TestData { + app_id: i32, + } + + let source = BodySource { + json: Some(r#"{"app_id":42}"#.to_string()), + file: None, + stdin: false, + }; + + let result: Result = parse_body(&source, std::io::empty()); + assert_eq!(result, Ok(TestData { app_id: 42 })); + } + + #[test] + fn parse_body_when_invalid_json_it_should_return_usage_error() { + #[derive(Debug, PartialEq, serde::Deserialize)] + struct TestData { + app_id: i32, + } + + let source = BodySource { + json: Some("invalid json".to_string()), + file: None, + stdin: false, + }; + + let result: Result = parse_body(&source, std::io::empty()); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.code, 2); + assert!(err.message.contains("Invalid JSON request body")); + } }