feat: add files endpoint with info, download, upload, and delete

Implement FileInfo, FileResponse, SaveFileRequest, and
CreatedWithIdResponse models. Add get_file_info, get_file (binary
download), upload_file (multipart), and delete_file methods. Tests
cover metadata retrieval, binary content download with headers, file
upload, deletion, and not-found error with message extraction.
This commit is contained in:
Stevan Freeborn
2026-03-25 13:44:03 -05:00
parent f7050506d2
commit 1161c645bf
5 changed files with 252 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
use reqwest::Method;
use crate::client::OnspringClient;
use crate::error::Result;
use crate::models::{CreatedWithIdResponse, FileInfo, FileResponse, SaveFileRequest};
impl OnspringClient {
/// Gets a file's metadata information.
pub async fn get_file_info(
&self,
record_id: i32,
field_id: i32,
file_id: i32,
) -> Result<FileInfo> {
let path = format!(
"/Files/recordId/{}/fieldId/{}/fileId/{}",
record_id, field_id, file_id
);
self.request(Method::GET, &path, &[], Option::<&()>::None)
.await
}
/// Downloads a file's content.
pub async fn get_file(
&self,
record_id: i32,
field_id: i32,
file_id: i32,
) -> Result<FileResponse> {
let path = format!(
"/Files/recordId/{}/fieldId/{}/fileId/{}/file",
record_id, field_id, file_id
);
let (_status, headers, data) = self.request_bytes(Method::GET, &path, &[]).await?;
let content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(String::from);
let file_name = headers
.get("content-disposition")
.and_then(|v| v.to_str().ok())
.and_then(|v| {
v.split("filename=")
.nth(1)
.map(|s| s.trim_matches('"').to_string())
});
Ok(FileResponse {
content_type,
file_name,
data,
})
}
/// Uploads a file attachment.
pub async fn upload_file(&self, request: SaveFileRequest) -> Result<CreatedWithIdResponse> {
let file_part = reqwest::multipart::Part::bytes(request.file_data)
.file_name(request.file_name)
.mime_str(&request.content_type)
.map_err(|e| crate::error::OnspringError::InvalidArgument(e.to_string()))?;
let mut form = reqwest::multipart::Form::new()
.text("RecordId", request.record_id.to_string())
.text("FieldId", request.field_id.to_string())
.part("File", file_part);
if let Some(notes) = request.notes {
form = form.text("Notes", notes);
}
if let Some(date) = request.modified_date {
form = form.text("ModifiedDate", date.to_rfc3339());
}
self.request_multipart("/Files", form).await
}
/// Deletes a file attachment.
pub async fn delete_file(&self, record_id: i32, field_id: i32, file_id: i32) -> Result<()> {
let path = format!(
"/Files/recordId/{}/fieldId/{}/fileId/{}",
record_id, field_id, file_id
);
self.request_no_content(Method::DELETE, &path, &[], Option::<&()>::None)
.await
}
}
+1
View File
@@ -1,4 +1,5 @@
mod apps; mod apps;
mod fields; mod fields;
mod files;
mod ping; mod ping;
mod records; mod records;
+45
View File
@@ -0,0 +1,45 @@
use bytes::Bytes;
use chrono::{DateTime, Utc};
use serde::Deserialize;
/// Information about a file attachment.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileInfo {
#[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>,
}
/// The content of a downloaded file.
#[derive(Debug, Clone)]
pub struct FileResponse {
pub content_type: Option<String>,
pub file_name: Option<String>,
pub data: Bytes,
}
/// Request to upload a file.
#[derive(Debug, Clone)]
pub struct SaveFileRequest {
pub record_id: i32,
pub field_id: i32,
pub notes: Option<String>,
pub modified_date: Option<DateTime<Utc>>,
pub file_name: String,
pub file_data: Vec<u8>,
pub content_type: String,
}
/// Response from creating a file, containing the new file ID.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreatedWithIdResponse {
pub id: i32,
}
+2
View File
@@ -1,11 +1,13 @@
mod app; mod app;
mod enums; mod enums;
mod field; mod field;
pub mod file;
mod paging; mod paging;
pub mod record; pub mod record;
pub use app::*; pub use app::*;
pub use enums::*; pub use enums::*;
pub use field::*; pub use field::*;
pub use file::*;
pub use paging::*; pub use paging::*;
pub use record::*; pub use record::*;
+116
View File
@@ -0,0 +1,116 @@
mod common;
use wiremock::matchers::{method, path};
use wiremock::{Mock, ResponseTemplate};
#[tokio::test]
async fn test_get_file_info() {
let (mock_server, client) = common::setup().await;
Mock::given(method("GET"))
.and(path("/Files/recordId/1/fieldId/2/fileId/3"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"type": "Attachment",
"contentType": "application/pdf",
"name": "document.pdf",
"createdDate": "2024-01-15T10:30:00Z",
"modifiedDate": "2024-01-15T10:30:00Z",
"owner": "admin",
"notes": "Important doc",
"fileHref": "/Files/recordId/1/fieldId/2/fileId/3/file"
})))
.mount(&mock_server)
.await;
let info = client.get_file_info(1, 2, 3).await.unwrap();
assert_eq!(info.name.as_deref(), Some("document.pdf"));
assert_eq!(info.content_type.as_deref(), Some("application/pdf"));
assert_eq!(info.owner.as_deref(), Some("admin"));
}
#[tokio::test]
async fn test_get_file_content() {
let (mock_server, client) = common::setup().await;
let file_bytes = b"hello file content";
Mock::given(method("GET"))
.and(path("/Files/recordId/1/fieldId/2/fileId/3/file"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(file_bytes.to_vec())
.insert_header("content-type", "application/pdf")
.insert_header("content-disposition", "attachment; filename=\"test.pdf\""),
)
.mount(&mock_server)
.await;
let response = client.get_file(1, 2, 3).await.unwrap();
assert_eq!(response.data.as_ref(), file_bytes);
assert_eq!(response.content_type.as_deref(), Some("application/pdf"));
assert_eq!(response.file_name.as_deref(), Some("test.pdf"));
}
#[tokio::test]
async fn test_upload_file() {
let (mock_server, client) = common::setup().await;
Mock::given(method("POST"))
.and(path("/Files"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": 42
})))
.mount(&mock_server)
.await;
let request = onspring::models::file::SaveFileRequest {
record_id: 1,
field_id: 2,
notes: Some("test notes".to_string()),
modified_date: None,
file_name: "test.txt".to_string(),
file_data: b"file content".to_vec(),
content_type: "text/plain".to_string(),
};
let result = client.upload_file(request).await.unwrap();
assert_eq!(result.id, 42);
}
#[tokio::test]
async fn test_delete_file() {
let (mock_server, client) = common::setup().await;
Mock::given(method("DELETE"))
.and(path("/Files/recordId/1/fieldId/2/fileId/3"))
.respond_with(ResponseTemplate::new(204))
.mount(&mock_server)
.await;
let result = client.delete_file(1, 2, 3).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_get_file_info_not_found() {
let (mock_server, client) = common::setup().await;
Mock::given(method("GET"))
.and(path("/Files/recordId/1/fieldId/2/fileId/999"))
.respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
"message": "File not found"
})))
.mount(&mock_server)
.await;
let result = client.get_file_info(1, 2, 999).await;
assert!(result.is_err());
if let Err(onspring::OnspringError::Api {
status_code,
message,
}) = result
{
assert_eq!(status_code, 404);
assert_eq!(message, "File not found");
} else {
panic!("Expected Api error");
}
}