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