Skip to main content

tauri_plugin_upload/
lib.rs

1// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-License-Identifier: MIT
4
5//! Upload files from disk to a remote server over HTTP.
6//!
7//! Download files from a remote HTTP server to disk.
8//!
9//! ## Cargo features
10//!
11//! - **rustls-tls** *(enabled by default)*: Enables TLS functionality provided by `rustls`.
12//! - **native-tls**: Enables TLS functionality provided by `native-tls`.
13//! - **native-tls-vendored**: Enables the `vendored` feature of `native-tls`.
14
15#![doc(
16    html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png",
17    html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png"
18)]
19
20mod transfer_stats;
21use transfer_stats::TransferStats;
22
23use futures_util::TryStreamExt;
24use serde::{Deserialize, Serialize, ser::Serializer};
25use tauri::{
26    Runtime, command,
27    ipc::Channel,
28    plugin::{Builder as PluginBuilder, TauriPlugin},
29};
30use tokio::{
31    fs::File,
32    io::{AsyncWriteExt, BufWriter},
33};
34use tokio_util::codec::{BytesCodec, FramedRead};
35
36use read_progress_stream::ReadProgressStream;
37
38use std::collections::HashMap;
39
40/// The HTTP method used to send the file in the `upload` command.
41///
42/// Serialized as an uppercase string (`"POST"`, `"PUT"` or `"PATCH"`) to match the JavaScript
43/// guest bindings.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(rename_all = "UPPERCASE")]
46pub enum HttpMethod {
47    /// Send the file with an HTTP `POST` request. This is the default when no method is given.
48    Post,
49    /// Send the file with an HTTP `PUT` request.
50    Put,
51    /// Send the file with an HTTP `PATCH` request.
52    Patch,
53}
54
55type Result<T> = std::result::Result<T, Error>;
56
57/// The error type returned by this plugin's `upload` and `download` commands.
58#[derive(Debug, thiserror::Error)]
59pub enum Error {
60    /// An I/O error, for example failing to open, read or write the file at the given path, or
61    /// the background task that performs the transfer panicking.
62    #[error(transparent)]
63    Io(#[from] std::io::Error),
64    /// An error returned by the underlying [`reqwest`] HTTP client while sending the request or
65    /// reading the response.
66    #[error(transparent)]
67    Request(#[from] reqwest::Error),
68    /// The content length of the request or response could not be determined.
69    #[error("{0}")]
70    ContentLength(String),
71    /// The HTTP response did not indicate success. Contains the status code and the response
72    /// body text.
73    #[error("request failed with status code {0}: {1}")]
74    HttpErrorCode(u16, String),
75}
76
77impl Serialize for Error {
78    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
79    where
80        S: Serializer,
81    {
82        serializer.serialize_str(self.to_string().as_ref())
83    }
84}
85
86#[derive(Clone, Serialize)]
87#[serde(rename_all = "camelCase")]
88struct ProgressPayload {
89    progress: u64,
90    progress_total: u64,
91    total: u64,
92    transfer_speed: u64,
93}
94
95#[command]
96async fn download(
97    url: String,
98    file_path: String,
99    headers: HashMap<String, String>,
100    body: Option<String>,
101    on_progress: Channel<ProgressPayload>,
102) -> Result<()> {
103    tokio::spawn(async move {
104        let client = reqwest::Client::new();
105        let mut request = if let Some(body) = body {
106            client.post(&url).body(body)
107        } else {
108            client.get(&url)
109        };
110        // Loop trought the headers keys and values
111        // and add them to the request object.
112        for (key, value) in headers {
113            request = request.header(&key, value);
114        }
115
116        let response = request.send().await?;
117        if !response.status().is_success() {
118            return Err(Error::HttpErrorCode(
119                response.status().as_u16(),
120                response.text().await.unwrap_or_default(),
121            ));
122        }
123        let total = response.content_length().unwrap_or(0);
124
125        let mut file = BufWriter::new(File::create(&file_path).await?);
126        let mut stream = response.bytes_stream();
127
128        let mut stats = TransferStats::default();
129        while let Some(chunk) = stream.try_next().await? {
130            file.write_all(&chunk).await?;
131            stats.record_chunk_transfer(chunk.len());
132            let _ = on_progress.send(ProgressPayload {
133                progress: chunk.len() as u64,
134                progress_total: stats.total_transferred,
135                total,
136                transfer_speed: stats.transfer_speed,
137            });
138        }
139        file.flush().await?;
140        Ok(())
141    })
142    .await
143    .map_err(|e| Error::Io(std::io::Error::other(e.to_string())))?
144}
145
146#[command]
147async fn upload(
148    url: String,
149    file_path: String,
150    headers: HashMap<String, String>,
151    method: Option<HttpMethod>,
152    on_progress: Channel<ProgressPayload>,
153) -> Result<String> {
154    tokio::spawn(async move {
155        // Read the file
156        let file = File::open(&file_path).await?;
157        let file_len = file.metadata().await.unwrap().len();
158
159        // Get HTTP method (defaults to POST)
160        let http_method = method.unwrap_or(HttpMethod::Post);
161
162        // Create the request and attach the file to the body
163        let client = reqwest::Client::new();
164        let mut request = match http_method {
165            HttpMethod::Put => client.put(&url),
166            HttpMethod::Patch => client.patch(&url),
167            HttpMethod::Post => client.post(&url),
168        }
169        .header(reqwest::header::CONTENT_LENGTH, file_len)
170        .body(file_to_body(on_progress, file, file_len));
171
172        // Loop through the headers keys and values
173        // and add them to the request object.
174        for (key, value) in headers {
175            request = request.header(&key, value);
176        }
177
178        let response = request.send().await?;
179        if response.status().is_success() {
180            response.text().await.map_err(Into::into)
181        } else {
182            Err(Error::HttpErrorCode(
183                response.status().as_u16(),
184                response.text().await.unwrap_or_default(),
185            ))
186        }
187    })
188    .await
189    .map_err(|e| Error::Io(std::io::Error::other(e.to_string())))?
190}
191
192fn file_to_body(channel: Channel<ProgressPayload>, file: File, file_len: u64) -> reqwest::Body {
193    let stream = FramedRead::new(file, BytesCodec::new()).map_ok(|r| r.freeze());
194
195    let mut stats = TransferStats::default();
196    reqwest::Body::wrap_stream(ReadProgressStream::new(
197        stream,
198        Box::new(move |progress, _total| {
199            stats.record_chunk_transfer(progress as usize);
200            let _ = channel.send(ProgressPayload {
201                progress,
202                progress_total: stats.total_transferred,
203                total: file_len,
204                transfer_speed: stats.transfer_speed,
205            });
206        }),
207    ))
208}
209
210/// Initializes the upload plugin, registering the `upload` and `download` commands.
211pub fn init<R: Runtime>() -> TauriPlugin<R> {
212    PluginBuilder::new("upload")
213        .invoke_handler(tauri::generate_handler![download, upload])
214        .build()
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use mockito::{self, Mock, Server, ServerGuard};
221    use tauri::ipc::InvokeResponseBody;
222    struct MockedServer {
223        _server: ServerGuard,
224        url: String,
225        mocked_endpoint: Mock,
226    }
227
228    #[tokio::test]
229    async fn should_error_on_download_if_status_not_success() {
230        let mocked_server = spawn_server_mocked(400).await;
231        let result = download_file(mocked_server.url).await;
232        mocked_server.mocked_endpoint.assert();
233        assert!(result.is_err());
234    }
235
236    #[tokio::test]
237    async fn should_download_file_successfully() {
238        let mocked_server = spawn_server_mocked(200).await;
239        let result = download_file(mocked_server.url).await;
240        mocked_server.mocked_endpoint.assert();
241        assert!(
242            result.is_ok(),
243            "failed to download file: {}",
244            result.unwrap_err()
245        );
246    }
247
248    #[tokio::test]
249    async fn should_error_on_upload_if_status_not_success() {
250        let mocked_server = spawn_upload_server_mocked(500, "POST").await;
251        let result = upload_file(mocked_server.url, None).await;
252        mocked_server.mocked_endpoint.assert();
253        assert!(result.is_err());
254        match result.unwrap_err() {
255            Error::HttpErrorCode(status, _) => assert_eq!(status, 500),
256            _ => panic!("Expected HttpErrorCode error"),
257        }
258    }
259
260    #[tokio::test]
261    async fn should_error_on_upload_if_file_not_found() {
262        let mocked_server = spawn_upload_server_mocked(200, "POST").await;
263        let file_path = "/nonexistent/file.txt".to_string();
264        let headers = HashMap::new();
265        let sender: Channel<ProgressPayload> =
266            Channel::new(|msg: InvokeResponseBody| -> tauri::Result<()> {
267                let _ = msg;
268                Ok(())
269            });
270
271        let result = upload(mocked_server.url, file_path, headers, None, sender).await;
272        assert!(result.is_err());
273        match result.unwrap_err() {
274            Error::Io(_) => {}
275            _ => panic!("Expected IO error for missing file"),
276        }
277    }
278
279    #[tokio::test]
280    async fn should_upload_file_with_post_method() {
281        let mocked_server = spawn_upload_server_mocked(200, "POST").await;
282        let result = upload_file(mocked_server.url, Some(HttpMethod::Post)).await;
283        mocked_server.mocked_endpoint.assert();
284        assert!(
285            result.is_ok(),
286            "failed to upload file: {}",
287            result.unwrap_err()
288        );
289        let response_body = result.unwrap();
290        assert_eq!(response_body, "upload successful");
291    }
292
293    #[tokio::test]
294    async fn should_upload_file_with_put_method() {
295        let mocked_server = spawn_upload_server_mocked(200, "PUT").await;
296        let result = upload_file(mocked_server.url, Some(HttpMethod::Put)).await;
297        mocked_server.mocked_endpoint.assert();
298        assert!(
299            result.is_ok(),
300            "failed to upload file with PUT: {}",
301            result.unwrap_err()
302        );
303        let response_body = result.unwrap();
304        assert_eq!(response_body, "upload successful");
305    }
306
307    #[tokio::test]
308    async fn should_upload_file_with_patch_method() {
309        let mocked_server = spawn_upload_server_mocked(200, "PATCH").await;
310        let result = upload_file(mocked_server.url, Some(HttpMethod::Patch)).await;
311        mocked_server.mocked_endpoint.assert();
312        assert!(
313            result.is_ok(),
314            "failed to upload file with PATCH: {}",
315            result.unwrap_err()
316        );
317        let response_body = result.unwrap();
318        assert_eq!(response_body, "upload successful");
319    }
320
321    async fn download_file(url: String) -> Result<()> {
322        let file_path = concat!(env!("CARGO_MANIFEST_DIR"), "/test/download.txt").to_string();
323        let headers = HashMap::new();
324        let sender: Channel<ProgressPayload> =
325            Channel::new(|msg: InvokeResponseBody| -> tauri::Result<()> {
326                let _ = msg;
327                Ok(())
328            });
329        download(url, file_path, headers, None, sender).await
330    }
331
332    async fn upload_file(url: String, method: Option<HttpMethod>) -> Result<String> {
333        let file_path = concat!(env!("CARGO_MANIFEST_DIR"), "/test/upload.txt").to_string();
334        let headers = HashMap::new();
335        let sender: Channel<ProgressPayload> =
336            Channel::new(|msg: InvokeResponseBody| -> tauri::Result<()> {
337                let _ = msg;
338                Ok(())
339            });
340        upload(url, file_path, headers, method, sender).await
341    }
342
343    async fn spawn_server_mocked(return_status: usize) -> MockedServer {
344        let mut _server = Server::new_async().await;
345        let path = "/mock_test";
346        let mock = _server
347            .mock("GET", path)
348            .with_status(return_status)
349            .with_body("mocked response body")
350            .create_async()
351            .await;
352
353        let url = _server.url() + path;
354        MockedServer {
355            _server,
356            url,
357            mocked_endpoint: mock,
358        }
359    }
360
361    async fn spawn_upload_server_mocked(return_status: usize, method: &str) -> MockedServer {
362        let mut _server = Server::new_async().await;
363        let path = "/upload_test";
364        let mock = _server
365            .mock(method, path)
366            .with_status(return_status)
367            .with_body("upload successful")
368            .match_header("content-length", "20")
369            .create_async()
370            .await;
371
372        let url = _server.url() + path;
373        MockedServer {
374            _server,
375            url,
376            mocked_endpoint: mock,
377        }
378    }
379}