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:
@@ -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,4 +1,5 @@
|
||||
mod apps;
|
||||
mod fields;
|
||||
mod files;
|
||||
mod ping;
|
||||
mod records;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
mod app;
|
||||
mod enums;
|
||||
mod field;
|
||||
pub mod file;
|
||||
mod paging;
|
||||
pub mod record;
|
||||
|
||||
pub use app::*;
|
||||
pub use enums::*;
|
||||
pub use field::*;
|
||||
pub use file::*;
|
||||
pub use paging::*;
|
||||
pub use record::*;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user