1#![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#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(rename_all = "UPPERCASE")]
46pub enum HttpMethod {
47 Post,
49 Put,
51 Patch,
53}
54
55type Result<T> = std::result::Result<T, Error>;
56
57#[derive(Debug, thiserror::Error)]
59pub enum Error {
60 #[error(transparent)]
63 Io(#[from] std::io::Error),
64 #[error(transparent)]
67 Request(#[from] reqwest::Error),
68 #[error("{0}")]
70 ContentLength(String),
71 #[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 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 let file = File::open(&file_path).await?;
157 let file_len = file.metadata().await.unwrap().len();
158
159 let http_method = method.unwrap_or(HttpMethod::Post);
161
162 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 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
210pub 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}