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, 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<'_> {}
48
49impl<'a> UploadFile<'a> {
50    /// Create a builder for the endpoint.
51    #[must_use]
52    pub fn builder() -> UploadFileBuilder<'a> {
53        UploadFileBuilder::default()
54    }
55}
56
57impl Endpoint for UploadFile<'_> {
58    fn method(&self) -> Method {
59        Method::POST
60    }
61
62    fn endpoint(&self) -> Cow<'static, str> {
63        "uploads.json".into()
64    }
65
66    fn parameters(&self) -> QueryParams {
67        let mut params = QueryParams::default();
68        if let Some(ref filename) = self.filename {
69            params.push("filename", filename);
70        } else {
71            let filename = self.file.file_name();
72            if let Some(filename) = filename {
73                params.push_opt("filename", filename.to_str());
74            }
75        }
76        params
77    }
78
79    fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, crate::Error> {
80        let mut file_content: Vec<u8> = Vec::new();
81        let mut f = std::fs::File::open(&self.file)
82            .map_err(|e| crate::Error::UploadFileError(self.file.clone(), e))?;
83        f.read_to_end(&mut file_content)
84            .map_err(|e| crate::Error::UploadFileError(self.file.clone(), e))?;
85        Ok(Some(("application/octet-stream", file_content)))
86    }
87}
88
89/// A lot of APIs in Redmine wrap their data in an extra layer, this is a
90/// helper struct for outer layers with a upload field holding the inner data
91#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
92pub struct UploadWrapper<T> {
93    /// to parse JSON with upload key
94    pub upload: T,
95}
96
97#[cfg(test)]
98pub(crate) mod test {
99    use super::*;
100    use crate::api::issues::{
101        test::ISSUES_LOCK, CreateIssue, Issue, IssueWrapper, UpdateIssue, UploadedAttachment,
102    };
103    use crate::api::test_helpers::with_project;
104    use std::error::Error;
105    use tracing_test::traced_test;
106
107    #[function_name::named]
108    #[traced_test]
109    #[test]
110    fn test_create_issue_with_attachment() -> Result<(), Box<dyn Error>> {
111        let _w_issues = ISSUES_LOCK.write();
112        let name = format!("unittest_{}", function_name!());
113        with_project(&name, |redmine, project_id, _| {
114            let upload_endpoint = UploadFile::builder().file("README.md").build()?;
115            let UploadWrapper {
116                upload: FileUploadToken { token },
117            } = redmine
118                .json_response_body::<_, UploadWrapper<FileUploadToken>>(&upload_endpoint)?;
119            let create_endpoint = CreateIssue::builder()
120                .project_id(project_id)
121                .subject("Attachment Test Issue")
122                .uploads(vec![UploadedAttachment {
123                    token: token.into(),
124                    filename: "README.md".into(),
125                    description: Some("Uploaded as part of unit test for redmine-api".into()),
126                    content_type: "text/markdown".into(),
127                }])
128                .build()?;
129            redmine.json_response_body::<_, IssueWrapper<Issue>>(&create_endpoint)?;
130            Ok(())
131        })?;
132        Ok(())
133    }
134
135    #[function_name::named]
136    #[traced_test]
137    #[test]
138    fn test_update_issue_with_attachment() -> Result<(), Box<dyn Error>> {
139        let _w_issues = ISSUES_LOCK.write();
140        let name = format!("unittest_{}", function_name!());
141        with_project(&name, |redmine, project_id, _| {
142            let upload_endpoint = UploadFile::builder().file("README.md").build()?;
143            let UploadWrapper {
144                upload: FileUploadToken { token },
145            } = redmine
146                .json_response_body::<_, UploadWrapper<FileUploadToken>>(&upload_endpoint)?;
147            let create_endpoint = CreateIssue::builder()
148                .project_id(project_id)
149                .subject("Attachment Test Issue")
150                .build()?;
151            let IssueWrapper { issue }: IssueWrapper<Issue> =
152                redmine.json_response_body::<_, _>(&create_endpoint)?;
153            let update_endpoint = UpdateIssue::builder()
154                .id(issue.id)
155                .subject("New test subject")
156                .uploads(vec![UploadedAttachment {
157                    token: token.into(),
158                    filename: "README.md".into(),
159                    description: Some("Uploaded as part of unit test for redmine-api".into()),
160                    content_type: "text/markdown".into(),
161                }])
162                .build()?;
163            redmine.ignore_response_body::<_>(&update_endpoint)?;
164            Ok(())
165        })?;
166        Ok(())
167    }
168}