utapi_rs/
utapi.rs

1use crate::config::{ApiKey, UploadthingConfig};
2use crate::models::{
3    Acl, ContentDisposition, DeleteFileResponse, FileKeysPayload, FileObj, FileUpload,
4    ListFilesOpts, PresignedUrlOpts, PresignedUrlResponse, RenameFilesOpts, UploadFileOpts,
5    UploadFileResponse, UploadFileResponseData, UploadthingFileResponse, UploadthingUrlsResponse,
6    UploadthingUsageInfo,
7};
8use anyhow::anyhow;
9use filesize::PathExt;
10use rand::{thread_rng, Rng};
11use reqwest::{header, multipart, Client, Response};
12use serde::Serialize;
13use serde_json::json;
14use std::collections::HashMap;
15use std::error::Error;
16use std::io::Read;
17
18use std::time::Duration;
19use tokio::macros::support::Future;
20use tokio::task::JoinHandle;
21
22const MAX_RETRIES: u32 = 20;
23const MAXIMUM_BACKOFF_MS: u64 = 64 * 1000;
24
25/// The `UtApi` struct represents the client for interacting with the Uploadthing API.
26///
27/// It contains the configuration for the service and the HTTP client used to make requests.
28#[derive(Clone)]
29pub struct UtApi {
30    /// Configuration for the Uploadthing service, including the API key and other settings.
31    config: UploadthingConfig,
32
33    /// The HTTP client for making requests to the Uploadthing service.
34    client: Client,
35}
36
37impl UtApi {
38    /// Creates a new instance of `UtApi`.
39    ///
40    /// This constructor initializes the `UtApi` struct with the provided API key
41    /// or, if none is provided, attempts to retrieve the API key from the environment.
42    /// It sets up the `UploadthingConfig` and the internal `Client` for HTTP requests.
43    ///
44    /// # Arguments
45    ///
46    /// * `api_key` - An `Option<String>` that holds the API key for authentication.
47    ///               If `None`, the API key is retrieved from the environment.
48    ///
49    /// # Examples
50    ///
51    /// ```
52    /// // Create a new API client with a provided API key.
53    /// let api_with_key = UtApi::new(Some("your_api_key".to_string()));
54    ///
55    /// // Create a new API client using the API key from the environment.
56    /// let api_with_env_key = UtApi::new(None);
57    /// ```
58    ///
59    /// # Returns
60    ///
61    /// Returns a new `UtApi` struct initialized with the provided or environment API key
62    /// and a new `Client`.
63    ///
64    /// # Panics
65    ///
66    /// Panics if the API key is not provided and is also not set in the environment.
67    pub fn new(api_key: Option<String>) -> UtApi {
68        // Initialize the configuration for the Uploadthing service using the provided API key.
69        // If no API key is provided, attempt to retrieve the key from the environment variable.
70        let api_key = api_key.unwrap_or_else(|| {
71            ApiKey::from_env()
72                .expect("API key not provided and not found in environment")
73                .to_string()
74        });
75
76        // Build the configuration with the retrieved or provided API key.
77        let config = UploadthingConfig::builder().api_key(&api_key).build();
78
79        // Create a new HTTP client for making requests.
80        let client = Client::new();
81
82        // Return a new instance of `UtApi` with the configured settings.
83        UtApi { config, client }
84    }
85
86    /// Creates a new instance of `UtApi` from a given `UploadthingConfig`.
87    ///
88    /// # Arguments
89    ///
90    /// * `config` - An `UploadthingConfig` instance containing the configuration for the service.
91    ///
92    /// # Examples
93    ///
94    /// ```
95    /// let config = UploadthingConfig::builder().api_key("your_api_key").build();
96    /// let api = UtApi::from_config(config);
97    /// ```
98    ///
99    /// # Returns
100    ///
101    /// Returns a new `UtApi` struct initialized with the provided configuration and a new `Client`.
102    pub fn from_config(config: UploadthingConfig) -> UtApi {
103        let client = Client::new();
104        UtApi { config, client }
105    }
106
107    /// Sends a `POST` request to the `Uploadthing` service.
108    ///
109    /// This method constructs a URL using the `pathname` and the host from the configuration,
110    /// then sends a `POST` request with the provided `payload` serialized as JSON.
111    /// It also sets necessary headers, including the user agent, API key, and version,
112    /// as well as a `Cache-Control` header to prevent caching.
113    ///
114    /// # Type Parameters
115    ///
116    /// * `T`: The type of the `payload` that implements `Serialize`.
117    ///
118    /// # Parameters
119    ///
120    /// * `pathname`: The endpoint path to which the request will be sent.
121    /// * `payload`: The data to be serialized and sent as the request body.
122    ///
123    /// # Returns
124    ///
125    /// A `Result` with the HTTP `Response` if the request was successful,
126    /// or an `Error` boxed in a `Box<dyn Error>` if the request failed.
127    ///
128    /// # Errors
129    ///
130    /// If the response status is not a success, this function will return an `Error`
131    /// containing the HTTP error returned by the server.
132    pub async fn request_uploadthing<T: Serialize>(
133        &self,
134        pathname: &str,
135        payload: &T,
136    ) -> Result<Response, anyhow::Error> {
137        // Construct the full URL by appending the pathname to the host from the config.
138        let url = format!("{}/{}", self.config.host, pathname);
139
140        // Perform a POST request with the serialized payload.
141        let response = self
142            .client
143            .post(&url)
144            .json(payload) // Serialize the payload as JSON and set it as the request body.
145            .header(header::CONTENT_TYPE, "application/json")
146            .header(header::CACHE_CONTROL, "no-store") // Ensure the response is not cached.
147            .header(header::USER_AGENT, self.config.user_agent.as_ref().unwrap()) // Set the User-Agent header.
148            .header(
149                "x-uploadthing-api-key",
150                self.config.api_key.as_ref().unwrap().to_string(), // Set the custom API key header.
151            )
152            .header(
153                "x-uploadthing-version",
154                self.config.version.as_ref().unwrap(), // Set the custom version header.
155            )
156            .send() // Send the request.
157            .await?; // Await the async operation, returning an error if one occurs.
158
159        // Check the HTTP response status code to determine success.
160        if response.status().is_success() {
161            Ok(response) // If successful, return the response.
162        } else {
163            // If the response indicates failure, extract and return the error.
164            let response_data =
165                serde_json::to_string_pretty(&response.json::<serde_json::Value>().await?)?.clone();
166            Err(anyhow!(response_data))
167        }
168    }
169
170    /// Sends a `DELETE` request to the `Uploadthing` service to delete a list of files.
171    ///
172    /// This method accepts a list of file keys and constructs a payload to send to the
173    /// `/api/deleteFile` endpoint. It then calls the `request_uploadthing` method to
174    /// perform the actual request.
175    ///
176    /// # Parameters
177    ///
178    /// * `file_keys`: A `Vec<String>` containing the keys of the files to be deleted.
179    ///
180    /// # Returns
181    ///
182    /// A `Result` with a `DeleteFileResponse` if the deletion was successful,
183    /// or an `Error` boxed in a `Box<dyn Error>` if the request failed.
184    ///
185    /// # Errors
186    ///
187    /// If the response status is not a success, or if the response cannot be deserialized
188    /// into a `DeleteFileResponse`, this function will return an `Error`.
189    pub async fn delete_files(
190        &self,
191        file_keys: Vec<String>,
192    ) -> Result<DeleteFileResponse, Box<dyn Error>> {
193        // Construct the payload with the file keys to be deleted.
194        let payload = FileKeysPayload { file_keys };
195
196        // Make a `DELETE` request to the Uploadthing service using the constructed payload.
197        let response = self
198            .request_uploadthing("/api/deleteFile", &payload)
199            .await?;
200
201        // Deserialize the JSON response into the `DeleteFileResponse` struct.
202        // This holds the result of the delete operation.
203        let delete_response: DeleteFileResponse = response.json().await?;
204
205        // Return the deserialized delete response.
206        Ok(delete_response)
207    }
208
209    /// Retrieves the URLs for a list of file keys from the `Uploadthing` service.
210    ///
211    /// # Parameters
212    ///
213    /// * `file_keys`: A `Vec<String>` containing the keys of the files whose URLs are to be retrieved.
214    ///
215    /// # Returns
216    ///
217    /// A `Result` with a `UploadthingUrlsResponse` if the retrieval was successful,
218    /// or an `Error` boxed in a `Box<dyn Error>` if the request failed.
219    ///
220    /// # Errors
221    ///
222    /// If the response status is not a success, or if the response cannot be deserialized
223    /// into a `UploadthingUrlsResponse`, this function will return an `Error`.
224    pub async fn get_file_urls(
225        &self,
226        file_keys: Vec<String>,
227    ) -> Result<UploadthingUrlsResponse, Box<dyn Error>> {
228        // Construct the payload with the file keys for which URLs are to be retrieved.
229        let payload = FileKeysPayload { file_keys };
230
231        // Make a `POST` request to the Uploadthing service using the constructed payload.
232        // Note: Assuming that the `getFileUrl` API uses a POST method as it was unspecified;
233        // adapt the HTTP method according to the API specification if necessary.
234        let response = self
235            .request_uploadthing("/api/getFileUrl", &payload)
236            .await?;
237
238        // Deserialize the JSON response into the `UploadthingUrlsResponse` struct.
239        // This holds the URLs for the requested file keys.
240        let urls_response: UploadthingUrlsResponse = response.json().await?;
241
242        // Return the deserialized URLs response.
243        Ok(urls_response)
244    }
245
246    /// Lists files stored in `Uploadthing` service.
247    ///
248    /// # Parameters
249    ///
250    /// * `opts`: An optional `ListFilesOpts` struct with parameters to control pagination.
251    ///
252    /// # Returns
253    ///
254    /// A `Result` with a `UploadthingFileResponse` if the retrieval was successful,
255    /// or an `Error` boxed in a `Box<dyn Error>` if the request failed.
256    ///
257    /// # Errors
258    ///
259    /// If the response status is not a success, or if the response cannot be deserialized
260    /// into a `UploadthingFileResponse`, this function will return an `Error`.
261    pub async fn list_files(
262        &self,
263        opts: Option<ListFilesOpts>,
264    ) -> Result<UploadthingFileResponse, Box<dyn Error>> {
265        // You might serialize `None` to send no specific parameters,
266        // or provide a default instance of ListFilesOpts with desired default values.
267        let payload = opts.unwrap_or_default();
268
269        // Make a `POST` request to the Uploadthing service using the constructed payload.
270        let response = self.request_uploadthing("/api/listFiles", &payload).await?;
271
272        // Deserialize the JSON response into the `UploadthingFileResponse` struct.
273        let file_response: UploadthingFileResponse = response.json().await?;
274
275        // Return the deserialized file response.
276        Ok(file_response)
277    }
278
279    /// Renames files in the `Uploadthing` service according to the given options.
280    ///
281    /// # Parameters
282    ///
283    /// * `files`: A `RenameFilesOpts` struct with the file keys and new names for renaming.
284    ///
285    /// # Returns
286    ///
287    /// An `Ok` result if the renaming operation was successful,
288    /// or an `Error` boxed in a `Box<dyn Error>` if the request failed.
289    ///
290    /// # Errors
291    ///
292    /// If the response status is not a success, this function will return an `Error`.
293    pub async fn rename_files(&self, files: RenameFilesOpts) -> Result<(), Box<dyn Error>> {
294        // Make a `POST` request to the Uploadthing service using the constructed payload.
295        // No response content is expected based on the comment in the Go code.
296        let _response = self.request_uploadthing("/api/renameFiles", &files).await?;
297
298        // If successful, return an `Ok` result with no value.
299        Ok(())
300    }
301
302    /// Gets usage information for the current `Uploadthing` account.
303    ///
304    /// # Returns
305    ///
306    /// A `Result` with a `UploadthingUsageInfo` if the retrieval was successful,
307    /// or an `Error` boxed in a `Box<dyn Error>` if the request failed.
308    ///
309    /// # Errors
310    ///
311    /// If the response status is not a success, or if the response cannot be deserialized
312    /// into an `UploadthingUsageInfo`, this function will return an `Error`.
313    pub async fn get_usage_info(&self) -> Result<UploadthingUsageInfo, Box<dyn Error>> {
314        // Make a `GET` request to the Uploadthing service to get the usage info.
315        // An empty payload is assumed because of the "bytes.NewBuffer([]byte{})" in Go code.
316        let response = self.request_uploadthing("/api/getUsageInfo", &()).await?;
317
318        // Deserialize the JSON response into the `UploadthingUsageInfo` struct.
319        let usage_info: UploadthingUsageInfo = response.json().await?;
320
321        // Return the deserialized usage information.
322        Ok(usage_info)
323    }
324
325    /// Generates a presigned URL for a file.
326    ///
327    /// The maximum value for `expires_in` is 604800 (7 days).
328    /// This function assumes that you must accept overrides on the UploadThing dashboard
329    /// for `expires_in` to be accepted.
330    ///
331    /// # Parameters
332    ///
333    /// * `opts`: A `PresignedUrlOpts` struct containing options for the presigned URL,
334    ///           including the file key and the expiration time in seconds.
335    ///
336    /// # Returns
337    ///
338    /// A `Result` with a `String` presigned URL if the operation was successful,
339    /// or an `Error` boxed in a `Box<dyn Error>` if the request failed, including
340    /// scenarios where `expires_in` is greater than the allowed maximum.
341    ///
342    /// # Errors
343    ///
344    /// If `expires_in` is greater than 604800 or if an error occurs during the request,
345    /// an `Error` is returned.
346    pub async fn get_presigned_url(
347        &self,
348        opts: PresignedUrlOpts,
349    ) -> Result<String, Box<dyn Error>> {
350        // Validate expiresIn.
351        if opts.expires_in.unwrap_or(0) > 604800 {
352            return Err(Box::new(std::io::Error::new(
353                std::io::ErrorKind::InvalidInput,
354                "expiresIn must be less than 604800",
355            )));
356        }
357
358        // Make a `POST` request to the Uploadthing service using the constructed payload.
359        let response = self
360            .request_uploadthing("/api/requestFileAccess", &opts)
361            .await?;
362
363        // Deserialize the JSON response into the `PresignedUrlResponse` struct.
364        let url_response: PresignedUrlResponse = response.json().await?;
365
366        // Return the `url` from the deserialized response.
367        Ok(url_response.url)
368    }
369
370    /// Uploads files to the `Uploadthing` service.
371    pub async fn upload_files(
372        &self,
373        files: Vec<FileObj>,
374        opts: Option<UploadFileOpts>,
375        wait_until_done: bool,
376    ) -> Result<Vec<FileUpload>, Box<dyn Error>> {
377        let mut metadata = HashMap::new();
378        let mut content_disposition = "inline";
379        let mut acl = "public-read";
380
381        match opts {
382            None => {}
383            Some(o) => {
384                metadata = o.metadata.unwrap_or(HashMap::new());
385                content_disposition = match o.content_disposition {
386                    None => "inline",
387                    Some(cd) => match cd {
388                        ContentDisposition::Attachment => "attachment",
389                        ContentDisposition::Inline => "inline",
390                    },
391                };
392                acl = match o.acl {
393                    None => "public-read",
394                    Some(acl) => match acl {
395                        Acl::Private => "private",
396                        Acl::PublicRead => "public-read",
397                    },
398                }
399            }
400        }
401
402        let value = self
403            .upload_files_internal(files, metadata, content_disposition, acl, wait_until_done)
404            .await?;
405        Ok(value)
406    }
407
408    /// Ping UploadThing to send a message saying a file is going to be uploaded, then upload it.
409    async fn upload_files_internal(
410        &self,
411        files: Vec<FileObj>,
412        metadata: HashMap<String, String>,
413        content_disposition: &str,
414        acl: &str,
415        wait_until_done: bool,
416    ) -> Result<Vec<FileUpload>, anyhow::Error> {
417        let file_data = files
418            .iter()
419            .map(|f| {
420                let mime_type = mime_guess::from_path(&f.path);
421                let mime = mime_type.first_or_octet_stream().to_string();
422                serde_json::json!({
423                    "name": f.name,
424                    "type": mime,
425                    "size": f.path.size_on_disk().expect("Should be able to get file size"),
426                })
427            })
428            .collect::<Vec<_>>();
429
430        let json_data = &json!({
431            "files": file_data,
432            "metadata": metadata,
433            "contentDisposition": content_disposition,
434            "acl": acl
435        });
436        let response = self
437            .request_uploadthing("/api/uploadFiles", json_data)
438            .await;
439
440        let response = match response {
441            Err(e) => {
442                eprintln!("[UT] Error uploading files: {}", e);
443                eprintln!(
444                    "[UT] Data sent in request:\n{}",
445                    serde_json::to_string(&json_data).unwrap()
446                );
447                return Err(e);
448            }
449            Ok(r) => r,
450        };
451
452        let uf_response: UploadFileResponse = response.json().await?;
453        let mut handles = vec![];
454        for (i, file) in files.iter().enumerate() {
455            let presigned = uf_response.data[i].clone();
456            let data = file_data[i].clone();
457            let path = file.path.clone();
458            let client = self.clone();
459            let task: JoinHandle<Result<FileUpload, anyhow::Error>> = tokio::task::spawn(
460                async move {
461                    // TODO: handle multi files vs. single url
462                    //
463                    // Actual JS implementation:
464                    //
465                    // if ("urls" in presigned) {
466                    // 	await uploadMultipart(file, presigned, {
467                    // 		...opts
468                    // 	});
469                    // } else {
470                    // 	await uploadPresignedPost(file, presigned, {
471                    // 		...opts
472                    // 	});
473                    // }
474                    let mut f = std::fs::File::open(&path)?;
475                    let file_name = data["name"].as_str().unwrap().to_string();
476
477                    tokio::select! {
478                        result = client.upload_presigned_post(file_name.clone(), &mut f, &presigned) => {
479                            match result {
480                                Ok(_) => {}
481                                Err(e) => {
482                                    eprintln!("[UT] Error uploading file {:?}: {}", path, e);
483                                    return Err(e);
484                                }
485                            }
486                        }
487                        _ = tokio::signal::ctrl_c() => {
488                            eprintln!("[UT] Upload cancelled for file {:?}", path);
489                            return Err(anyhow::anyhow!("Upload cancelled"));
490                        }
491                    }
492                    if wait_until_done {
493                        // Poll for file data
494                        let url =
495                            format!("{}/api/pollUpload/{}", client.config.host, presigned.key);
496
497                        tokio::select! {
498                            result = retry_with_time_delays(|| client.poll_for_file_data(&url)) => {
499                                result?;
500                            }
501                            _ = tokio::signal::ctrl_c() => {
502                                eprintln!("[UT] Polling cancelled for file {:?}", path);
503                                return Err(anyhow::anyhow!("Polling cancelled"));
504                            }
505                        }
506                    }
507
508                    Ok(FileUpload {
509                        key: presigned.key.clone(),
510                        url: presigned.file_url.clone(),
511                        name: file_name,
512                        size: data["size"].as_u64().unwrap(),
513                    })
514                },
515            );
516
517            handles.push(task);
518        }
519
520        let uploads = futures::future::try_join_all(handles).await?;
521        let uploads: Vec<FileUpload> = uploads.into_iter().filter_map(Result::ok).collect();
522
523        Ok(uploads)
524    }
525
526    /// Uploads a file using a POST request to the Uploadthing service.
527    async fn upload_presigned_post(
528        &self,
529        file_name: String,
530        file: &mut std::fs::File,
531        presigned: &UploadFileResponseData,
532    ) -> Result<(), anyhow::Error> {
533        let mut form = multipart::Form::new();
534
535        for (k, v) in presigned.fields.as_object().unwrap().iter() {
536            let value = v.clone().to_owned().as_str().unwrap().to_owned();
537            form = form.text(k.clone(), value);
538        }
539
540        let mut file_bytes = Vec::new();
541        file.read_to_end(&mut file_bytes)?;
542        let file_part = multipart::Part::bytes(file_bytes).file_name(file_name.clone());
543        form = form.part("file", file_part);
544
545        let res = self
546            .client
547            .post(&presigned.presigned_url)
548            .header(
549                "x-uploadthing-api-key",
550                self.config.api_key.as_ref().unwrap().to_string(),
551            )
552            .multipart(form)
553            .send()
554            .await?;
555
556        if !res.status().is_success() {
557            let text = res.text().await?;
558            eprintln!("Failed to upload file: {}", text);
559        }
560
561        Ok(())
562    }
563
564    /// Make a request to UploadThing to check if the file has finished uploading.
565    async fn poll_for_file_data(&self, url: &str) -> Result<Option<()>, anyhow::Error> {
566        let res = self
567            .client
568            .get(url)
569            .header(
570                "x-uploadthing-api-key",
571                self.config.api_key.as_ref().unwrap().to_string(),
572            )
573            .send()
574            .await;
575
576        let res = match res {
577            Ok(res) => res,
578            Err(err) => {
579                println!("[UT] Error polling for file data for {}: {}", url, err);
580                return Err(anyhow!(err));
581            }
582        };
583
584        let maybe_json: Result<serde_json::Value, _> =
585            res.json().await.map_err(|err| err.to_string());
586
587        match maybe_json {
588            Ok(json) => {
589                if json["status"] == "done" {
590                    return Ok(Some(()));
591                }
592            }
593            Err(err) => {
594                println!("[UT] Error polling for file data for {}: {}", url, err);
595            }
596        }
597
598        Ok(None)
599    }
600}
601
602/// Retry a function with exponential timed back-off.
603async fn retry_with_time_delays<F, T, Fut>(do_the_thing: F) -> Result<Option<T>, anyhow::Error>
604where
605    F: Fn() -> Fut,
606    Fut: Future<Output = Result<Option<T>, anyhow::Error>>,
607{
608    let mut tries = 0;
609    let mut backoff_ms = 500;
610    let mut backoff_fuzz_ms: i32;
611
612    loop {
613        if tries > MAX_RETRIES {
614            return Ok(None);
615        }
616
617        let result = do_the_thing().await;
618        if result.is_ok() {
619            return result;
620        }
621
622        tries += 1;
623        backoff_ms = std::cmp::min(MAXIMUM_BACKOFF_MS, backoff_ms * 2);
624        backoff_fuzz_ms = thread_rng().gen_range(0..500);
625
626        if tries > 3 {
627            println!(
628                "[UT] Call unsuccessful after {} tries. Retrying in {} seconds...",
629                tries,
630                backoff_ms / 1000
631            );
632        }
633
634        tokio::select! {
635            _ = tokio::signal::ctrl_c() => {
636                return Err(anyhow::anyhow!("Operation cancelled"));
637            }
638            _ = tokio::time::sleep(Duration::from_millis(backoff_ms + backoff_fuzz_ms as u64)) => {}
639        }
640    }
641}