redmine_api/api/
uploads.rs

1//! Uploads Rest API Endpoint definitions
2//!
3//! [Redmine Documentation](https://www.redmine.org/projects/redmine/wiki/Rest_api#Attaching-files)
4//!
5//! - [x] upload file endpoint
6//! - [ ] create project file endpoint (in api::files)
7//! - [x] [CreateIssue|crate::api::issues::CreateIssue] parameter for attachments (in api::issues)
8//! - [x] [UpdateIssue|crate::api::issues::UpdateIssue] parameter for attachments (in api::issues)
9//! - [ ] apparently news can have attachments too?
10
11/// The endpoint to upload a file to Redmine for use in either project files
12/// or issue attachments
13///
14use derive_builder::Builder;
15use reqwest::Method;
16use std::borrow::Cow;
17use std::io::Read;
18use std::path::PathBuf;
19
20use crate::api::{Endpoint, NoPagination, QueryParams, ReturnsJsonResponse};
21
22/// return type for the [UploadFile] endpoint, there is not much point in
23/// making your own since it only has one field and if that is not used
24/// calling [UploadFile] is useless
25#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
26pub struct FileUploadToken {
27    /// the file upload token to be used in other endpoints
28    token: String,
29}
30
31/// endpoint to upload a file for use in either project files or issue attachments
32///
33/// the token it returns needs to be passed to one of those endpoints for the file
34/// to actually be visible anywhere in Redmine
35#[derive(Debug, Clone, Builder)]
36#[builder(setter(strip_option))]
37pub struct UploadFile<'a> {
38    /// the actual file to send to Redmine
39    #[builder(setter(into))]
40    file: PathBuf,
41    /// the filename to send to Redmine, if not set it will be taken from
42    /// the file
43    #[builder(default, setter(into))]
44    filename: Option<Cow<'a, str>>,
45}
46
47impl ReturnsJsonResponse for UploadFile<'_> {}
48impl NoPagination for UploadFile<'_> {}
49
50impl<'a> UploadFile<'a> {
51    /// Create a builder for the endpoint.
52    #[must_use]
53    pub fn builder() -> UploadFileBuilder<'a> {
54        UploadFileBuilder::default()
55    }
56}
57
58impl Endpoint for UploadFile<'_> {
59    fn method(&self) -> Method {
60        Method::POST
61    }
62
63    fn endpoint(&self) -> Cow<'static, str> {
64        "uploads.json".into()
65    }
66
67    fn parameters(&self) -> QueryParams {
68        let mut params = QueryParams::default();
69        if let Some(ref filename) = self.filename {
70            params.push("filename", filename);
71        } else {
72            let filename = self.file.file_name();
73            if let Some(filename) = filename {
74                params.push_opt("filename", filename.to_str());
75            }
76        }
77        params
78    }
79
80    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
81        let mut file_content: Vec<u8> = Vec::new();
82        let mut f = std::fs::File::open(&self.file)
83            .map_err(|e| crate::Error::UploadFileError(self.file.clone(), e))?;
84        f.read_to_end(&mut file_content)
85            .map_err(|e| crate::Error::UploadFileError(self.file.clone(), e))?;
86        Ok(Some(("application/octet-stream", file_content)))
87    }
88}
89
90/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
91/// helper struct for outer layers with a upload field holding the inner data
92#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
93pub struct UploadWrapper<T> {
94    /// to parse JSON with upload key
95    pub upload: T,
96}
97
98#[cfg(test)]
99pub(crate) mod test {
100    use super::*;
101    use crate::api::issues::{
102        test::ISSUES_LOCK, CreateIssue, Issue, IssueWrapper, UpdateIssue, UploadedAttachment,
103    };
104    use crate::api::test_helpers::with_project;
105    use std::error::Error;
106    use tracing_test::traced_test;
107
108    #[function_name::named]
109    #[traced_test]
110    #[test]
111    fn test_create_issue_with_attachment() -> Result<(), Box<dyn Error>> {
112        let _w_issues = ISSUES_LOCK.write();
113        let name = format!("unittest_{}", function_name!());
114        with_project(&name, |redmine, project_id, _| {
115            let upload_endpoint = UploadFile::builder().file("README.md").build()?;
116            let UploadWrapper {
117                upload: FileUploadToken { token },
118            } = redmine
119                .json_response_body::<_, UploadWrapper<FileUploadToken>>(&upload_endpoint)?;
120            let create_endpoint = CreateIssue::builder()
121                .project_id(project_id)
122                .subject("Attachment Test Issue")
123                .uploads(vec![UploadedAttachment {
124                    token: token.into(),
125                    filename: "README.md".into(),
126                    description: Some("Uploaded as part of unit test for redmine-api".into()),
127                    content_type: "text/markdown".into(),
128                }])
129                .build()?;
130            redmine.json_response_body::<_, IssueWrapper<Issue>>(&create_endpoint)?;
131            Ok(())
132        })?;
133        Ok(())
134    }
135
136    #[function_name::named]
137    #[traced_test]
138    #[test]
139    fn test_update_issue_with_attachment() -> Result<(), Box<dyn Error>> {
140        let _w_issues = ISSUES_LOCK.write();
141        let name = format!("unittest_{}", function_name!());
142        with_project(&name, |redmine, project_id, _| {
143            let upload_endpoint = UploadFile::builder().file("README.md").build()?;
144            let UploadWrapper {
145                upload: FileUploadToken { token },
146            } = redmine
147                .json_response_body::<_, UploadWrapper<FileUploadToken>>(&upload_endpoint)?;
148            let create_endpoint = CreateIssue::builder()
149                .project_id(project_id)
150                .subject("Attachment Test Issue")
151                .build()?;
152            let IssueWrapper { issue }: IssueWrapper<Issue> =
153                redmine.json_response_body::<_, _>(&create_endpoint)?;
154            let update_endpoint = UpdateIssue::builder()
155                .id(issue.id)
156                .subject("New test subject")
157                .uploads(vec![UploadedAttachment {
158                    token: token.into(),
159                    filename: "README.md".into(),
160                    description: Some("Uploaded as part of unit test for redmine-api".into()),
161                    content_type: "text/markdown".into(),
162                }])
163                .build()?;
164            redmine.ignore_response_body::<_>(&update_endpoint)?;
165            Ok(())
166        })?;
167        Ok(())
168    }
169}