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#[derive(Debug, Clone)]
23pub struct StorageClient {
24 http: reqwest::Client,
25 base_url: Url,
26 api_key: String,
27}
28
29impl StorageClient {
30 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 pub fn base_url(&self) -> &Url {
65 &self.base_url
66 }
67
68 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 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 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 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 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 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 pub fn from(&self, bucket: &str) -> StorageBucketApi {
154 StorageBucketApi::new(self.clone(), bucket.to_string())
155 }
156
157 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 #[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 use wiremock::matchers::{method, path};
367 use wiremock::{Mock, MockServer, ResponseTemplate};
368
369 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 assert_eq!(message, "HTTP 409");
500 }
501 other => panic!("Expected Api error, got: {:?}", other),
502 }
503 }
504}