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}