feat: implement record commands

This commit is contained in:
Stevan Freeborn committed 2026-09-14 19:53:56 -05:00
1 parent 54346ff20f
commit 56b5abc1d1
6 files changed
+2098 -12

No files matched your search

+351 -7
View File
@@ -1,4 +1,6 @@
use clap::{Args, Parser, Subcommand}; use std::path::PathBuf;
use clap::{Args, Parser, Subcommand, ValueEnum};
#[derive(Debug, PartialEq, Parser)] #[derive(Debug, PartialEq, Parser)]
#[command(name = "onx")] #[command(name = "onx")]
@@ -31,16 +33,24 @@ pub struct Cli {
pub enum Command { pub enum Command {
#[command(about = "Check connectivity to the Onspring API")] #[command(about = "Check connectivity to the Onspring API")]
Ping, Ping,
#[command(about = "Perform operations against the apps in the instance")] #[command(about = "Perform operations against the apps in the instance")]
Apps { Apps {
#[command(subcommand)] #[command(subcommand)]
command: AppsCommand, command: AppsCommand,
}, },
#[command(about = "Perform operations against the fields in the instance")] #[command(about = "Perform operations against the fields in the instance")]
Fields { Fields {
#[command(subcommand)] #[command(subcommand)]
command: FieldsCommand, command: FieldsCommand,
}, },
#[command(about = "Perform operations against the records in the instance")]
Records {
#[command(subcommand)]
command: RecordsCommand,
},
} }
#[derive(Debug, PartialEq, Subcommand)] #[derive(Debug, PartialEq, Subcommand)]
@@ -50,17 +60,19 @@ pub enum AppsCommand {
#[command(about = "Get information about an app")] #[command(about = "Get information about an app")]
Get { Get {
#[arg(long)] #[arg(long = "app-id")]
#[arg(short = 'i')] #[arg(short = 'i')]
#[arg(alias = "app")]
#[arg(help = "App id to get")] #[arg(help = "App id to get")]
app_id: i32, app_id: i32,
}, },
#[command(about = "Get information for a batch of apps")] #[command(about = "Get information for a batch of apps")]
BatchGet { BatchGet {
#[arg(long)] #[arg(long = "ids")]
#[arg(value_delimiter = ',')] #[arg(value_delimiter = ',')]
#[arg(short = 'i')] #[arg(short = 'i')]
#[arg(alias = "apps")]
#[arg(help = "Comma-separated list of app ids to get")] #[arg(help = "Comma-separated list of app ids to get")]
ids: Vec<i32>, ids: Vec<i32>,
}, },
@@ -70,9 +82,10 @@ pub enum AppsCommand {
pub enum FieldsCommand { pub enum FieldsCommand {
#[command(about = "Get information for a list of fields")] #[command(about = "Get information for a list of fields")]
List { List {
#[arg(long)] #[arg(long = "app-id")]
#[arg(short = 'i')] #[arg(short = 'i')]
#[arg(help = "App id to get")] #[arg(alias = "app")]
#[arg(help = "App id whose fields will be retrieved")]
app_id: i32, app_id: i32,
#[command(flatten)] #[command(flatten)]
@@ -81,22 +94,112 @@ pub enum FieldsCommand {
#[command(about = "Get information about a field")] #[command(about = "Get information about a field")]
Get { Get {
#[arg(long)] #[arg(long = "field-id")]
#[arg(short = 'i')] #[arg(short = 'i')]
#[arg(alias = "field")]
#[arg(help = "Field id to get")] #[arg(help = "Field id to get")]
field_id: i32, field_id: i32,
}, },
#[command(about = "Get information for a batch of fields")] #[command(about = "Get information for a batch of fields")]
BatchGet { BatchGet {
#[arg(long)] #[arg(long = "ids")]
#[arg(value_delimiter = ',')] #[arg(value_delimiter = ',')]
#[arg(short = 'i')] #[arg(short = 'i')]
#[arg(alias = "fields")]
#[arg(help = "Comma-separated list of field ids to get")] #[arg(help = "Comma-separated list of field ids to get")]
ids: Vec<i32>, ids: Vec<i32>,
}, },
} }
#[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<i32>,
#[arg(long)]
data_format: Option<DataFormatArg>,
},
#[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<i32>,
#[arg(long = "format")]
#[arg(short = 'd')]
#[arg(help = "Format of data in response")]
data_format: Option<DataFormatArg>,
},
#[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)] #[derive(Debug, PartialEq, Clone, Args, Default)]
pub struct PagingArgs { pub struct PagingArgs {
#[arg(long)] #[arg(long)]
@@ -110,6 +213,29 @@ pub struct PagingArgs {
pub page_size: Option<i32>, pub page_size: Option<i32>,
} }
#[derive(Debug, PartialEq, Clone, Args, Default)]
pub struct BodySource {
#[arg(long)]
#[arg(short = 'j')]
#[arg(help = "Inline JSON request body")]
pub json: Option<String>,
#[arg(long)]
#[arg(short = 'f')]
#[arg(help = "Path to a JSON request body file")]
pub file: Option<PathBuf>,
#[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<I, T>(args: I) -> crate::error::CliResult<Cli> pub fn parse_cli_from<I, T>(args: I) -> crate::error::CliResult<Cli>
where where
I: IntoIterator<Item = T>, I: IntoIterator<Item = T>,
@@ -489,4 +615,222 @@ mod tests {
assert!(err.message.contains(env!("CARGO_PKG_NAME"))); assert!(err.message.contains(env!("CARGO_PKG_NAME")));
assert!(err.message.contains(env!("CARGO_PKG_VERSION"))); 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);
}
} }
+262 -1
View File
@@ -1,5 +1,7 @@
use onspring::{ 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 { pub trait OnspringRunner {
@@ -35,6 +37,49 @@ pub trait OnspringRunner {
&self, &self,
ids: &[i32], ids: &[i32],
) -> impl std::future::Future<Output = Result<CollectionResponse<Field>, OnspringError>> + Send; ) -> impl std::future::Future<Output = Result<CollectionResponse<Field>, OnspringError>> + Send;
fn list_records(
&self,
app_id: i32,
paging: Option<PagingRequest>,
field_ids: Option<&[i32]>,
data_format: Option<DataFormat>,
) -> impl std::future::Future<Output = Result<PagedResponse<Record>, OnspringError>> + Send;
fn get_record(
&self,
app_id: i32,
record_id: i32,
field_ids: Option<&[i32]>,
data_format: Option<DataFormat>,
) -> impl std::future::Future<Output = Result<Record, OnspringError>> + Send;
fn save_record(
&self,
request: SaveRecordRequest,
) -> impl std::future::Future<Output = Result<SaveRecordResponse, OnspringError>> + Send;
fn delete_record(
&self,
app_id: i32,
record_id: i32,
) -> impl std::future::Future<Output = Result<(), OnspringError>> + Send;
fn batch_get_records(
&self,
request: BatchGetRecordsRequest,
) -> impl std::future::Future<Output = Result<CollectionResponse<Record>, OnspringError>> + Send;
fn query_records(
&self,
request: QueryRecordsRequest,
paging: Option<PagingRequest>,
) -> impl std::future::Future<Output = Result<PagedResponse<Record>, OnspringError>> + Send;
fn batch_delete_records(
&self,
request: BatchDeleteRecordsRequest,
) -> impl std::future::Future<Output = Result<(), OnspringError>> + Send;
} }
impl OnspringRunner for OnspringClient { impl OnspringRunner for OnspringClient {
@@ -72,6 +117,59 @@ impl OnspringRunner for OnspringClient {
async fn batch_get_fields(&self, ids: &[i32]) -> Result<CollectionResponse<Field>, OnspringError> { async fn batch_get_fields(&self, ids: &[i32]) -> Result<CollectionResponse<Field>, OnspringError> {
self.batch_get_fields(ids).await self.batch_get_fields(ids).await
} }
async fn list_records(
&self,
app_id: i32,
paging: Option<PagingRequest>,
field_ids: Option<&[i32]>,
data_format: Option<DataFormat>,
) -> Result<PagedResponse<Record>, 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<DataFormat>,
) -> Result<Record, OnspringError> {
self.get_record(app_id, record_id, field_ids, data_format).await
}
async fn save_record(
&self,
request: SaveRecordRequest,
) -> Result<SaveRecordResponse, OnspringError> {
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<CollectionResponse<Record>, OnspringError> {
self.batch_get_records(request).await
}
async fn query_records(
&self,
request: QueryRecordsRequest,
paging: Option<PagingRequest>,
) -> Result<PagedResponse<Record>, 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)] #[cfg(test)]
@@ -95,6 +193,32 @@ pub mod testing {
pub list_fields_paging: Mutex<Option<Option<PagingRequest>>>, pub list_fields_paging: Mutex<Option<Option<PagingRequest>>>,
pub get_field_id: Mutex<Option<i32>>, pub get_field_id: Mutex<Option<i32>>,
pub batch_get_fields_ids: Mutex<Option<Vec<i32>>>, pub batch_get_fields_ids: Mutex<Option<Vec<i32>>>,
pub list_records_result: Result<PagedResponse<Record>, OnspringError>,
pub get_record_result: Result<Record, OnspringError>,
pub save_record_result: Result<SaveRecordResponse, OnspringError>,
pub delete_record_result: Result<(), OnspringError>,
pub batch_get_records_result: Result<CollectionResponse<Record>, OnspringError>,
pub query_records_result: Result<PagedResponse<Record>, OnspringError>,
pub batch_delete_records_result: Result<(), OnspringError>,
pub list_records_app_id: Mutex<Option<i32>>,
pub list_records_paging: Mutex<Option<Option<PagingRequest>>>,
pub list_records_field_ids: Mutex<Option<Option<Vec<i32>>>>,
pub list_records_data_format: Mutex<Option<Option<DataFormat>>>,
pub get_record_app_id: Mutex<Option<i32>>,
pub get_record_record_id: Mutex<Option<i32>>,
pub get_record_field_ids: Mutex<Option<Option<Vec<i32>>>>,
pub get_record_data_format: Mutex<Option<Option<DataFormat>>>,
pub save_record_request: Mutex<Option<SaveRecordRequest>>,
pub delete_record_app_id: Mutex<Option<i32>>,
pub delete_record_record_id: Mutex<Option<i32>>,
pub batch_get_records_request: Mutex<Option<BatchGetRecordsRequest>>,
pub query_records_request: Mutex<Option<QueryRecordsRequest>>,
pub query_records_paging: Mutex<Option<Option<PagingRequest>>>,
pub batch_delete_records_request: Mutex<Option<BatchDeleteRecordsRequest>>,
} }
impl Default for MockClient { impl Default for MockClient {
@@ -150,6 +274,54 @@ pub mod testing {
list_fields_paging: Mutex::new(None), list_fields_paging: Mutex::new(None),
get_field_id: Mutex::new(None), get_field_id: Mutex::new(None),
batch_get_fields_ids: 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)), Err(err) => Err(clone_onspring_error(err)),
} }
} }
async fn list_records(
&self,
app_id: i32,
paging: Option<PagingRequest>,
field_ids: Option<&[i32]>,
data_format: Option<DataFormat>,
) -> Result<PagedResponse<Record>, 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<DataFormat>,
) -> Result<Record, OnspringError> {
*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<SaveRecordResponse, OnspringError> {
*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<CollectionResponse<Record>, 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<PagingRequest>,
) -> Result<PagedResponse<Record>, 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)),
}
}
} }
} }
+4 -2
View File
@@ -1,6 +1,7 @@
pub mod apps; pub mod apps;
pub mod ping;
pub mod fields; pub mod fields;
pub mod ping;
pub mod records;
use std::io::Write; use std::io::Write;
@@ -17,5 +18,6 @@ pub async fn handle<C: OnspringRunner, W: Write>(
Command::Ping => ping::handle(client, writer, cli.pretty).await, Command::Ping => ping::handle(client, writer, cli.pretty).await,
Command::Apps { command } => apps::handle(command, 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::Fields { command } => fields::handle(command, client, writer, cli.pretty).await,
} Command::Records { command } => records::handle(command, client, writer, cli.pretty).await
}
} }
File diff suppressed because it is too large. Load diff
+56
View File
@@ -2,6 +2,7 @@ use std::io::Write;
use onspring::{ use onspring::{
App, CollectionResponse, Field, FormulaOutputType, ListFieldValue, Multiplicity, PagedResponse, App, CollectionResponse, Field, FormulaOutputType, ListFieldValue, Multiplicity, PagedResponse,
Record, RecordFieldValue, SaveRecordResponse, ValueType,
}; };
use serde::Serialize; use serde::Serialize;
use uuid::Uuid; use uuid::Uuid;
@@ -152,6 +153,61 @@ impl From<Field> 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<RecordFieldValue> 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<Vec<RecordFieldValueOutput>>,
}
impl From<Record> 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<Vec<String>>,
}
impl From<SaveRecordResponse> for SaveRecordResponseOutput {
fn from(value: SaveRecordResponse) -> Self {
Self {
id: value.id,
warnings: value.warnings,
}
}
}
pub fn render_json<T: Serialize>(value: &T, pretty: bool) -> CliResult<String> { pub fn render_json<T: Serialize>(value: &T, pretty: bool) -> CliResult<String> {
if pretty { if pretty {
serde_json::to_string_pretty(value).map_err(|_| CliError::runtime("Failed to serialize output")) serde_json::to_string_pretty(value).map_err(|_| CliError::runtime("Failed to serialize output"))
+178 -2
View File
@@ -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}; use crate::error::{CliError, CliResult};
pub fn paging_request(args: &PagingArgs) -> CliResult<Option<PagingRequest>> { pub fn paging_request(args: &PagingArgs) -> CliResult<Option<PagingRequest>> {
@@ -54,6 +57,62 @@ pub fn validate_ids(ids: &[i32], name: &str, max_len: Option<usize>) -> CliResul
Ok(()) Ok(())
} }
pub fn read_body<R: Read>(source: &BodySource, mut reader: R) -> CliResult<String> {
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<T: DeserializeOwned, R: Read>(
source: &BodySource,
reader: R,
) -> CliResult<T> {
let content = read_body(source, reader)?;
serde_json::from_str(&content)
.map_err(|e| CliError::usage(format!("Invalid JSON request body: {e}")))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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<TestData, _> = 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<TestData, _> = 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"));
}
} }