Skip to main content

supabase_client_storage/
bucket_api.rs

1use reqwest::header::HeaderValue;
2use serde_json::json;
3
4use crate::client::StorageClient;
5use crate::error::StorageError;
6use crate::types::*;
7
8/// File operations API scoped to a specific bucket.
9///
10/// Created via `StorageClient::from("bucket_name")`.
11///
12/// # Example
13/// ```ignore
14/// let file_api = storage.from("avatars");
15/// file_api.upload("photo.png", data, FileOptions::new()).await?;
16/// let bytes = file_api.download("photo.png").await?;
17/// ```
18#[derive(Debug, Clone)]
19pub struct StorageBucketApi {
20    client: StorageClient,
21    bucket_id: String,
22}
23
24impl StorageBucketApi {
25    pub(crate) fn new(client: StorageClient, bucket_id: String) -> Self {
26        Self { client, bucket_id }
27    }
28
29    /// Upload a file to the bucket.
30    ///
31    /// Mirrors `supabase.storage.from('bucket').upload(path, file, options)`.
32    pub async fn upload(
33        &self,
34        path: &str,
35        data: Vec<u8>,
36        options: FileOptions,
37    ) -> Result<UploadResponse, StorageError> {
38        let url = self
39            .client
40            .url(&format!("/object/{}/{}", self.bucket_id, path));
41
42        let content_type = options
43            .content_type
44            .as_deref()
45            .unwrap_or("application/octet-stream");
46
47        let mut req = self
48            .client
49            .http()
50            .post(url)
51            .header("content-type", content_type)
52            .body(data);
53
54        if let Some(cache) = &options.cache_control {
55            req = req.header("cache-control", cache.as_str());
56        }
57        if let Some(upsert) = options.upsert {
58            req = req.header("x-upsert", if upsert { "true" } else { "false" });
59        }
60        if let Some(metadata) = &options.metadata {
61            let meta_str = serde_json::to_string(metadata)?;
62            req = req.header("x-metadata", meta_str);
63        }
64
65        let resp = req.send().await?;
66        self.client.handle_response(resp).await
67    }
68
69    /// Update (replace) a file in the bucket.
70    ///
71    /// Mirrors `supabase.storage.from('bucket').update(path, file, options)`.
72    pub async fn update(
73        &self,
74        path: &str,
75        data: Vec<u8>,
76        options: Option<FileOptions>,
77    ) -> Result<UploadResponse, StorageError> {
78        let url = self
79            .client
80            .url(&format!("/object/{}/{}", self.bucket_id, path));
81
82        let opts = options.unwrap_or_default();
83        let content_type = opts
84            .content_type
85            .as_deref()
86            .unwrap_or("application/octet-stream");
87
88        let mut req = self
89            .client
90            .http()
91            .put(url)
92            .header("content-type", content_type)
93            .body(data);
94
95        if let Some(cache) = &opts.cache_control {
96            req = req.header("cache-control", cache.as_str());
97        }
98        if let Some(upsert) = opts.upsert {
99            req = req.header("x-upsert", if upsert { "true" } else { "false" });
100        }
101        if let Some(metadata) = &opts.metadata {
102            let meta_str = serde_json::to_string(metadata)?;
103            req = req.header("x-metadata", meta_str);
104        }
105
106        let resp = req.send().await?;
107        self.client.handle_response(resp).await
108    }
109
110    /// Download a file from the bucket.
111    ///
112    /// Returns the raw file bytes.
113    pub async fn download(&self, path: &str) -> Result<Vec<u8>, StorageError> {
114        let url = self
115            .client
116            .url(&format!("/object/{}/{}", self.bucket_id, path));
117        let resp = self.client.http().get(url).send().await?;
118        self.client.handle_bytes_response(resp).await
119    }
120
121    /// List files in the bucket.
122    ///
123    /// `path` is the folder prefix (e.g., `"folder"` or `None` for root).
124    pub async fn list(
125        &self,
126        path: Option<&str>,
127        options: Option<SearchOptions>,
128    ) -> Result<Vec<FileObject>, StorageError> {
129        let url = self
130            .client
131            .url(&format!("/object/list/{}", self.bucket_id));
132
133        let mut body = json!({
134            "prefix": path.unwrap_or(""),
135        });
136
137        if let Some(opts) = options {
138            if let Some(limit) = opts.limit {
139                body["limit"] = json!(limit);
140            }
141            if let Some(offset) = opts.offset {
142                body["offset"] = json!(offset);
143            }
144            if let Some(sort_by) = opts.sort_by {
145                body["sortBy"] = json!(sort_by);
146            }
147            if let Some(search) = opts.search {
148                body["search"] = json!(search);
149            }
150        }
151
152        let resp = self.client.http().post(url).json(&body).send().await?;
153        self.client.handle_response(resp).await
154    }
155
156    /// Move a file within the bucket.
157    ///
158    /// Mirrors `supabase.storage.from('bucket').move(from, to)`.
159    pub async fn move_file(&self, from: &str, to: &str) -> Result<(), StorageError> {
160        let url = self.client.url("/object/move");
161        let body = json!({
162            "bucketId": self.bucket_id,
163            "sourceKey": from,
164            "destinationKey": to,
165        });
166
167        let resp = self.client.http().post(url).json(&body).send().await?;
168        self.client.handle_empty_response(resp).await
169    }
170
171    /// Copy a file within the bucket.
172    ///
173    /// Returns the key of the new file.
174    pub async fn copy(&self, from: &str, to: &str) -> Result<String, StorageError> {
175        let url = self.client.url("/object/copy");
176        let body = json!({
177            "bucketId": self.bucket_id,
178            "sourceKey": from,
179            "destinationKey": to,
180        });
181
182        let resp = self.client.http().post(url).json(&body).send().await?;
183        let result: serde_json::Value = self.client.handle_response(resp).await?;
184        Ok(result
185            .get("Key")
186            .or_else(|| result.get("key"))
187            .and_then(|v| v.as_str())
188            .unwrap_or(to)
189            .to_string())
190    }
191
192    /// Remove files from the bucket.
193    ///
194    /// Mirrors `supabase.storage.from('bucket').remove([paths])`.
195    pub async fn remove(&self, paths: Vec<&str>) -> Result<Vec<FileObject>, StorageError> {
196        let url = self
197            .client
198            .url(&format!("/object/{}", self.bucket_id));
199        let body = json!({
200            "prefixes": paths,
201        });
202
203        let resp = self.client.http().delete(url).json(&body).send().await?;
204        self.client.handle_response(resp).await
205    }
206
207    /// Create a signed URL for time-limited access to a file.
208    ///
209    /// `expires_in` is the number of seconds until the URL expires.
210    pub async fn create_signed_url(
211        &self,
212        path: &str,
213        expires_in: u64,
214    ) -> Result<SignedUrlResponse, StorageError> {
215        let url = self
216            .client
217            .url(&format!("/object/sign/{}/{}", self.bucket_id, path));
218        let body = json!({ "expiresIn": expires_in });
219
220        let resp = self.client.http().post(url).json(&body).send().await?;
221        let mut result: SignedUrlResponse = self.client.handle_response(resp).await?;
222
223        // Prepend base URL if the signed URL is relative
224        if result.signed_url.starts_with('/') {
225            let base = self.client.base_url().as_str().trim_end_matches('/');
226            result.signed_url = format!("{}{}", base, result.signed_url);
227        }
228
229        Ok(result)
230    }
231
232    /// Create signed URLs for multiple files.
233    ///
234    /// `expires_in` is the number of seconds until the URLs expire.
235    pub async fn create_signed_urls(
236        &self,
237        paths: Vec<&str>,
238        expires_in: u64,
239    ) -> Result<Vec<SignedUrlBatchEntry>, StorageError> {
240        let url = self
241            .client
242            .url(&format!("/object/sign/{}", self.bucket_id));
243        let body = json!({
244            "expiresIn": expires_in,
245            "paths": paths,
246        });
247
248        let resp = self.client.http().post(url).json(&body).send().await?;
249        let mut results: Vec<SignedUrlBatchEntry> = self.client.handle_response(resp).await?;
250
251        // Prepend base URL to any relative signed URLs
252        let base = self.client.base_url().as_str().trim_end_matches('/');
253        for entry in &mut results {
254            if let Some(ref mut signed_url) = entry.signed_url {
255                if signed_url.starts_with('/') {
256                    *signed_url = format!("{}{}", base, signed_url);
257                }
258            }
259        }
260
261        Ok(results)
262    }
263
264    /// Get the public URL for a file (no HTTP call, just URL construction).
265    ///
266    /// Only works for files in public buckets.
267    pub fn get_public_url(&self, path: &str) -> String {
268        let base = self.client.base_url().as_str().trim_end_matches('/');
269        format!("{}/object/public/{}/{}", base, self.bucket_id, path)
270    }
271
272    /// Create a signed upload URL for delegated uploads.
273    pub async fn create_signed_upload_url(
274        &self,
275        path: &str,
276    ) -> Result<SignedUploadUrlResponse, StorageError> {
277        let url = self.client.url(&format!(
278            "/object/upload/sign/{}/{}",
279            self.bucket_id, path
280        ));
281
282        let resp = self
283            .client
284            .http()
285            .post(url)
286            .json(&json!({}))
287            .send()
288            .await?;
289        self.client.handle_response(resp).await
290    }
291
292    /// Upload a file using a previously created signed upload URL.
293    pub async fn upload_to_signed_url(
294        &self,
295        token: &str,
296        path: &str,
297        data: Vec<u8>,
298        options: Option<FileOptions>,
299    ) -> Result<(), StorageError> {
300        let url = self.client.url(&format!(
301            "/object/upload/sign/{}/{}?token={}",
302            self.bucket_id, path, token
303        ));
304
305        let opts = options.unwrap_or_default();
306        let content_type = opts
307            .content_type
308            .as_deref()
309            .unwrap_or("application/octet-stream");
310
311        let mut req = self
312            .client
313            .http()
314            .put(url)
315            .header("content-type", content_type)
316            .body(data);
317
318        if let Some(cache) = &opts.cache_control {
319            req = req.header(
320                "cache-control",
321                HeaderValue::from_str(cache)
322                    .map_err(|e| StorageError::InvalidConfig(format!("Invalid header: {}", e)))?,
323            );
324        }
325
326        let resp = req.send().await?;
327        self.client.handle_empty_response(resp).await
328    }
329
330    /// Get file metadata.
331    ///
332    /// Mirrors `supabase.storage.from('bucket').info(path)`.
333    pub async fn info(&self, path: &str) -> Result<FileInfo, StorageError> {
334        let url = self.client.url(&format!(
335            "/object/info/authenticated/{}/{}",
336            self.bucket_id, path
337        ));
338        let resp = self.client.http().get(url).send().await?;
339        self.client.handle_response(resp).await
340    }
341
342    /// Check if a file exists.
343    ///
344    /// Mirrors `supabase.storage.from('bucket').exists(path)`.
345    /// Returns `true` if the file exists, `false` if it does not (404).
346    pub async fn exists(&self, path: &str) -> Result<bool, StorageError> {
347        let url = self
348            .client
349            .url(&format!("/object/{}/{}", self.bucket_id, path));
350        let resp = self.client.http().head(url).send().await?;
351        let status = resp.status().as_u16();
352        if status >= 200 && status < 300 {
353            Ok(true)
354        } else if status == 404 || status == 400 {
355            Ok(false)
356        } else {
357            Err(StorageError::Api {
358                status,
359                message: format!("HTTP {}", status),
360            })
361        }
362    }
363
364    /// Download with server-side image transformation.
365    ///
366    /// Mirrors `supabase.storage.from('bucket').download(path, { transform })`.
367    pub async fn download_with_transform(
368        &self,
369        path: &str,
370        transform: &TransformOptions,
371    ) -> Result<Vec<u8>, StorageError> {
372        let qs = transform.to_query_string();
373        let url_path = if qs.is_empty() {
374            format!(
375                "/render/image/authenticated/{}/{}",
376                self.bucket_id, path
377            )
378        } else {
379            format!(
380                "/render/image/authenticated/{}/{}?{}",
381                self.bucket_id, path, qs
382            )
383        };
384        let url = self.client.url(&url_path);
385        let resp = self.client.http().get(url).send().await?;
386        self.client.handle_bytes_response(resp).await
387    }
388
389    /// Get public URL with image transformation (no HTTP call).
390    ///
391    /// Mirrors `supabase.storage.from('bucket').getPublicUrl(path, { transform })`.
392    pub fn get_public_url_with_transform(
393        &self,
394        path: &str,
395        transform: &TransformOptions,
396    ) -> String {
397        let base = self.client.base_url().as_str().trim_end_matches('/');
398        let qs = transform.to_query_string();
399        if qs.is_empty() {
400            format!(
401                "{}/render/image/public/{}/{}",
402                base, self.bucket_id, path
403            )
404        } else {
405            format!(
406                "{}/render/image/public/{}/{}?{}",
407                base, self.bucket_id, path, qs
408            )
409        }
410    }
411
412    /// Create a signed URL with image transformation.
413    ///
414    /// Mirrors `supabase.storage.from('bucket').createSignedUrl(path, expiresIn, { transform })`.
415    pub async fn create_signed_url_with_transform(
416        &self,
417        path: &str,
418        expires_in: u64,
419        transform: &TransformOptions,
420    ) -> Result<SignedUrlResponse, StorageError> {
421        let url = self
422            .client
423            .url(&format!("/object/sign/{}/{}", self.bucket_id, path));
424        let mut body = json!({ "expiresIn": expires_in });
425        if !transform.is_empty() {
426            body["transform"] = transform.to_json();
427        }
428
429        let resp = self.client.http().post(url).json(&body).send().await?;
430        let mut result: SignedUrlResponse = self.client.handle_response(resp).await?;
431
432        if result.signed_url.starts_with('/') {
433            let base = self.client.base_url().as_str().trim_end_matches('/');
434            result.signed_url = format!("{}{}", base, result.signed_url);
435        }
436
437        Ok(result)
438    }
439
440    /// Create batch signed URLs with image transformation.
441    ///
442    /// Mirrors `supabase.storage.from('bucket').createSignedUrls(paths, expiresIn, { transform })`.
443    pub async fn create_signed_urls_with_transform(
444        &self,
445        paths: Vec<&str>,
446        expires_in: u64,
447        transform: &TransformOptions,
448    ) -> Result<Vec<SignedUrlBatchEntry>, StorageError> {
449        let url = self
450            .client
451            .url(&format!("/object/sign/{}", self.bucket_id));
452        let mut body = json!({
453            "expiresIn": expires_in,
454            "paths": paths,
455        });
456        if !transform.is_empty() {
457            body["transform"] = transform.to_json();
458        }
459
460        let resp = self.client.http().post(url).json(&body).send().await?;
461        let mut results: Vec<SignedUrlBatchEntry> = self.client.handle_response(resp).await?;
462
463        let base = self.client.base_url().as_str().trim_end_matches('/');
464        for entry in &mut results {
465            if let Some(ref mut signed_url) = entry.signed_url {
466                if signed_url.starts_with('/') {
467                    *signed_url = format!("{}{}", base, signed_url);
468                }
469            }
470        }
471
472        Ok(results)
473    }
474
475    /// Move a file to a different bucket.
476    ///
477    /// Mirrors `supabase.storage.from('bucket').move(from, to, { destinationBucket })`.
478    pub async fn move_to_bucket(
479        &self,
480        from: &str,
481        to_bucket: &str,
482        to_path: &str,
483    ) -> Result<(), StorageError> {
484        let url = self.client.url("/object/move");
485        let body = json!({
486            "bucketId": self.bucket_id,
487            "sourceKey": from,
488            "destinationBucket": to_bucket,
489            "destinationKey": to_path,
490        });
491
492        let resp = self.client.http().post(url).json(&body).send().await?;
493        self.client.handle_empty_response(resp).await
494    }
495
496    /// Copy a file to a different bucket.
497    ///
498    /// Returns the key of the new file.
499    ///
500    /// Mirrors `supabase.storage.from('bucket').copy(from, to, { destinationBucket })`.
501    pub async fn copy_to_bucket(
502        &self,
503        from: &str,
504        to_bucket: &str,
505        to_path: &str,
506    ) -> Result<String, StorageError> {
507        let url = self.client.url("/object/copy");
508        let body = json!({
509            "bucketId": self.bucket_id,
510            "sourceKey": from,
511            "destinationBucket": to_bucket,
512            "destinationKey": to_path,
513        });
514
515        let resp = self.client.http().post(url).json(&body).send().await?;
516        let result: serde_json::Value = self.client.handle_response(resp).await?;
517        Ok(result
518            .get("Key")
519            .or_else(|| result.get("key"))
520            .and_then(|v| v.as_str())
521            .unwrap_or(to_path)
522            .to_string())
523    }
524
525    /// Get a public URL with optional download disposition.
526    ///
527    /// When `filename` is `Some("file.txt")`, appends `?download=file.txt` to the URL.
528    /// When `filename` is `Some("")`, appends `?download=` (uses original filename).
529    ///
530    /// Mirrors `supabase.storage.from('bucket').getPublicUrl(path, { download })`.
531    pub fn get_public_url_with_download(&self, path: &str, filename: Option<&str>) -> String {
532        let base_url = self.get_public_url(path);
533        match filename {
534            Some(name) => format!("{}?download={}", base_url, name),
535            None => base_url,
536        }
537    }
538
539    /// Create a signed URL with optional download disposition.
540    ///
541    /// When `filename` is `Some("file.txt")`, appends `&download=file.txt` to the signed URL.
542    /// When `filename` is `Some("")`, appends `&download=` (uses original filename).
543    ///
544    /// Mirrors `supabase.storage.from('bucket').createSignedUrl(path, expiresIn, { download })`.
545    pub async fn create_signed_url_with_download(
546        &self,
547        path: &str,
548        expires_in: u64,
549        filename: Option<&str>,
550    ) -> Result<SignedUrlResponse, StorageError> {
551        let mut result = self.create_signed_url(path, expires_in).await?;
552        if let Some(name) = filename {
553            let separator = if result.signed_url.contains('?') { "&" } else { "?" };
554            result.signed_url = format!("{}{}download={}", result.signed_url, separator, name);
555        }
556        Ok(result)
557    }
558
559    /// Get the bucket ID this API is scoped to.
560    pub fn bucket_id(&self) -> &str {
561        &self.bucket_id
562    }
563}