redmine_api/api/
attachments.rs

1//! Attachments Rest API Endpoint definitions
2//!
3//! [Redmine Documentation](https://www.redmine.org/projects/redmine/wiki/Rest_Attachments)
4//!
5//! - [x] specific attachment endpoint
6//! - [x] update attachment endpoint
7//! - [x] delete attachment endpoint
8
9use derive_builder::Builder;
10use reqwest::Method;
11use std::borrow::Cow;
12
13use crate::api::users::UserEssentials;
14use crate::api::{Endpoint, NoPagination, ReturnsJsonResponse};
15
16/// a type for attachment to use as an API return type
17///
18/// alternatively you can use your own type limited to the fields you need
19#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
20pub struct Attachment {
21    /// numeric id
22    pub id: u64,
23    /// filename as specified on upload
24    pub filename: String,
25    /// file size
26    pub filesize: u64,
27    /// content MIME type
28    pub content_type: Option<String>,
29    /// description
30    #[serde(default)]
31    pub description: Option<String>,
32    /// url where the content of this attachment can be downloaded
33    pub content_url: String,
34    /// uploader
35    pub author: UserEssentials,
36    /// The time when this file was uploaded
37    #[serde(
38        serialize_with = "crate::api::serialize_rfc3339",
39        deserialize_with = "crate::api::deserialize_rfc3339"
40    )]
41    pub created_on: time::OffsetDateTime,
42    /// the URL for the thumbnail for this attachment
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub thumbnail_url: Option<String>,
45    /// A string containing a hash of the file content (e.g., SHA256 or MD5).
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub digest: Option<String>,
48    /// An integer representing the download count.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub downloads: Option<u64>,
51}
52
53/// The endpoint for a specific Redmine attachment
54#[derive(Debug, Clone, Builder)]
55#[builder(setter(strip_option))]
56pub struct GetAttachment {
57    /// id of the attachment to retrieve
58    id: u64,
59}
60
61impl ReturnsJsonResponse for GetAttachment {}
62impl NoPagination for GetAttachment {}
63
64impl GetAttachment {
65    /// Create a builder for the endpoint.
66    #[must_use]
67    pub fn builder() -> GetAttachmentBuilder {
68        GetAttachmentBuilder::default()
69    }
70}
71
72impl Endpoint for GetAttachment {
73    fn method(&self) -> Method {
74        Method::GET
75    }
76
77    fn endpoint(&self) -> Cow<'static, str> {
78        format!("attachments/{}.json", &self.id).into()
79    }
80}
81
82/// The endpoint to update a Redmine attachment
83#[derive(Debug, Clone, Builder)]
84#[builder(setter(strip_option))]
85pub struct UpdateAttachment {
86    /// id of the attachment to update
87    id: u64,
88    /// the attachment update data
89    attachment: AttachmentUpdate,
90}
91
92impl UpdateAttachment {
93    /// Create a builder for the endpoint.
94    #[must_use]
95    pub fn builder() -> UpdateAttachmentBuilder {
96        UpdateAttachmentBuilder::default()
97    }
98}
99
100impl ReturnsJsonResponse for UpdateAttachment {}
101impl NoPagination for UpdateAttachment {}
102
103impl Endpoint for UpdateAttachment {
104    fn method(&self) -> Method {
105        Method::PUT
106    }
107
108    fn endpoint(&self) -> Cow<'static, str> {
109        format!("attachments/{}.json", &self.id).into()
110    }
111
112    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
113        Ok(Some((
114            "application/json",
115            serde_json::to_vec(&AttachmentWrapper {
116                attachment: self.attachment.clone(),
117            })?,
118        )))
119    }
120}
121
122/// The attachment update data
123#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
124pub struct AttachmentUpdate {
125    /// new filename
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub filename: Option<String>,
128    /// new description
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub description: Option<String>,
131}
132
133/// The endpoint to delete a Redmine attachment
134#[derive(Debug, Clone, Builder)]
135#[builder(setter(strip_option))]
136pub struct DeleteAttachment {
137    /// id of the attachment to delete
138    id: u64,
139}
140
141impl DeleteAttachment {
142    /// Create a builder for the endpoint.
143    #[must_use]
144    pub fn builder() -> DeleteAttachmentBuilder {
145        DeleteAttachmentBuilder::default()
146    }
147}
148
149impl Endpoint for DeleteAttachment {
150    fn method(&self) -> Method {
151        Method::DELETE
152    }
153
154    fn endpoint(&self) -> Cow<'static, str> {
155        format!("attachments/{}.json", &self.id).into()
156    }
157}
158
159/// helper struct for outer layers with a attachment field holding the inner data
160#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
161pub struct AttachmentWrapper<T> {
162    /// to parse JSON with attachment key
163    pub attachment: T,
164}
165
166#[cfg(test)]
167mod test {
168    use super::*;
169    use pretty_assertions::assert_eq;
170    use std::error::Error;
171    use tracing_test::traced_test;
172
173    #[traced_test]
174    #[test]
175    fn test_get_attachment() -> Result<(), Box<dyn Error>> {
176        use crate::api::issues::{CreateIssue, GetIssue, Issue, IssueWrapper, UploadedAttachment};
177        use crate::api::test_helpers::with_project;
178        use crate::api::uploads::{FileUploadToken, UploadFile, UploadWrapper};
179
180        with_project("test_get_attachment", |redmine, project_id, _| {
181            let upload_endpoint = UploadFile::builder().file("README.md").build()?;
182            let UploadWrapper {
183                upload: FileUploadToken { id: _, token },
184            } = redmine
185                .json_response_body::<_, UploadWrapper<FileUploadToken>>(&upload_endpoint)?;
186            let create_issue_endpoint = CreateIssue::builder()
187                .project_id(project_id)
188                .subject("Attachment Test Issue")
189                .uploads(vec![UploadedAttachment {
190                    token: token.into(),
191                    filename: "README.md".into(),
192                    description: Some("Uploaded as part of unit test for redmine-api".into()),
193                    content_type: "application/octet-stream".into(),
194                }])
195                .build()?;
196            let IssueWrapper {
197                issue: created_issue,
198            } = redmine.json_response_body::<_, IssueWrapper<Issue>>(&create_issue_endpoint)?;
199
200            let get_issue_endpoint = GetIssue::builder()
201                .id(created_issue.id)
202                .include(vec![crate::api::issues::IssueInclude::Attachments])
203                .build()?;
204            let IssueWrapper { issue } =
205                redmine.json_response_body::<_, IssueWrapper<Issue>>(&get_issue_endpoint)?;
206            let attachment_id = issue.attachments.unwrap().first().unwrap().id;
207
208            let endpoint = GetAttachment::builder().id(attachment_id).build()?;
209            redmine.json_response_body::<_, AttachmentWrapper<Attachment>>(&endpoint)?;
210            Ok(())
211        })
212    }
213
214    /// this tests if any of the results contain a field we are not deserializing
215    ///
216    /// this will only catch fields we missed if they are part of the response but
217    /// it is better than nothing
218    #[traced_test]
219    #[test]
220    fn test_completeness_attachment_type() -> Result<(), Box<dyn Error>> {
221        dotenvy::dotenv()?;
222        let redmine = crate::api::Redmine::from_env(
223            reqwest::blocking::Client::builder()
224                .use_rustls_tls()
225                .build()?,
226        )?;
227        let endpoint = GetAttachment::builder().id(38468).build()?;
228        let AttachmentWrapper { attachment: value } =
229            redmine.json_response_body::<_, AttachmentWrapper<serde_json::Value>>(&endpoint)?;
230        let o: Attachment = serde_json::from_value(value.clone())?;
231        let reserialized = serde_json::to_value(o)?;
232        assert_eq!(value, reserialized);
233        Ok(())
234    }
235
236    #[traced_test]
237    #[test]
238    fn test_update_delete_attachment() -> Result<(), Box<dyn Error>> {
239        use crate::api::issues::{CreateIssue, GetIssue, Issue, IssueWrapper, UploadedAttachment};
240        use crate::api::test_helpers::with_project;
241        use crate::api::uploads::{FileUploadToken, UploadFile, UploadWrapper};
242
243        with_project("test_update_delete_attachment", |redmine, project_id, _| {
244            let upload_endpoint = UploadFile::builder().file("README.md").build()?;
245            let UploadWrapper {
246                upload: FileUploadToken { id: _, token },
247            } = redmine
248                .json_response_body::<_, UploadWrapper<FileUploadToken>>(&upload_endpoint)?;
249            let create_issue_endpoint = CreateIssue::builder()
250                .project_id(project_id)
251                .subject("Attachment Test Issue")
252                .uploads(vec![UploadedAttachment {
253                    token: token.into(),
254                    filename: "README.md".into(),
255                    description: Some("Uploaded as part of unit test for redmine-api".into()),
256                    content_type: "application/octet-stream".into(),
257                }])
258                .build()?;
259            let IssueWrapper {
260                issue: created_issue,
261            } = redmine.json_response_body::<_, IssueWrapper<Issue>>(&create_issue_endpoint)?;
262
263            let get_issue_endpoint = GetIssue::builder()
264                .id(created_issue.id)
265                .include(vec![crate::api::issues::IssueInclude::Attachments])
266                .build()?;
267            let IssueWrapper { issue } =
268                redmine.json_response_body::<_, IssueWrapper<Issue>>(&get_issue_endpoint)?;
269            let attachment_id = issue.attachments.unwrap().first().unwrap().id;
270
271            let update_endpoint = UpdateAttachment::builder()
272                .id(attachment_id)
273                .attachment(AttachmentUpdate {
274                    filename: Some("new_readme.md".to_string()),
275                    description: Some("new description".to_string()),
276                })
277                .build()?;
278            redmine.ignore_response_body(&update_endpoint)?;
279
280            let get_endpoint = GetAttachment::builder().id(attachment_id).build()?;
281            let AttachmentWrapper { attachment } =
282                redmine.json_response_body::<_, AttachmentWrapper<Attachment>>(&get_endpoint)?;
283            assert_eq!(attachment.filename, "new_readme.md");
284            assert_eq!(attachment.description.unwrap(), "new description");
285
286            let delete_endpoint = DeleteAttachment::builder().id(attachment_id).build()?;
287            redmine.ignore_response_body(&delete_endpoint)?;
288
289            let get_issue_endpoint = GetIssue::builder()
290                .id(issue.id)
291                .include(vec![crate::api::issues::IssueInclude::Attachments])
292                .build()?;
293            let IssueWrapper { issue } =
294                redmine.json_response_body::<_, IssueWrapper<Issue>>(&get_issue_endpoint)?;
295            assert!(issue.attachments.is_none_or(|v| v.is_empty()));
296            Ok(())
297        })
298    }
299}