pcloud/file/
upload.rs

1use crate::folder::FolderIdentifier;
2
3use super::File;
4
5/// Response returned by the `uploadfile` endpoint when uploading multiple files.
6///
7/// Contains the list of uploaded file IDs and their corresponding metadata.
8#[derive(Debug, serde::Deserialize)]
9pub struct MultipartFileUploadResponse {
10    /// The IDs of the uploaded files.
11    #[serde(rename = "fileids")]
12    pub file_ids: Vec<u64>,
13
14    /// Metadata for each uploaded file.
15    pub metadata: Vec<File>,
16}
17
18/// Builder for uploading multiple files to pCloud.
19///
20/// This struct provides a convenient way to assemble a multipart form upload,
21/// either from in-memory data, raw bodies, or asynchronous streams.
22#[derive(Debug, Default)]
23pub struct MultiFileUpload {
24    parts: Vec<reqwest::multipart::Part>,
25}
26
27impl MultiFileUpload {
28    /// Adds a file stream to the upload and returns the updated builder.
29    ///
30    /// This is a chainable version of [`MultiFileUpload::add_stream_entry`].
31    ///
32    /// # Arguments
33    ///
34    /// * `filename` - The name to assign to the uploaded file.
35    /// * `length` - The size of the file in bytes.
36    /// * `stream` - A `TryStream` of bytes representing the file content.
37    pub fn with_stream_entry<F, S>(mut self, filename: F, length: Option<u64>, stream: S) -> Self
38    where
39        F: Into<String>,
40        S: futures_core::stream::TryStream + Send + Sync + 'static,
41        S::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
42        bytes::Bytes: From<S::Ok>,
43    {
44        self.add_stream_entry(filename, length, stream);
45        self
46    }
47
48    /// Adds a file stream to the upload.
49    ///
50    /// # Arguments
51    ///
52    /// * `filename` - The name to assign to the uploaded file.
53    /// * `length` - The size of the file in bytes.
54    /// * `stream` - A `TryStream` of bytes representing the file content.
55    pub fn add_stream_entry<F, S>(&mut self, filename: F, length: Option<u64>, stream: S)
56    where
57        F: Into<String>,
58        S: futures_core::stream::TryStream + Send + Sync + 'static,
59        S::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
60        bytes::Bytes: From<S::Ok>,
61    {
62        let body = reqwest::Body::wrap_stream(stream);
63        self.add_body_entry(filename, length, body);
64    }
65
66    /// Adds a file from a raw body and returns the updated builder.
67    ///
68    /// This is a chainable version of [`MultiFileUpload::add_body_entry`].
69    ///
70    /// # Arguments
71    ///
72    /// * `filename` - The name to assign to the uploaded file.
73    /// * `length` - The size of the file in bytes.
74    /// * `body` - A `reqwest::Body` representing the file data.
75    pub fn with_body_entry<F, B>(mut self, filename: F, length: Option<u64>, body: B) -> Self
76    where
77        F: Into<String>,
78        B: Into<reqwest::Body>,
79    {
80        self.add_body_entry(filename, length, body);
81        self
82    }
83
84    /// Adds a file from a raw body to the upload.
85    ///
86    /// # Arguments
87    ///
88    /// * `filename` - The name to assign to the uploaded file.
89    /// * `length` - The size of the file in bytes.
90    /// * `body` - A `reqwest::Body` containing the file content.
91    pub fn add_body_entry<F, B>(&mut self, filename: F, length: Option<u64>, body: B)
92    where
93        F: Into<String>,
94        B: Into<reqwest::Body>,
95    {
96        let part = if let Some(length) = length {
97            let mut headers = reqwest::header::HeaderMap::with_capacity(1);
98            let content_length = length.to_string();
99            headers.append(
100                reqwest::header::CONTENT_LENGTH,
101                reqwest::header::HeaderValue::from_str(&content_length)
102                    .expect("content-length must be a valid number"),
103            );
104
105            reqwest::multipart::Part::stream_with_length(body, length)
106                .file_name(filename.into())
107                .headers(headers)
108        } else {
109            reqwest::multipart::Part::stream(body).file_name(filename.into())
110        };
111
112        self.parts.push(part);
113    }
114
115    /// Converts the upload builder into a multipart form.
116    ///
117    /// This method is used internally before sending the request.
118    fn into_form(self) -> reqwest::multipart::Form {
119        self.parts.into_iter().enumerate().fold(
120            reqwest::multipart::Form::default(),
121            |form, (index, part)| form.part(format!("f{index}"), part),
122        )
123    }
124}
125
126impl crate::Client {
127    /// Uploads multiple files to a specified folder on pCloud.
128    ///
129    /// This method uses multipart form submission to upload several files in a single request.
130    ///
131    /// # Arguments
132    ///
133    /// * `parent` - A value convertible into a [`FolderIdentifier`] representing the destination folder.
134    /// * `files` - A [`MultiFileUpload`] builder containing the files to upload.
135    ///
136    /// # Returns
137    ///
138    /// On success, returns a list of [`File`] metadata for each uploaded file.
139    ///
140    /// # Errors
141    ///
142    /// Returns a [`crate::Error`] if the upload fails due to network issues,
143    /// invalid input, or server-side errors.
144    ///
145    /// # Examples
146    ///
147    /// ```rust,ignore
148    /// use bytes::Bytes;
149    /// use futures_util::stream;
150    ///
151    /// # async fn example(client: &pcloud::Client) -> Result<(), pcloud::Error> {
152    /// let data = vec![Ok(Bytes::from_static(b"hello world"))];
153    /// let stream = stream::iter(data);
154    ///
155    /// let upload = pcloud::file::upload::MultiFileUpload::default()
156    ///     .with_stream_entry("hello.txt", 11, stream);
157    ///
158    /// let uploaded = client.upload_files("/my-folder", upload).await?;
159    /// println!("Uploaded {} file(s)", uploaded.len());
160    /// # Ok(())
161    /// # }
162    /// ```
163    pub async fn upload_files(
164        &self,
165        parent: impl Into<FolderIdentifier<'_>>,
166        files: MultiFileUpload,
167    ) -> crate::Result<Vec<File>> {
168        self.post_request_multipart::<MultipartFileUploadResponse, _>(
169            "uploadfile",
170            parent.into(),
171            files.into_form(),
172        )
173        .await
174        .map(|res| res.metadata)
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use crate::{file::upload::MultiFileUpload, Client, Credentials};
181    use mockito::Matcher;
182
183    #[tokio::test]
184    async fn multipart_success() {
185        let mut server = mockito::Server::new_async().await;
186        let m_upload = server
187            .mock("POST", "/uploadfile")
188            .match_query(Matcher::AllOf(vec![
189                Matcher::UrlEncoded("access_token".into(), "access-token".into()),
190                Matcher::UrlEncoded("folderid".into(), "0".into()),
191            ]))
192            .match_body(Matcher::Any)
193            .match_header("accept", "*/*")
194            .match_header("user-agent", crate::USER_AGENT)
195            .match_header(
196                "content-type",
197                Matcher::Regex("multipart/form-data; boundary=.*".to_string()),
198            )
199            .match_header("content-length", Matcher::Regex("[0-9]+".to_string()))
200            .with_status(200)
201            .with_body(
202                r#"{
203        "result": 0,
204        "metadata": [
205            {
206                "name": "big-file.bin",
207                "created": "Tue, 09 Aug 2022 13:43:17 +0000",
208                "thumb": false,
209                "modified": "Tue, 09 Aug 2022 13:43:17 +0000",
210                "isfolder": false,
211                "fileid": 15669308155,
212                "hash": 15418918230810325691,
213                "path": "/big-file.bin",
214                "category": 0,
215                "id": "f15669308155",
216                "isshared": false,
217                "ismine": true,
218                "size": 1073741824,
219                "parentfolderid": 0,
220                "contenttype": "application/octet-stream",
221                "icon": "file"
222            }
223        ],
224        "checksums": [
225            {
226                "sha1": "a91d3c45d2ff6dc99ed3d1c150f6fae91b2e10a1",
227                "sha256": "2722eb2ec44a8f5655df8ef3b7c6a1658de40d5aedcab26b3e6d043222681360"
228            }
229        ],
230        "fileids": [15669308155]
231    }"#,
232            )
233            .create();
234        let client = Client::new(server.url(), Credentials::access_token("access-token")).unwrap();
235        //
236        let file = tokio::fs::File::open("./readme.md").await.unwrap();
237        let length = std::fs::metadata("./readme.md").unwrap().len();
238        let files = MultiFileUpload::default().with_body_entry("big-file.bin", Some(length), file);
239        let result = client.upload_files(0, files).await.unwrap();
240        //
241        assert_eq!(result.len(), 1);
242        m_upload.assert();
243    }
244}