diff --git a/src/endpoints/files.rs b/src/endpoints/files.rs new file mode 100644 index 0000000..9253f28 --- /dev/null +++ b/src/endpoints/files.rs @@ -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 { + 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 { + 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 { + 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 + } +} diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs index 64644c5..8aea80c 100644 --- a/src/endpoints/mod.rs +++ b/src/endpoints/mod.rs @@ -1,4 +1,5 @@ mod apps; mod fields; +mod files; mod ping; mod records; diff --git a/src/models/file.rs b/src/models/file.rs new file mode 100644 index 0000000..78eb2a2 --- /dev/null +++ b/src/models/file.rs @@ -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, + pub content_type: Option, + pub name: Option, + pub created_date: Option>, + pub modified_date: Option>, + pub owner: Option, + pub notes: Option, + pub file_href: Option, +} + +/// The content of a downloaded file. +#[derive(Debug, Clone)] +pub struct FileResponse { + pub content_type: Option, + pub file_name: Option, + 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, + pub modified_date: Option>, + pub file_name: String, + pub file_data: Vec, + 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, +} diff --git a/src/models/mod.rs b/src/models/mod.rs index e4c6ebd..a85045a 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -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::*; diff --git a/tests/test_files.rs b/tests/test_files.rs new file mode 100644 index 0000000..080f1f4 --- /dev/null +++ b/tests/test_files.rs @@ -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"); + } +}