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 apps;
|
||||||
mod fields;
|
mod fields;
|
||||||
|
mod files;
|
||||||
mod ping;
|
mod ping;
|
||||||
mod records;
|
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 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::*;
|
||||||
|
|||||||
@@ -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