Skip to main content

supabase_client_storage/
client.rs

1use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
2use serde::de::DeserializeOwned;
3use serde_json::json;
4use url::Url;
5
6use crate::bucket_api::StorageBucketApi;
7use crate::error::{StorageApiErrorResponse, StorageError};
8use crate::types::*;
9
10/// HTTP client for Supabase Storage API.
11///
12/// Communicates with Storage REST endpoints at `/storage/v1/...`.
13///
14/// # Example
15/// ```ignore
16/// use supabase_client_storage::StorageClient;
17///
18/// let storage = StorageClient::new("https://your-project.supabase.co", "your-anon-key")?;
19/// let buckets = storage.list_buckets().await?;
20/// let file_api = storage.from("avatars");
21/// ```
22#[derive(Debug, Clone)]
23pub struct StorageClient {
24    http: reqwest::Client,
25    base_url: Url,
26    api_key: String,
27}
28
29impl StorageClient {
30    /// Create a new storage client.
31    ///
32    /// `supabase_url` is the project URL (e.g., `https://your-project.supabase.co`).
33    /// `api_key` is the Supabase anon or service_role key.
34    pub fn new(supabase_url: &str, api_key: &str) -> Result<Self, StorageError> {
35        let base = supabase_url.trim_end_matches('/');
36        let base_url = Url::parse(&format!("{}/storage/v1", base))?;
37
38        let mut default_headers = HeaderMap::new();
39        default_headers.insert(
40            "apikey",
41            HeaderValue::from_str(api_key)
42                .map_err(|e| StorageError::InvalidConfig(format!("Invalid API key header: {}", e)))?,
43        );
44        default_headers.insert(
45            reqwest::header::AUTHORIZATION,
46            HeaderValue::from_str(&format!("Bearer {}", api_key))
47                .map_err(|e| StorageError::InvalidConfig(format!("Invalid auth header: {}", e)))?,
48        );
49        default_headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
50
51        let http = reqwest::Client::builder()
52            .default_headers(default_headers)
53            .build()
54            .map_err(StorageError::Http)?;
55
56        Ok(Self {
57            http,
58            base_url,
59            api_key: api_key.to_string(),
60        })
61    }
62
63    /// Get the base URL for the storage API.
64    pub fn base_url(&self) -> &Url {
65        &self.base_url
66    }
67
68    // ─── Bucket Operations ───────────────────────────────────────
69
70    /// List all buckets.
71    pub async fn list_buckets(&self) -> Result<Vec<Bucket>, StorageError> {
72        let url = self.url("/bucket");
73        let resp = self.http.get(url).send().await?;
74        self.handle_response(resp).await
75    }
76
77    /// Get a bucket by ID.
78    pub async fn get_bucket(&self, id: &str) -> Result<Bucket, StorageError> {
79        let url = self.url(&format!("/bucket/{}", id));
80        let resp = self.http.get(url).send().await?;
81        self.handle_response(resp).await
82    }
83
84    /// Create a new bucket.
85    pub async fn create_bucket(
86        &self,
87        id: &str,
88        options: BucketOptions,
89    ) -> Result<CreateBucketResponse, StorageError> {
90        let url = self.url("/bucket");
91        let mut body = json!({
92            "id": id,
93            "name": id,
94        });
95        if let Some(public) = options.public {
96            body["public"] = json!(public);
97        }
98        if let Some(limit) = options.file_size_limit {
99            body["file_size_limit"] = json!(limit);
100        }
101        if let Some(types) = options.allowed_mime_types {
102            body["allowed_mime_types"] = json!(types);
103        }
104
105        let resp = self.http.post(url).json(&body).send().await?;
106        self.handle_response(resp).await
107    }
108
109    /// Update a bucket.
110    pub async fn update_bucket(
111        &self,
112        id: &str,
113        options: BucketOptions,
114    ) -> Result<(), StorageError> {
115        let url = self.url(&format!("/bucket/{}", id));
116        let mut body = json!({
117            "id": id,
118            "name": id,
119        });
120        if let Some(public) = options.public {
121            body["public"] = json!(public);
122        }
123        if let Some(limit) = options.file_size_limit {
124            body["file_size_limit"] = json!(limit);
125        }
126        if let Some(types) = options.allowed_mime_types {
127            body["allowed_mime_types"] = json!(types);
128        }
129
130        let resp = self.http.put(url).json(&body).send().await?;
131        self.handle_empty_response(resp).await
132    }
133
134    /// Empty a bucket (remove all files).
135    pub async fn empty_bucket(&self, id: &str) -> Result<(), StorageError> {
136        let url = self.url(&format!("/bucket/{}/empty", id));
137        let resp = self.http.post(url).json(&json!({})).send().await?;
138        self.handle_empty_response(resp).await
139    }
140
141    /// Delete a bucket. The bucket must be empty first.
142    pub async fn delete_bucket(&self, id: &str) -> Result<(), StorageError> {
143        let url = self.url(&format!("/bucket/{}", id));
144        let resp = self.http.delete(url).json(&json!({})).send().await?;
145        self.handle_empty_response(resp).await
146    }
147
148    // ─── File API Factory ────────────────────────────────────────
149
150    /// Create a file operations API scoped to a bucket.
151    ///
152    /// Mirrors `supabase.storage.from('bucket')`.
153    pub fn from(&self, bucket: &str) -> StorageBucketApi {
154        StorageBucketApi::new(self.clone(), bucket.to_string())
155    }
156
157    // ─── Internal Helpers ────────────────────────────────────────
158
159    pub(crate) fn url(&self, path: &str) -> Url {
160        let mut url = self.base_url.clone();
161        let current = url.path().to_string();
162        if let Some(query_start) = path.find('?') {
163            url.set_path(&format!("{}{}", current, &path[..query_start]));
164            url.set_query(Some(&path[query_start + 1..]));
165        } else {
166            url.set_path(&format!("{}{}", current, path));
167        }
168        url
169    }
170
171    pub(crate) fn http(&self) -> &reqwest::Client {
172        &self.http
173    }
174
175    #[allow(dead_code)]
176    pub(crate) fn api_key(&self) -> &str {
177        &self.api_key
178    }
179
180    pub(crate) async fn handle_response<T: DeserializeOwned>(
181        &self,
182        resp: reqwest::Response,
183    ) -> Result<T, StorageError> {
184        let status = resp.status().as_u16();
185        if status >= 400 {
186            return Err(self.parse_error(status, resp).await);
187        }
188        let body: T = resp.json().await?;
189        Ok(body)
190    }
191
192    pub(crate) async fn handle_empty_response(
193        &self,
194        resp: reqwest::Response,
195    ) -> Result<(), StorageError> {
196        let status = resp.status().as_u16();
197        if status >= 400 {
198            return Err(self.parse_error(status, resp).await);
199        }
200        Ok(())
201    }
202
203    pub(crate) async fn handle_bytes_response(
204        &self,
205        resp: reqwest::Response,
206    ) -> Result<Vec<u8>, StorageError> {
207        let status = resp.status().as_u16();
208        if status >= 400 {
209            return Err(self.parse_error(status, resp).await);
210        }
211        let bytes = resp.bytes().await?;
212        Ok(bytes.to_vec())
213    }
214
215    async fn parse_error(&self, status: u16, resp: reqwest::Response) -> StorageError {
216        match resp.json::<StorageApiErrorResponse>().await {
217            Ok(err_resp) => StorageError::Api {
218                status,
219                message: err_resp.error_message(),
220            },
221            Err(_) => StorageError::Api {
222                status,
223                message: format!("HTTP {}", status),
224            },
225        }
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn client_new_ok() {
235        let client = StorageClient::new("https://example.supabase.co", "test-key");
236        assert!(client.is_ok());
237    }
238
239    #[test]
240    fn client_base_url() {
241        let client = StorageClient::new("https://example.supabase.co", "test-key").unwrap();
242        assert_eq!(client.base_url().path(), "/storage/v1");
243    }
244
245    #[test]
246    fn client_base_url_trailing_slash() {
247        let client = StorageClient::new("https://example.supabase.co/", "test-key").unwrap();
248        assert_eq!(client.base_url().path(), "/storage/v1");
249    }
250
251    #[test]
252    fn url_building() {
253        let client = StorageClient::new("https://example.supabase.co", "test-key").unwrap();
254
255        let url = client.url("/bucket");
256        assert_eq!(url.path(), "/storage/v1/bucket");
257        assert!(url.query().is_none());
258
259        let url = client.url("/bucket/avatars");
260        assert_eq!(url.path(), "/storage/v1/bucket/avatars");
261    }
262
263    #[test]
264    fn url_building_with_query() {
265        let client = StorageClient::new("https://example.supabase.co", "test-key").unwrap();
266        let url = client.url("/object/upload/sign/bucket/path?token=abc");
267        assert_eq!(url.path(), "/storage/v1/object/upload/sign/bucket/path");
268        assert_eq!(url.query(), Some("token=abc"));
269    }
270
271    #[test]
272    fn public_url_construction() {
273        let client = StorageClient::new("https://example.supabase.co", "test-key").unwrap();
274        let api = client.from("avatars");
275        let url = api.get_public_url("folder/photo.png");
276        assert_eq!(
277            url,
278            "https://example.supabase.co/storage/v1/object/public/avatars/folder/photo.png"
279        );
280    }
281
282    #[test]
283    fn public_url_with_transform_all_options() {
284        use crate::types::{TransformOptions, ResizeMode, ImageFormat};
285
286        let client = StorageClient::new("https://example.supabase.co", "test-key").unwrap();
287        let api = client.from("photos");
288        let transform = TransformOptions::new()
289            .width(200)
290            .height(150)
291            .resize(ResizeMode::Cover)
292            .quality(80)
293            .format(ImageFormat::Origin);
294        let url = api.get_public_url_with_transform("photo.jpg", &transform);
295        assert_eq!(
296            url,
297            "https://example.supabase.co/storage/v1/render/image/public/photos/photo.jpg?width=200&height=150&resize=cover&quality=80&format=origin"
298        );
299    }
300
301    #[test]
302    fn public_url_with_transform_partial() {
303        use crate::types::TransformOptions;
304
305        let client = StorageClient::new("https://example.supabase.co", "test-key").unwrap();
306        let api = client.from("photos");
307        let transform = TransformOptions::new().width(300);
308        let url = api.get_public_url_with_transform("img.png", &transform);
309        assert_eq!(
310            url,
311            "https://example.supabase.co/storage/v1/render/image/public/photos/img.png?width=300"
312        );
313    }
314
315    #[test]
316    fn public_url_with_empty_transform() {
317        use crate::types::TransformOptions;
318
319        let client = StorageClient::new("https://example.supabase.co", "test-key").unwrap();
320        let api = client.from("photos");
321        let transform = TransformOptions::default();
322        let url = api.get_public_url_with_transform("img.png", &transform);
323        assert_eq!(
324            url,
325            "https://example.supabase.co/storage/v1/render/image/public/photos/img.png"
326        );
327    }
328
329    // ─── Phase 10: Download URL Tests ────────────────────────
330
331    #[test]
332    fn public_url_with_download_filename() {
333        let client = StorageClient::new("https://example.supabase.co", "test-key").unwrap();
334        let api = client.from("docs");
335        let url = api.get_public_url_with_download("report.pdf", Some("my-report.pdf"));
336        assert_eq!(
337            url,
338            "https://example.supabase.co/storage/v1/object/public/docs/report.pdf?download=my-report.pdf"
339        );
340    }
341
342    #[test]
343    fn public_url_with_download_default() {
344        let client = StorageClient::new("https://example.supabase.co", "test-key").unwrap();
345        let api = client.from("docs");
346        let url = api.get_public_url_with_download("report.pdf", Some(""));
347        assert_eq!(
348            url,
349            "https://example.supabase.co/storage/v1/object/public/docs/report.pdf?download="
350        );
351    }
352
353    #[test]
354    fn public_url_with_download_none() {
355        let client = StorageClient::new("https://example.supabase.co", "test-key").unwrap();
356        let api = client.from("docs");
357        let url = api.get_public_url_with_download("report.pdf", None);
358        assert_eq!(
359            url,
360            "https://example.supabase.co/storage/v1/object/public/docs/report.pdf"
361        );
362    }
363
364    // ─── Wiremock Tests ──────────────────────────────────────
365
366    use wiremock::matchers::{method, path};
367    use wiremock::{Mock, MockServer, ResponseTemplate};
368
369    /// Helper: create a StorageClient pointing at the given mock server.
370    fn mock_client(server: &MockServer) -> StorageClient {
371        StorageClient::new(&server.uri(), "test-anon-key").unwrap()
372    }
373
374    #[tokio::test]
375    async fn wiremock_exists_returns_false_on_404() {
376        let server = MockServer::start().await;
377        Mock::given(method("HEAD"))
378            .and(path("/storage/v1/object/avatars/missing.png"))
379            .respond_with(ResponseTemplate::new(404))
380            .mount(&server)
381            .await;
382
383        let client = mock_client(&server);
384        let api = client.from("avatars");
385        let exists = api.exists("missing.png").await.unwrap();
386        assert!(!exists);
387    }
388
389    #[tokio::test]
390    async fn wiremock_exists_returns_false_on_400() {
391        let server = MockServer::start().await;
392        Mock::given(method("HEAD"))
393            .and(path("/storage/v1/object/avatars/bad-path"))
394            .respond_with(ResponseTemplate::new(400))
395            .mount(&server)
396            .await;
397
398        let client = mock_client(&server);
399        let api = client.from("avatars");
400        let exists = api.exists("bad-path").await.unwrap();
401        assert!(!exists);
402    }
403
404    #[tokio::test]
405    async fn wiremock_exists_returns_true_on_200() {
406        let server = MockServer::start().await;
407        Mock::given(method("HEAD"))
408            .and(path("/storage/v1/object/avatars/photo.png"))
409            .respond_with(ResponseTemplate::new(200))
410            .mount(&server)
411            .await;
412
413        let client = mock_client(&server);
414        let api = client.from("avatars");
415        let exists = api.exists("photo.png").await.unwrap();
416        assert!(exists);
417    }
418
419    #[tokio::test]
420    async fn wiremock_exists_returns_error_on_500() {
421        let server = MockServer::start().await;
422        Mock::given(method("HEAD"))
423            .and(path("/storage/v1/object/avatars/error.png"))
424            .respond_with(ResponseTemplate::new(500))
425            .mount(&server)
426            .await;
427
428        let client = mock_client(&server);
429        let api = client.from("avatars");
430        let err = api.exists("error.png").await.unwrap_err();
431        match err {
432            StorageError::Api { status, .. } => assert_eq!(status, 500),
433            other => panic!("Expected Api error, got: {:?}", other),
434        }
435    }
436
437    #[tokio::test]
438    async fn wiremock_list_buckets_error() {
439        let server = MockServer::start().await;
440        Mock::given(method("GET"))
441            .and(path("/storage/v1/bucket"))
442            .respond_with(
443                ResponseTemplate::new(403)
444                    .set_body_json(serde_json::json!({"message": "Forbidden"})),
445            )
446            .mount(&server)
447            .await;
448
449        let client = mock_client(&server);
450        let err = client.list_buckets().await.unwrap_err();
451        match err {
452            StorageError::Api { status, message } => {
453                assert_eq!(status, 403);
454                assert_eq!(message, "Forbidden");
455            }
456            other => panic!("Expected Api error, got: {:?}", other),
457        }
458    }
459
460    #[tokio::test]
461    async fn wiremock_download_error() {
462        let server = MockServer::start().await;
463        Mock::given(method("GET"))
464            .and(path("/storage/v1/object/docs/secret.pdf"))
465            .respond_with(
466                ResponseTemplate::new(401)
467                    .set_body_json(serde_json::json!({"message": "Unauthorized"})),
468            )
469            .mount(&server)
470            .await;
471
472        let client = mock_client(&server);
473        let api = client.from("docs");
474        let err = api.download("secret.pdf").await.unwrap_err();
475        match err {
476            StorageError::Api { status, message } => {
477                assert_eq!(status, 401);
478                assert_eq!(message, "Unauthorized");
479            }
480            other => panic!("Expected Api error, got: {:?}", other),
481        }
482    }
483
484    #[tokio::test]
485    async fn wiremock_delete_bucket_error_non_json() {
486        let server = MockServer::start().await;
487        Mock::given(method("DELETE"))
488            .and(path("/storage/v1/bucket/locked"))
489            .respond_with(ResponseTemplate::new(409).set_body_string("conflict"))
490            .mount(&server)
491            .await;
492
493        let client = mock_client(&server);
494        let err = client.delete_bucket("locked").await.unwrap_err();
495        match err {
496            StorageError::Api { status, message } => {
497                assert_eq!(status, 409);
498                // Falls back to "HTTP 409" when JSON parsing fails
499                assert_eq!(message, "HTTP 409");
500            }
501            other => panic!("Expected Api error, got: {:?}", other),
502        }
503    }
504}