feat: implement files commands

This commit is contained in:
Stevan Freeborn committed 2026-09-14 20:40:21 -05:00
1 parent 56b5abc1d1
commit 213ed0b834
8 files changed
+1446 -61

No files matched your search

Generated
+1
View File
@@ -797,6 +797,7 @@ dependencies = [
name = "onx" name = "onx"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"bytes",
"chrono", "chrono",
"clap", "clap",
"mime_guess", "mime_guess",
+1
View File
@@ -7,6 +7,7 @@ license = "MIT"
repository = "https://github.com/StevanFreeborn/onx" repository = "https://github.com/StevanFreeborn/onx"
[dependencies] [dependencies]
bytes = "1.12.1"
chrono = { version = "0.4.45", features = ["serde"] } chrono = { version = "0.4.45", features = ["serde"] }
clap = { version = "4.6.6", features = ["derive", "env"] } clap = { version = "4.6.6", features = ["derive", "env"] }
mime_guess = "2.0.5" mime_guess = "2.0.5"
+255 -36
View File
@@ -51,6 +51,12 @@ pub enum Command {
#[command(subcommand)] #[command(subcommand)]
command: RecordsCommand, command: RecordsCommand,
}, },
#[command(about = "Perform operations against the files in the instance")]
Files {
#[command(subcommand)]
command: FilesCommand,
},
} }
#[derive(Debug, PartialEq, Subcommand)] #[derive(Debug, PartialEq, Subcommand)]
@@ -60,19 +66,17 @@ pub enum AppsCommand {
#[command(about = "Get information about an app")] #[command(about = "Get information about an app")]
Get { Get {
#[arg(long = "app-id")] #[arg(long = "app")]
#[arg(short = 'i')] #[arg(short = 'a')]
#[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 = "ids")] #[arg(long = "apps")]
#[arg(value_delimiter = ',')] #[arg(value_delimiter = ',')]
#[arg(short = 'i')] #[arg(short = 'a')]
#[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>,
}, },
@@ -82,9 +86,8 @@ 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 = "app-id")] #[arg(long = "app")]
#[arg(short = 'i')] #[arg(short = 'a')]
#[arg(alias = "app")]
#[arg(help = "App id whose fields will be retrieved")] #[arg(help = "App id whose fields will be retrieved")]
app_id: i32, app_id: i32,
@@ -94,19 +97,17 @@ pub enum FieldsCommand {
#[command(about = "Get information about a field")] #[command(about = "Get information about a field")]
Get { Get {
#[arg(long = "field-id")] #[arg(long = "field")]
#[arg(short = 'i')] #[arg(short = 'f')]
#[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 = "ids")] #[arg(long = "fields")]
#[arg(value_delimiter = ',')] #[arg(value_delimiter = ',')]
#[arg(short = 'i')] #[arg(short = 'f')]
#[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>,
}, },
@@ -200,6 +201,107 @@ pub enum RecordsCommand {
}, },
} }
#[derive(Debug, PartialEq, Subcommand)]
pub enum FilesCommand {
#[command(about = "Get metadata info for a file")]
Info {
#[arg(long = "record")]
#[arg(short = 'r')]
#[arg(help = "Record id for record where the file is held")]
record_id: i32,
#[arg(long = "field")]
#[arg(short = 'f')]
#[arg(help = "Field id for field where the file is held")]
field_id: i32,
#[arg(long = "file")]
#[arg(short = 'l')]
#[arg(help = "The id of the file")]
file_id: i32,
},
#[command(about = "Get the content for a file")]
Get {
#[arg(long = "record")]
#[arg(short = 'r')]
#[arg(help = "Record id for record where the file is held")]
record_id: i32,
#[arg(long = "field")]
#[arg(short = 'f')]
#[arg(help = "Field id for field where the file is held")]
field_id: i32,
#[arg(long = "file")]
#[arg(short = 'l')]
#[arg(help = "The id of the file")]
file_id: i32,
#[arg(long)]
#[arg(short = 'o')]
#[arg(help = "The output path for the file")]
output: Option<PathBuf>,
},
Upload {
#[arg(long = "record")]
#[arg(short = 'r')]
#[arg(help = "Record id for record where the file should be uploaded")]
record_id: i32,
#[arg(long = "field")]
#[arg(short = 'f')]
#[arg(help = "Field id for field where the file should be uploaded")]
field_id: i32,
#[arg(long)]
#[arg(short = 'i')]
#[arg(help = "The input path of the file to be uploaded")]
input: Option<PathBuf>,
#[arg(long)]
#[arg(short = 's')]
#[arg(help = "Indicates whether file data should be read in from standard in")]
stdin: bool,
#[arg(long = "name")]
#[arg(short = 'n')]
#[arg(help = "The name of the file to be uploaded")]
file_name: Option<String>,
#[arg(long = "type")]
#[arg(short = 't')]
#[arg(help = "The content type of the file to be uploaded")]
content_type: Option<String>,
#[arg(long)]
#[arg(help = "Notes that should be included in the file upload")]
notes: Option<String>,
#[arg(long)]
#[arg(help = "Modified date that should be included in the file upload as RFC3339 timestamp")]
modified_date: Option<String>,
},
Delete {
#[arg(long = "record")]
#[arg(short = 'r')]
#[arg(help = "Record id for record where the file is held")]
record_id: i32,
#[arg(long = "field")]
#[arg(short = 'f')]
#[arg(help = "Field id for field where the file is held")]
field_id: i32,
#[arg(long = "file")]
#[arg(short = 'l')]
#[arg(help = "The id of the file")]
file_id: i32,
},
}
#[derive(Debug, PartialEq, Clone, Args, Default)] #[derive(Debug, PartialEq, Clone, Args, Default)]
pub struct PagingArgs { pub struct PagingArgs {
#[arg(long)] #[arg(long)]
@@ -316,7 +418,7 @@ mod tests {
#[test] #[test]
fn parse_cli_from_when_called_with_apps_get_it_should_return_cli() { fn parse_cli_from_when_called_with_apps_get_it_should_return_cli() {
let result = parse_cli_from(["test", "apps", "get", "--app-id", "123"]); let result = parse_cli_from(["test", "apps", "get", "--app", "123"]);
assert_eq!( assert_eq!(
result, result,
@@ -333,7 +435,7 @@ mod tests {
#[test] #[test]
fn parse_cli_from_when_called_with_apps_get_short_flag_it_should_return_cli() { fn parse_cli_from_when_called_with_apps_get_short_flag_it_should_return_cli() {
let result = parse_cli_from(["test", "apps", "get", "-i", "123"]); let result = parse_cli_from(["test", "apps", "get", "-a", "123"]);
assert_eq!( assert_eq!(
result, result,
@@ -359,7 +461,7 @@ mod tests {
#[test] #[test]
fn parse_cli_from_when_called_with_apps_batch_get_it_should_return_cli() { fn parse_cli_from_when_called_with_apps_batch_get_it_should_return_cli() {
let result = parse_cli_from(["test", "apps", "batch-get", "--ids", "1,2,3"]); let result = parse_cli_from(["test", "apps", "batch-get", "--apps", "1,2,3"]);
assert_eq!( assert_eq!(
result, result,
@@ -395,7 +497,7 @@ mod tests {
#[test] #[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"]); let result = parse_cli_from(["test", "apps", "batch-get", "--apps", "invalid"]);
assert!(result.is_err()); assert!(result.is_err());
let err = result.unwrap_err(); let err = result.unwrap_err();
@@ -448,7 +550,7 @@ mod tests {
#[test] #[test]
fn parse_cli_from_when_called_with_fields_list_it_should_return_cli() { fn parse_cli_from_when_called_with_fields_list_it_should_return_cli() {
let result = parse_cli_from(["test", "fields", "list", "--app-id", "10"]); let result = parse_cli_from(["test", "fields", "list", "--app", "10"]);
assert_eq!( assert_eq!(
result, result,
@@ -472,7 +574,7 @@ mod tests {
"test", "test",
"fields", "fields",
"list", "list",
"-i", "-a",
"10", "10",
"--page-number", "--page-number",
"2", "2",
@@ -510,7 +612,7 @@ mod tests {
#[test] #[test]
fn parse_cli_from_when_called_with_fields_get_it_should_return_cli() { fn parse_cli_from_when_called_with_fields_get_it_should_return_cli() {
let result = parse_cli_from(["test", "fields", "get", "--field-id", "123"]); let result = parse_cli_from(["test", "fields", "get", "--field", "123"]);
assert_eq!( assert_eq!(
result, result,
@@ -527,7 +629,7 @@ mod tests {
#[test] #[test]
fn parse_cli_from_when_called_with_fields_get_short_flag_it_should_return_cli() { 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"]); let result = parse_cli_from(["test", "fields", "get", "-f", "123"]);
assert_eq!( assert_eq!(
result, result,
@@ -553,7 +655,7 @@ mod tests {
#[test] #[test]
fn parse_cli_from_when_called_with_fields_batch_get_it_should_return_cli() { 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"]); let result = parse_cli_from(["test", "fields", "batch-get", "--fields", "1,2,3"]);
assert_eq!( assert_eq!(
result, result,
@@ -589,7 +691,7 @@ mod tests {
#[test] #[test]
fn parse_cli_from_when_called_with_fields_batch_get_invalid_id_format_it_should_return_usage_error() 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"]); let result = parse_cli_from(["test", "fields", "batch-get", "--fields", "invalid"]);
assert!(result.is_err()); assert!(result.is_err());
let err = result.unwrap_err(); let err = result.unwrap_err();
@@ -680,7 +782,17 @@ mod tests {
#[test] #[test]
fn parse_cli_from_when_called_with_records_get_it_should_return_cli() { fn parse_cli_from_when_called_with_records_get_it_should_return_cli() {
let result = parse_cli_from([ let result = parse_cli_from([
"test", "records", "get", "-a", "1", "-r", "10", "-f", "100", "-d", "formatted", "test",
"records",
"get",
"-a",
"1",
"-r",
"10",
"-f",
"100",
"-d",
"formatted",
]); ]);
assert_eq!( assert_eq!(
@@ -703,9 +815,7 @@ mod tests {
#[test] #[test]
fn parse_cli_from_when_called_with_records_save_json_it_should_return_cli() { fn parse_cli_from_when_called_with_records_save_json_it_should_return_cli() {
let result = parse_cli_from([ let result = parse_cli_from(["test", "records", "save", "--json", r#"{"appId":1}"#]);
"test", "records", "save", "--json", r#"{"appId":1}"#,
]);
assert_eq!( assert_eq!(
result, result,
@@ -748,9 +858,7 @@ mod tests {
#[test] #[test]
fn parse_cli_from_when_called_with_records_batch_get_it_should_return_cli() { fn parse_cli_from_when_called_with_records_batch_get_it_should_return_cli() {
let result = parse_cli_from([ let result = parse_cli_from(["test", "records", "batch-get", "-f", "input.json"]);
"test", "records", "batch-get", "-f", "input.json",
]);
assert_eq!( assert_eq!(
result, result,
@@ -773,9 +881,7 @@ mod tests {
#[test] #[test]
fn parse_cli_from_when_called_with_records_query_it_should_return_cli() { fn parse_cli_from_when_called_with_records_query_it_should_return_cli() {
let result = parse_cli_from([ let result = parse_cli_from(["test", "records", "query", "--stdin", "-n", "1", "-s", "50"]);
"test", "records", "query", "--stdin", "-n", "1", "-s", "50",
]);
assert_eq!( assert_eq!(
result, result,
@@ -803,7 +909,11 @@ mod tests {
#[test] #[test]
fn parse_cli_from_when_called_with_records_batch_delete_it_should_return_cli() { fn parse_cli_from_when_called_with_records_batch_delete_it_should_return_cli() {
let result = parse_cli_from([ let result = parse_cli_from([
"test", "records", "batch-delete", "-j", r#"{"appId":1,"recordIds":[10]}"#, "test",
"records",
"batch-delete",
"-j",
r#"{"appId":1,"recordIds":[10]}"#,
]); ]);
assert_eq!( assert_eq!(
@@ -833,4 +943,113 @@ mod tests {
let err = result.unwrap_err(); let err = result.unwrap_err();
assert_eq!(err.code, 2); assert_eq!(err.code, 2);
} }
#[test]
fn parse_cli_from_when_called_with_files_info_it_should_return_cli() {
let result = parse_cli_from([
"test", "files", "info", "--record", "1", "--field", "2", "--file", "3",
]);
assert_eq!(
result,
Ok(Cli {
api_key: None,
base_url: None,
pretty: false,
command: Command::Files {
command: FilesCommand::Info {
record_id: 1,
field_id: 2,
file_id: 3,
},
},
})
);
}
#[test]
fn parse_cli_from_when_called_with_files_get_it_should_return_cli() {
let result = parse_cli_from([
"test", "files", "get", "-r", "1", "-f", "2", "-l", "3", "-o", "out.bin",
]);
assert_eq!(
result,
Ok(Cli {
api_key: None,
base_url: None,
pretty: false,
command: Command::Files {
command: FilesCommand::Get {
record_id: 1,
field_id: 2,
file_id: 3,
output: Some(std::path::PathBuf::from("out.bin")),
},
},
})
);
}
#[test]
fn parse_cli_from_when_called_with_files_upload_it_should_return_cli() {
let result = parse_cli_from([
"test", "files", "upload", "-r", "1", "-f", "2", "-i", "doc.pdf", "-n", "renamed.pdf",
"-t", "application/pdf", "--notes", "test notes", "--modified-date",
"2026-01-01T00:00:00Z",
]);
assert_eq!(
result,
Ok(Cli {
api_key: None,
base_url: None,
pretty: false,
command: Command::Files {
command: FilesCommand::Upload {
record_id: 1,
field_id: 2,
input: Some(std::path::PathBuf::from("doc.pdf")),
stdin: false,
file_name: Some("renamed.pdf".to_string()),
content_type: Some("application/pdf".to_string()),
notes: Some("test notes".to_string()),
modified_date: Some("2026-01-01T00:00:00Z".to_string()),
},
},
})
);
}
#[test]
fn parse_cli_from_when_called_with_files_delete_it_should_return_cli() {
let result = parse_cli_from([
"test", "files", "delete", "--record", "10", "--field", "20", "--file", "30",
]);
assert_eq!(
result,
Ok(Cli {
api_key: None,
base_url: None,
pretty: false,
command: Command::Files {
command: FilesCommand::Delete {
record_id: 10,
field_id: 20,
file_id: 30,
},
},
})
);
}
#[test]
fn parse_cli_from_when_called_with_files_without_subcommand_it_should_return_usage_error() {
let result = parse_cli_from(["test", "files"]);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, 2);
}
} }
+171 -3
View File
@@ -1,7 +1,8 @@
use onspring::{ use onspring::{
App, BatchDeleteRecordsRequest, BatchGetRecordsRequest, CollectionResponse, DataFormat, Field, App, BatchDeleteRecordsRequest, BatchGetRecordsRequest, CollectionResponse,
OnspringClient, OnspringError, PagedResponse, PagingRequest, QueryRecordsRequest, Record, CreatedWithIdResponse, DataFormat, Field, FileInfo, FileResponse, OnspringClient, OnspringError,
SaveRecordRequest, SaveRecordResponse, PagedResponse, PagingRequest, QueryRecordsRequest, Record, SaveFileRequest, SaveRecordRequest,
SaveRecordResponse,
}; };
pub trait OnspringRunner { pub trait OnspringRunner {
@@ -80,6 +81,32 @@ pub trait OnspringRunner {
&self, &self,
request: BatchDeleteRecordsRequest, request: BatchDeleteRecordsRequest,
) -> impl std::future::Future<Output = Result<(), OnspringError>> + Send; ) -> impl std::future::Future<Output = Result<(), OnspringError>> + Send;
fn get_file_info(
&self,
record_id: i32,
field_id: i32,
file_id: i32,
) -> impl std::future::Future<Output = Result<FileInfo, OnspringError>> + Send;
fn get_file(
&self,
record_id: i32,
field_id: i32,
file_id: i32,
) -> impl std::future::Future<Output = Result<FileResponse, OnspringError>> + Send;
fn upload_file(
&self,
request: SaveFileRequest,
) -> impl std::future::Future<Output = Result<CreatedWithIdResponse, OnspringError>> + Send;
fn delete_file(
&self,
record_id: i32,
field_id: i32,
file_id: i32,
) -> impl std::future::Future<Output = Result<(), OnspringError>> + Send;
} }
impl OnspringRunner for OnspringClient { impl OnspringRunner for OnspringClient {
@@ -170,6 +197,40 @@ impl OnspringRunner for OnspringClient {
) -> Result<(), OnspringError> { ) -> Result<(), OnspringError> {
self.batch_delete_records(request).await self.batch_delete_records(request).await
} }
async fn get_file_info(
&self,
record_id: i32,
field_id: i32,
file_id: i32,
) -> Result<FileInfo, OnspringError> {
self.get_file_info(record_id, field_id, file_id).await
}
async fn get_file(
&self,
record_id: i32,
field_id: i32,
file_id: i32,
) -> Result<FileResponse, OnspringError> {
self.get_file(record_id, field_id, file_id).await
}
async fn upload_file(
&self,
request: SaveFileRequest,
) -> Result<CreatedWithIdResponse, OnspringError> {
self.upload_file(request).await
}
async fn delete_file(
&self,
record_id: i32,
field_id: i32,
file_id: i32,
) -> Result<(), OnspringError> {
self.delete_file(record_id, field_id, file_id).await
}
} }
#[cfg(test)] #[cfg(test)]
@@ -219,6 +280,25 @@ pub mod testing {
pub query_records_request: Mutex<Option<QueryRecordsRequest>>, pub query_records_request: Mutex<Option<QueryRecordsRequest>>,
pub query_records_paging: Mutex<Option<Option<PagingRequest>>>, pub query_records_paging: Mutex<Option<Option<PagingRequest>>>,
pub batch_delete_records_request: Mutex<Option<BatchDeleteRecordsRequest>>, pub batch_delete_records_request: Mutex<Option<BatchDeleteRecordsRequest>>,
pub get_file_info_result: Result<FileInfo, OnspringError>,
pub get_file_result: Result<FileResponse, OnspringError>,
pub upload_file_result: Result<CreatedWithIdResponse, OnspringError>,
pub delete_file_result: Result<(), OnspringError>,
pub get_file_info_record_id: Mutex<Option<i32>>,
pub get_file_info_field_id: Mutex<Option<i32>>,
pub get_file_info_file_id: Mutex<Option<i32>>,
pub get_file_record_id: Mutex<Option<i32>>,
pub get_file_field_id: Mutex<Option<i32>>,
pub get_file_file_id: Mutex<Option<i32>>,
pub upload_file_request: Mutex<Option<SaveFileRequest>>,
pub delete_file_record_id: Mutex<Option<i32>>,
pub delete_file_field_id: Mutex<Option<i32>>,
pub delete_file_file_id: Mutex<Option<i32>>,
} }
impl Default for MockClient { impl Default for MockClient {
@@ -322,6 +402,38 @@ pub mod testing {
query_records_request: Mutex::new(None), query_records_request: Mutex::new(None),
query_records_paging: Mutex::new(None), query_records_paging: Mutex::new(None),
batch_delete_records_request: Mutex::new(None), batch_delete_records_request: Mutex::new(None),
get_file_info_result: Ok(FileInfo {
file_type: None,
content_type: None,
name: None,
created_date: None,
modified_date: None,
owner: None,
notes: None,
file_href: None,
}),
get_file_result: Ok(FileResponse {
content_type: None,
file_name: None,
data: bytes::Bytes::new(),
}),
upload_file_result: Ok(CreatedWithIdResponse { id: 1 }),
delete_file_result: Ok(()),
get_file_info_record_id: Mutex::new(None),
get_file_info_field_id: Mutex::new(None),
get_file_info_file_id: Mutex::new(None),
get_file_record_id: Mutex::new(None),
get_file_field_id: Mutex::new(None),
get_file_file_id: Mutex::new(None),
upload_file_request: Mutex::new(None),
delete_file_record_id: Mutex::new(None),
delete_file_field_id: Mutex::new(None),
delete_file_file_id: Mutex::new(None),
} }
} }
} }
@@ -496,5 +608,61 @@ pub mod testing {
Err(err) => Err(clone_onspring_error(err)), Err(err) => Err(clone_onspring_error(err)),
} }
} }
async fn get_file_info(
&self,
record_id: i32,
field_id: i32,
file_id: i32,
) -> Result<FileInfo, OnspringError> {
*self.get_file_info_record_id.lock().unwrap() = Some(record_id);
*self.get_file_info_field_id.lock().unwrap() = Some(field_id);
*self.get_file_info_file_id.lock().unwrap() = Some(file_id);
match &self.get_file_info_result {
Ok(res) => Ok(res.clone()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn get_file(
&self,
record_id: i32,
field_id: i32,
file_id: i32,
) -> Result<FileResponse, OnspringError> {
*self.get_file_record_id.lock().unwrap() = Some(record_id);
*self.get_file_field_id.lock().unwrap() = Some(field_id);
*self.get_file_file_id.lock().unwrap() = Some(file_id);
match &self.get_file_result {
Ok(res) => Ok(res.clone()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn upload_file(
&self,
request: SaveFileRequest,
) -> Result<CreatedWithIdResponse, OnspringError> {
*self.upload_file_request.lock().unwrap() = Some(request);
match &self.upload_file_result {
Ok(res) => Ok(res.clone()),
Err(err) => Err(clone_onspring_error(err)),
}
}
async fn delete_file(
&self,
record_id: i32,
field_id: i32,
file_id: i32,
) -> Result<(), OnspringError> {
*self.delete_file_record_id.lock().unwrap() = Some(record_id);
*self.delete_file_field_id.lock().unwrap() = Some(field_id);
*self.delete_file_file_id.lock().unwrap() = Some(file_id);
match &self.delete_file_result {
Ok(()) => Ok(()),
Err(err) => Err(clone_onspring_error(err)),
}
}
} }
} }
+965
View File
@@ -0,0 +1,965 @@
use std::io::{Read, Write};
use chrono::{DateTime, Utc};
use onspring::SaveFileRequest;
use crate::cli::FilesCommand;
use crate::client::OnspringRunner;
use crate::error::{CliError, CliResult};
use crate::output::{CreatedWithIdResponseOutput, FileInfoOutput, SuccessResponse, write_json};
use crate::validation::validate_positive_i32;
pub async fn handle<C: OnspringRunner, W: Write>(
command: &FilesCommand,
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<C: OnspringRunner, W: Write, R: Read>(
command: &FilesCommand,
client: &C,
writer: &mut W,
mut reader: R,
pretty: bool,
) -> CliResult<()> {
match command {
FilesCommand::Info {
record_id,
field_id,
file_id,
} => {
validate_positive_i32(*record_id, "record_id")?;
validate_positive_i32(*field_id, "field_id")?;
validate_positive_i32(*file_id, "file_id")?;
let response = client
.get_file_info(*record_id, *field_id, *file_id)
.await?;
let output: FileInfoOutput = response.into();
write_json(writer, &output, pretty)?;
}
FilesCommand::Get {
record_id,
field_id,
file_id,
output,
} => {
validate_positive_i32(*record_id, "record_id")?;
validate_positive_i32(*field_id, "field_id")?;
validate_positive_i32(*file_id, "file_id")?;
let response = client.get_file(*record_id, *field_id, *file_id).await?;
if let Some(output_path) = output {
std::fs::write(output_path, &response.data).map_err(|e| {
CliError::runtime(format!(
"Failed to write file to '{}': {e}",
output_path.display()
))
})?;
} else {
writer
.write_all(&response.data)
.map_err(|_| CliError::runtime("Failed to write output"))?;
}
}
FilesCommand::Upload {
record_id,
field_id,
input,
stdin,
file_name,
content_type,
notes,
modified_date,
} => {
validate_positive_i32(*record_id, "record_id")?;
validate_positive_i32(*field_id, "field_id")?;
if input.is_none() && !*stdin {
return Err(CliError::usage(
"A file source is required. Provide --input or --stdin.",
));
}
if input.is_some() && *stdin {
return Err(CliError::usage(
"Only one file source may be specified (--input or --stdin).",
));
}
let (file_bytes, resolved_file_name) = if let Some(path) = input {
let bytes = std::fs::read(path).map_err(|e| {
CliError::usage(format!("Failed to read file '{}': {e}", path.display()))
})?;
let default_name = path
.file_name()
.and_then(|n| n.to_str())
.map(String::from);
let name = file_name.clone().or(default_name).ok_or_else(|| {
CliError::usage("A file name is required. Provide --name.")
})?;
(bytes, name)
} else {
let mut buffer = Vec::new();
reader
.read_to_end(&mut buffer)
.map_err(|e| CliError::runtime(format!("Failed to read from stdin: {e}")))?;
let name = file_name
.clone()
.ok_or_else(|| CliError::usage("A file name is required when reading from stdin. Provide --name."))?;
(buffer, name)
};
let resolved_content_type = match content_type {
Some(ct) => ct.clone(),
None => mime_guess::from_path(&resolved_file_name)
.first_or_octet_stream()
.to_string(),
};
let parsed_modified_date = match modified_date {
Some(date_str) => Some(DateTime::parse_from_rfc3339(date_str).map_err(|e| {
CliError::usage(format!(
"Invalid modified_date '{date_str}'. Expected RFC3339 format: {e}"
))
})?.with_timezone(&Utc)),
None => None,
};
let request = SaveFileRequest {
record_id: *record_id,
field_id: *field_id,
notes: notes.clone(),
modified_date: parsed_modified_date,
file_name: resolved_file_name,
file_data: file_bytes,
content_type: resolved_content_type,
};
let response = client.upload_file(request).await?;
let output: CreatedWithIdResponseOutput = response.into();
write_json(writer, &output, pretty)?;
}
FilesCommand::Delete {
record_id,
field_id,
file_id,
} => {
validate_positive_i32(*record_id, "record_id")?;
validate_positive_i32(*field_id, "field_id")?;
validate_positive_i32(*file_id, "file_id")?;
client.delete_file(*record_id, *field_id, *file_id).await?;
write_json(writer, &SuccessResponse { ok: true }, pretty)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use bytes::Bytes;
use chrono::TimeZone;
use onspring::{CreatedWithIdResponse, FileInfo, FileResponse, OnspringError};
use crate::client::testing::MockClient;
use crate::error::CliError;
// --- Info tests ---
#[tokio::test]
async fn handle_when_files_info_succeeds_it_should_write_file_info_output() {
let created = Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap();
let modified = Utc.with_ymd_and_hms(2026, 1, 16, 12, 0, 0).unwrap();
let mock_client = MockClient {
get_file_info_result: Ok(FileInfo {
file_type: Some("Attachment".to_string()),
content_type: Some("application/pdf".to_string()),
name: Some("test.pdf".to_string()),
created_date: Some(created),
modified_date: Some(modified),
owner: Some("User 1".to_string()),
notes: Some("Some notes".to_string()),
file_href: Some("https://api.onspring.com/files/3/file".to_string()),
}),
..Default::default()
};
let command = FilesCommand::Info {
record_id: 1,
field_id: 2,
file_id: 3,
};
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#"{"type":"Attachment","contentType":"application/pdf","name":"test.pdf","createdDate":"2026-01-15T10:00:00Z","modifiedDate":"2026-01-16T12:00:00Z","owner":"User 1","notes":"Some notes","fileHref":"https://api.onspring.com/files/3/file"}"#
);
assert_eq!(*mock_client.get_file_info_record_id.lock().unwrap(), Some(1));
assert_eq!(*mock_client.get_file_info_field_id.lock().unwrap(), Some(2));
assert_eq!(*mock_client.get_file_info_file_id.lock().unwrap(), Some(3));
}
#[tokio::test]
async fn handle_when_files_info_succeeds_pretty_it_should_write_pretty_json() {
let mock_client = MockClient {
get_file_info_result: Ok(FileInfo {
file_type: Some("Attachment".to_string()),
content_type: Some("text/plain".to_string()),
name: Some("note.txt".to_string()),
created_date: None,
modified_date: None,
owner: None,
notes: None,
file_href: None,
}),
..Default::default()
};
let command = FilesCommand::Info {
record_id: 1,
field_id: 2,
file_id: 3,
};
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",
" \"type\": \"Attachment\",\n",
" \"contentType\": \"text/plain\",\n",
" \"name\": \"note.txt\",\n",
" \"createdDate\": null,\n",
" \"modifiedDate\": null,\n",
" \"owner\": null,\n",
" \"notes\": null,\n",
" \"fileHref\": null\n",
"}"
);
assert_eq!(written.trim(), expected);
}
#[tokio::test]
async fn handle_when_files_info_with_invalid_record_id_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FilesCommand::Info {
record_id: 0,
field_id: 2,
file_id: 3,
};
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());
}
#[tokio::test]
async fn handle_when_files_info_with_invalid_field_id_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FilesCommand::Info {
record_id: 1,
field_id: -1,
file_id: 3,
};
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());
}
#[tokio::test]
async fn handle_when_files_info_with_invalid_file_id_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FilesCommand::Info {
record_id: 1,
field_id: 2,
file_id: 0,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage("file_id must be greater than 0."))
);
assert!(buffer.is_empty());
}
#[tokio::test]
async fn handle_when_files_info_fails_it_should_return_mapped_cli_error() {
let mock_client = MockClient {
get_file_info_result: Err(OnspringError::Api {
status_code: 404,
message: "File not found".to_string(),
}),
..Default::default()
};
let command = FilesCommand::Info {
record_id: 1,
field_id: 2,
file_id: 3,
};
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): File not found"))
);
assert!(buffer.is_empty());
}
// --- Get tests ---
#[tokio::test]
async fn handle_when_files_get_without_output_flag_it_should_write_bytes_to_writer() {
let file_bytes = b"Hello, World!";
let mock_client = MockClient {
get_file_result: Ok(FileResponse {
content_type: Some("text/plain".to_string()),
file_name: Some("hello.txt".to_string()),
data: Bytes::from_static(file_bytes),
}),
..Default::default()
};
let command = FilesCommand::Get {
record_id: 10,
field_id: 20,
file_id: 30,
output: None,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
assert_eq!(buffer, file_bytes);
assert_eq!(*mock_client.get_file_record_id.lock().unwrap(), Some(10));
assert_eq!(*mock_client.get_file_field_id.lock().unwrap(), Some(20));
assert_eq!(*mock_client.get_file_file_id.lock().unwrap(), Some(30));
}
#[tokio::test]
async fn handle_when_files_get_with_output_flag_it_should_write_to_file() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("downloaded.bin");
let file_bytes = vec![1, 2, 3, 4, 5];
let mock_client = MockClient {
get_file_result: Ok(FileResponse {
content_type: Some("application/octet-stream".to_string()),
file_name: Some("data.bin".to_string()),
data: Bytes::from(file_bytes.clone()),
}),
..Default::default()
};
let command = FilesCommand::Get {
record_id: 1,
field_id: 2,
file_id: 3,
output: Some(file_path.clone()),
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
assert!(buffer.is_empty());
let read_back = std::fs::read(&file_path).unwrap();
assert_eq!(read_back, file_bytes);
}
#[tokio::test]
async fn handle_when_files_get_with_invalid_output_path_it_should_return_runtime_error() {
let mock_client = MockClient {
get_file_result: Ok(FileResponse {
content_type: None,
file_name: None,
data: Bytes::from_static(b"data"),
}),
..Default::default()
};
let command = FilesCommand::Get {
record_id: 1,
field_id: 2,
file_id: 3,
output: Some(PathBuf::from("/nonexistent_dir_12345/sub/file.txt")),
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, 1);
assert!(err.message.contains("Failed to write file to"));
}
#[tokio::test]
async fn handle_when_files_get_with_invalid_ids_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FilesCommand::Get {
record_id: 0,
field_id: 1,
file_id: 1,
output: None,
};
let mut buffer = Vec::new();
assert_eq!(
handle(&command, &mock_client, &mut buffer, false).await,
Err(CliError::usage("record_id must be greater than 0."))
);
let command = FilesCommand::Get {
record_id: 1,
field_id: 0,
file_id: 1,
output: None,
};
assert_eq!(
handle(&command, &mock_client, &mut buffer, false).await,
Err(CliError::usage("field_id must be greater than 0."))
);
let command = FilesCommand::Get {
record_id: 1,
field_id: 1,
file_id: 0,
output: None,
};
assert_eq!(
handle(&command, &mock_client, &mut buffer, false).await,
Err(CliError::usage("file_id must be greater than 0."))
);
}
#[tokio::test]
async fn handle_when_files_get_fails_it_should_return_mapped_cli_error() {
let mock_client = MockClient {
get_file_result: Err(OnspringError::Api {
status_code: 500,
message: "Internal error".to_string(),
}),
..Default::default()
};
let command = FilesCommand::Get {
record_id: 1,
field_id: 2,
file_id: 3,
output: 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 (500): Internal error"
))
);
}
// --- Upload tests ---
#[tokio::test]
async fn handle_when_files_upload_from_input_file_succeeds() {
let temp_file = tempfile::NamedTempFile::new().unwrap();
std::fs::write(temp_file.path(), b"Upload content").unwrap();
let mock_client = MockClient {
upload_file_result: Ok(CreatedWithIdResponse { id: 99 }),
..Default::default()
};
let command = FilesCommand::Upload {
record_id: 1,
field_id: 2,
input: Some(temp_file.path().to_path_buf()),
stdin: false,
file_name: Some("custom_name.txt".to_string()),
content_type: Some("text/custom".to_string()),
notes: Some("Upload note".to_string()),
modified_date: Some("2026-03-01T12:00:00Z".to_string()),
};
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":99}"#);
let req = mock_client.upload_file_request.lock().unwrap().clone().unwrap();
assert_eq!(req.record_id, 1);
assert_eq!(req.field_id, 2);
assert_eq!(req.file_name, "custom_name.txt");
assert_eq!(req.content_type, "text/custom");
assert_eq!(req.file_data, b"Upload content");
assert_eq!(req.notes, Some("Upload note".to_string()));
assert_eq!(
req.modified_date,
Some(Utc.with_ymd_and_hms(2026, 3, 1, 12, 0, 0).unwrap())
);
}
#[tokio::test]
async fn handle_when_files_upload_succeeds_pretty_it_should_write_pretty_json() {
let temp_file = tempfile::NamedTempFile::new().unwrap();
std::fs::write(temp_file.path(), b"content").unwrap();
let mock_client = MockClient {
upload_file_result: Ok(CreatedWithIdResponse { id: 101 }),
..Default::default()
};
let command = FilesCommand::Upload {
record_id: 1,
field_id: 2,
input: Some(temp_file.path().to_path_buf()),
stdin: false,
file_name: Some("data.txt".to_string()),
content_type: None,
notes: None,
modified_date: 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", " \"id\": 101\n", "}");
assert_eq!(written.trim(), expected);
}
#[tokio::test]
async fn handle_when_files_upload_infers_file_name_and_content_type_from_input() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("report.csv");
std::fs::write(&file_path, b"a,b,c\n1,2,3").unwrap();
let mock_client = MockClient {
upload_file_result: Ok(CreatedWithIdResponse { id: 50 }),
..Default::default()
};
let command = FilesCommand::Upload {
record_id: 1,
field_id: 2,
input: Some(file_path),
stdin: false,
file_name: None,
content_type: None,
notes: None,
modified_date: None,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(result, Ok(()));
let req = mock_client.upload_file_request.lock().unwrap().clone().unwrap();
assert_eq!(req.file_name, "report.csv");
assert_eq!(req.content_type, "text/csv");
}
#[tokio::test]
async fn handle_when_files_upload_reads_from_stdin() {
let mock_client = MockClient {
upload_file_result: Ok(CreatedWithIdResponse { id: 75 }),
..Default::default()
};
let command = FilesCommand::Upload {
record_id: 5,
field_id: 10,
input: None,
stdin: true,
file_name: Some("data.json".to_string()),
content_type: None,
notes: None,
modified_date: None,
};
let mut buffer = Vec::new();
let stdin_bytes = b"{\"hello\":\"world\"}";
let result = handle_with_reader(
&command,
&mock_client,
&mut buffer,
&stdin_bytes[..],
false,
)
.await;
assert_eq!(result, Ok(()));
let req = mock_client.upload_file_request.lock().unwrap().clone().unwrap();
assert_eq!(req.record_id, 5);
assert_eq!(req.field_id, 10);
assert_eq!(req.file_name, "data.json");
assert_eq!(req.content_type, "application/json");
assert_eq!(req.file_data, stdin_bytes);
}
#[tokio::test]
async fn handle_when_files_upload_without_source_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FilesCommand::Upload {
record_id: 1,
field_id: 2,
input: None,
stdin: false,
file_name: Some("file.txt".to_string()),
content_type: None,
notes: None,
modified_date: None,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage(
"A file source is required. Provide --input or --stdin."
))
);
}
#[tokio::test]
async fn handle_when_files_upload_with_both_sources_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FilesCommand::Upload {
record_id: 1,
field_id: 2,
input: Some(PathBuf::from("test.txt")),
stdin: true,
file_name: Some("file.txt".to_string()),
content_type: None,
notes: None,
modified_date: None,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert_eq!(
result,
Err(CliError::usage(
"Only one file source may be specified (--input or --stdin)."
))
);
}
#[tokio::test]
async fn handle_when_files_upload_input_file_not_found_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FilesCommand::Upload {
record_id: 1,
field_id: 2,
input: Some(PathBuf::from("nonexistent_file_12345.txt")),
stdin: false,
file_name: None,
content_type: None,
notes: None,
modified_date: None,
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, 2);
assert!(err.message.contains("Failed to read file"));
}
#[tokio::test]
async fn handle_when_files_upload_stdin_missing_name_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FilesCommand::Upload {
record_id: 1,
field_id: 2,
input: None,
stdin: true,
file_name: None,
content_type: None,
notes: None,
modified_date: None,
};
let mut buffer = Vec::new();
let result = handle_with_reader(
&command,
&mock_client,
&mut buffer,
std::io::empty(),
false,
)
.await;
assert_eq!(
result,
Err(CliError::usage(
"A file name is required when reading from stdin. Provide --name."
))
);
}
#[tokio::test]
async fn handle_when_files_upload_invalid_modified_date_it_should_return_usage_error() {
let temp_file = tempfile::NamedTempFile::new().unwrap();
let mock_client = MockClient::default();
let command = FilesCommand::Upload {
record_id: 1,
field_id: 2,
input: Some(temp_file.path().to_path_buf()),
stdin: false,
file_name: None,
content_type: None,
notes: None,
modified_date: Some("not-a-date".to_string()),
};
let mut buffer = Vec::new();
let result = handle(&command, &mock_client, &mut buffer, false).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.code, 2);
assert!(err.message.contains("Invalid modified_date"));
}
#[tokio::test]
async fn handle_when_files_upload_with_invalid_ids_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FilesCommand::Upload {
record_id: 0,
field_id: 2,
input: Some(PathBuf::from("test.txt")),
stdin: false,
file_name: None,
content_type: None,
notes: None,
modified_date: None,
};
let mut buffer = Vec::new();
assert_eq!(
handle(&command, &mock_client, &mut buffer, false).await,
Err(CliError::usage("record_id must be greater than 0."))
);
let command = FilesCommand::Upload {
record_id: 1,
field_id: 0,
input: Some(PathBuf::from("test.txt")),
stdin: false,
file_name: None,
content_type: None,
notes: None,
modified_date: None,
};
assert_eq!(
handle(&command, &mock_client, &mut buffer, false).await,
Err(CliError::usage("field_id must be greater than 0."))
);
}
#[tokio::test]
async fn handle_when_files_upload_fails_it_should_return_mapped_cli_error() {
let temp_file = tempfile::NamedTempFile::new().unwrap();
let mock_client = MockClient {
upload_file_result: Err(OnspringError::Api {
status_code: 400,
message: "Invalid file type".to_string(),
}),
..Default::default()
};
let command = FilesCommand::Upload {
record_id: 1,
field_id: 2,
input: Some(temp_file.path().to_path_buf()),
stdin: false,
file_name: None,
content_type: None,
notes: None,
modified_date: 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 (400): Invalid file type"
))
);
}
// --- Delete tests ---
#[tokio::test]
async fn handle_when_files_delete_succeeds_it_should_write_success_response() {
let mock_client = MockClient {
delete_file_result: Ok(()),
..Default::default()
};
let command = FilesCommand::Delete {
record_id: 1,
field_id: 2,
file_id: 3,
};
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_file_record_id.lock().unwrap(), Some(1));
assert_eq!(*mock_client.delete_file_field_id.lock().unwrap(), Some(2));
assert_eq!(*mock_client.delete_file_file_id.lock().unwrap(), Some(3));
}
#[tokio::test]
async fn handle_when_files_delete_succeeds_pretty_it_should_write_pretty_json() {
let mock_client = MockClient {
delete_file_result: Ok(()),
..Default::default()
};
let command = FilesCommand::Delete {
record_id: 1,
field_id: 2,
file_id: 3,
};
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_files_delete_with_invalid_ids_it_should_return_usage_error() {
let mock_client = MockClient::default();
let command = FilesCommand::Delete {
record_id: 0,
field_id: 2,
file_id: 3,
};
let mut buffer = Vec::new();
assert_eq!(
handle(&command, &mock_client, &mut buffer, false).await,
Err(CliError::usage("record_id must be greater than 0."))
);
let command = FilesCommand::Delete {
record_id: 1,
field_id: 0,
file_id: 3,
};
assert_eq!(
handle(&command, &mock_client, &mut buffer, false).await,
Err(CliError::usage("field_id must be greater than 0."))
);
let command = FilesCommand::Delete {
record_id: 1,
field_id: 2,
file_id: -1,
};
assert_eq!(
handle(&command, &mock_client, &mut buffer, false).await,
Err(CliError::usage("file_id must be greater than 0."))
);
}
#[tokio::test]
async fn handle_when_files_delete_fails_it_should_return_mapped_cli_error() {
let mock_client = MockClient {
delete_file_result: Err(OnspringError::Api {
status_code: 404,
message: "File not found".to_string(),
}),
..Default::default()
};
let command = FilesCommand::Delete {
record_id: 1,
field_id: 2,
file_id: 3,
};
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): File not found"))
);
}
}
+3 -1
View File
@@ -2,6 +2,7 @@ pub mod apps;
pub mod fields; pub mod fields;
pub mod ping; pub mod ping;
pub mod records; pub mod records;
pub mod files;
use std::io::Write; use std::io::Write;
@@ -18,6 +19,7 @@ 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 Command::Records { command } => records::handle(command, client, writer, cli.pretty).await,
Command::Files { command } => files::handle(command, client, writer, cli.pretty).await,
} }
} }
+45 -2
View File
@@ -1,8 +1,10 @@
use std::io::Write; use std::io::Write;
use chrono::{DateTime, Utc};
use onspring::{ use onspring::{
App, CollectionResponse, Field, FormulaOutputType, ListFieldValue, Multiplicity, PagedResponse, App, CollectionResponse, CreatedWithIdResponse, Field, FileInfo, FormulaOutputType,
Record, RecordFieldValue, SaveRecordResponse, ValueType, ListFieldValue, Multiplicity, PagedResponse, Record, RecordFieldValue, SaveRecordResponse,
ValueType,
}; };
use serde::Serialize; use serde::Serialize;
use uuid::Uuid; use uuid::Uuid;
@@ -208,6 +210,47 @@ impl From<SaveRecordResponse> for SaveRecordResponseOutput {
} }
} }
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileInfoOutput {
#[serde(rename = "type")]
pub file_type: Option<String>,
pub content_type: Option<String>,
pub name: Option<String>,
pub created_date: Option<DateTime<Utc>>,
pub modified_date: Option<DateTime<Utc>>,
pub owner: Option<String>,
pub notes: Option<String>,
pub file_href: Option<String>,
}
impl From<FileInfo> for FileInfoOutput {
fn from(value: FileInfo) -> Self {
Self {
file_type: value.file_type,
content_type: value.content_type,
name: value.name,
created_date: value.created_date,
modified_date: value.modified_date,
owner: value.owner,
notes: value.notes,
file_href: value.file_href,
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreatedWithIdResponseOutput {
pub id: i32,
}
impl From<CreatedWithIdResponse> for CreatedWithIdResponseOutput {
fn from(value: CreatedWithIdResponse) -> Self {
Self { id: value.id }
}
}
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"))
+5 -19
View File
@@ -58,11 +58,7 @@ pub fn validate_ids(ids: &[i32], name: &str, max_len: Option<usize>) -> CliResul
} }
pub fn read_body<R: Read>(source: &BodySource, mut reader: R) -> CliResult<String> { pub fn read_body<R: Read>(source: &BodySource, mut reader: R) -> CliResult<String> {
let count = [ let count = [source.json.is_some(), source.file.is_some(), source.stdin]
source.json.is_some(),
source.file.is_some(),
source.stdin,
]
.into_iter() .into_iter()
.filter(|&b| b) .filter(|&b| b)
.count(); .count();
@@ -84,13 +80,8 @@ pub fn read_body<R: Read>(source: &BodySource, mut reader: R) -> CliResult<Strin
} }
if let Some(ref path) = source.file { if let Some(ref path) = source.file {
return std::fs::read_to_string(path).map_err(|e| { return std::fs::read_to_string(path)
CliError::usage(format!( .map_err(|e| CliError::usage(format!("Failed to read file '{}': {}", path.display(), e)));
"Failed to read file '{}': {}",
path.display(),
e
))
});
} }
if source.stdin { if source.stdin {
@@ -104,10 +95,7 @@ pub fn read_body<R: Read>(source: &BodySource, mut reader: R) -> CliResult<Strin
unreachable!() unreachable!()
} }
pub fn parse_body<T: DeserializeOwned, R: Read>( pub fn parse_body<T: DeserializeOwned, R: Read>(source: &BodySource, reader: R) -> CliResult<T> {
source: &BodySource,
reader: R,
) -> CliResult<T> {
let content = read_body(source, reader)?; let content = read_body(source, reader)?;
serde_json::from_str(&content) serde_json::from_str(&content)
.map_err(|e| CliError::usage(format!("Invalid JSON request body: {e}"))) .map_err(|e| CliError::usage(format!("Invalid JSON request body: {e}")))
@@ -261,9 +249,7 @@ mod tests {
let ids = vec![1; 101]; let ids = vec![1; 101];
assert_eq!( assert_eq!(
validate_ids(&ids, "ids", Some(100)), validate_ids(&ids, "ids", Some(100)),
Err(CliError::usage( Err(CliError::usage("ids cannot contain more than 100 values."))
"ids cannot contain more than 100 values."
))
); );
} }