use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
use serde::de::DeserializeOwned;
use serde_json::json;
use url::Url;
use crate::bucket_api::StorageBucketApi;
use crate::error::{StorageApiErrorResponse, StorageError};
use crate::types::*;
#[derive(Debug, Clone)]
pub struct StorageClient {
http: reqwest::Client,
base_url: Url,
api_key: String,
}
impl StorageClient {
pub fn new(supabase_url: &str, api_key: &str) -> Result<Self, StorageError> {
let base = supabase_url.trim_end_matches('/');
let base_url = Url::parse(&format!("{}/storage/v1", base))?;
let mut default_headers = HeaderMap::new();
default_headers.insert(
"apikey",
HeaderValue::from_str(api_key)
.map_err(|e| StorageError::InvalidConfig(format!("Invalid API key header: {}", e)))?,
);
default_headers.insert(
reqwest::header::AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {}", api_key))
.map_err(|e| StorageError::InvalidConfig(format!("Invalid auth header: {}", e)))?,
);
default_headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
let http = reqwest::Client::builder()
.default_headers(default_headers)
.build()
.map_err(StorageError::Http)?;
Ok(Self {
http,
base_url,
api_key: api_key.to_string(),
})
}
pub fn base_url(&self) -> &Url {
&self.base_url
}
pub async fn list_buckets(&self) -> Result<Vec<Bucket>, StorageError> {
let url = self.url("/bucket");
let resp = self.http.get(url).send().await?;
self.handle_response(resp).await
}
pub async fn get_bucket(&self, id: &str) -> Result<Bucket, StorageError> {
let url = self.url(&format!("/bucket/{}", id));
let resp = self.http.get(url).send().await?;
self.handle_response(resp).await
}
pub async fn create_bucket(
&self,
id: &str,
options: BucketOptions,
) -> Result<CreateBucketResponse, StorageError> {
let url = self.url("/bucket");
let mut body = json!({
"id": id,
"name": id,
});
if let Some(public) = options.public {
body["public"] = json!(public);
}
if let Some(limit) = options.file_size_limit {
body["file_size_limit"] = json!(limit);
}
if let Some(types) = options.allowed_mime_types {
body["allowed_mime_types"] = json!(types);
}
let resp = self.http.post(url).json(&body).send().await?;
self.handle_response(resp).await
}
pub async fn update_bucket(
&self,
id: &str,
options: BucketOptions,
) -> Result<(), StorageError> {
let url = self.url(&format!("/bucket/{}", id));
let mut body = json!({
"id": id,
"name": id,
});
if let Some(public) = options.public {
body["public"] = json!(public);
}
if let Some(limit) = options.file_size_limit {
body["file_size_limit"] = json!(limit);
}
if let Some(types) = options.allowed_mime_types {
body["allowed_mime_types"] = json!(types);
}
let resp = self.http.put(url).json(&body).send().await?;
self.handle_empty_response(resp).await
}
pub async fn empty_bucket(&self, id: &str) -> Result<(), StorageError> {
let url = self.url(&format!("/bucket/{}/empty", id));
let resp = self.http.post(url).json(&json!({})).send().await?;
self.handle_empty_response(resp).await
}
pub async fn delete_bucket(&self, id: &str) -> Result<(), StorageError> {
let url = self.url(&format!("/bucket/{}", id));
let resp = self.http.delete(url).json(&json!({})).send().await?;
self.handle_empty_response(resp).await
}
pub fn from(&self, bucket: &str) -> StorageBucketApi {
StorageBucketApi::new(self.clone(), bucket.to_string())
}
pub(crate) fn url(&self, path: &str) -> Url {
let mut url = self.base_url.clone();
let current = url.path().to_string();
if let Some(query_start) = path.find('?') {
url.set_path(&format!("{}{}", current, &path[..query_start]));
url.set_query(Some(&path[query_start + 1..]));
} else {
url.set_path(&format!("{}{}", current, path));
}
url
}
pub(crate) fn http(&self) -> &reqwest::Client {
&self.http
}
#[allow(dead_code)]
pub(crate) fn api_key(&self) -> &str {
&self.api_key
}
pub(crate) async fn handle_response<T: DeserializeOwned>(
&self,
resp: reqwest::Response,
) -> Result<T, StorageError> {
let status = resp.status().as_u16();
if status >= 400 {
return Err(self.parse_error(status, resp).await);
}
let body: T = resp.json().await?;
Ok(body)
}
pub(crate) async fn handle_empty_response(
&self,
resp: reqwest::Response,
) -> Result<(), StorageError> {
let status = resp.status().as_u16();
if status >= 400 {
return Err(self.parse_error(status, resp).await);
}
Ok(())
}
pub(crate) async fn handle_bytes_response(
&self,
resp: reqwest::Response,
) -> Result<Vec<u8>, StorageError> {
let status = resp.status().as_u16();
if status >= 400 {
return Err(self.parse_error(status, resp).await);
}
let bytes = resp.bytes().await?;
Ok(bytes.to_vec())
}
async fn parse_error(&self, status: u16, resp: reqwest::Response) -> StorageError {
match resp.json::<StorageApiErrorResponse>().await {
Ok(err_resp) => StorageError::Api {
status,
message: err_resp.error_message(),
},
Err(_) => StorageError::Api {
status,
message: format!("HTTP {}", status),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_new_ok() {
let client = StorageClient::new("https://example.supabase.co", "test-key");
assert!(client.is_ok());
}
#[test]
fn client_base_url() {
let client = StorageClient::new("https://example.supabase.co", "test-key").unwrap();
assert_eq!(client.base_url().path(), "/storage/v1");
}
#[test]
fn client_base_url_trailing_slash() {
let client = StorageClient::new("https://example.supabase.co/", "test-key").unwrap();
assert_eq!(client.base_url().path(), "/storage/v1");
}
#[test]
fn url_building() {
let client = StorageClient::new("https://example.supabase.co", "test-key").unwrap();
let url = client.url("/bucket");
assert_eq!(url.path(), "/storage/v1/bucket");
assert!(url.query().is_none());
let url = client.url("/bucket/avatars");
assert_eq!(url.path(), "/storage/v1/bucket/avatars");
}
#[test]
fn url_building_with_query() {
let client = StorageClient::new("https://example.supabase.co", "test-key").unwrap();
let url = client.url("/object/upload/sign/bucket/path?token=abc");
assert_eq!(url.path(), "/storage/v1/object/upload/sign/bucket/path");
assert_eq!(url.query(), Some("token=abc"));
}
#[test]
fn public_url_construction() {
let client = StorageClient::new("https://example.supabase.co", "test-key").unwrap();
let api = client.from("avatars");
let url = api.get_public_url("folder/photo.png");
assert_eq!(
url,
"https://example.supabase.co/storage/v1/object/public/avatars/folder/photo.png"
);
}
#[test]
fn public_url_with_transform_all_options() {
use crate::types::{TransformOptions, ResizeMode, ImageFormat};
let client = StorageClient::new("https://example.supabase.co", "test-key").unwrap();
let api = client.from("photos");
let transform = TransformOptions::new()
.width(200)
.height(150)
.resize(ResizeMode::Cover)
.quality(80)
.format(ImageFormat::Origin);
let url = api.get_public_url_with_transform("photo.jpg", &transform);
assert_eq!(
url,
"https://example.supabase.co/storage/v1/render/image/public/photos/photo.jpg?width=200&height=150&resize=cover&quality=80&format=origin"
);
}
#[test]
fn public_url_with_transform_partial() {
use crate::types::TransformOptions;
let client = StorageClient::new("https://example.supabase.co", "test-key").unwrap();
let api = client.from("photos");
let transform = TransformOptions::new().width(300);
let url = api.get_public_url_with_transform("img.png", &transform);
assert_eq!(
url,
"https://example.supabase.co/storage/v1/render/image/public/photos/img.png?width=300"
);
}
#[test]
fn public_url_with_empty_transform() {
use crate::types::TransformOptions;
let client = StorageClient::new("https://example.supabase.co", "test-key").unwrap();
let api = client.from("photos");
let transform = TransformOptions::default();
let url = api.get_public_url_with_transform("img.png", &transform);
assert_eq!(
url,
"https://example.supabase.co/storage/v1/render/image/public/photos/img.png"
);
}
#[test]
fn public_url_with_download_filename() {
let client = StorageClient::new("https://example.supabase.co", "test-key").unwrap();
let api = client.from("docs");
let url = api.get_public_url_with_download("report.pdf", Some("my-report.pdf"));
assert_eq!(
url,
"https://example.supabase.co/storage/v1/object/public/docs/report.pdf?download=my-report.pdf"
);
}
#[test]
fn public_url_with_download_default() {
let client = StorageClient::new("https://example.supabase.co", "test-key").unwrap();
let api = client.from("docs");
let url = api.get_public_url_with_download("report.pdf", Some(""));
assert_eq!(
url,
"https://example.supabase.co/storage/v1/object/public/docs/report.pdf?download="
);
}
#[test]
fn public_url_with_download_none() {
let client = StorageClient::new("https://example.supabase.co", "test-key").unwrap();
let api = client.from("docs");
let url = api.get_public_url_with_download("report.pdf", None);
assert_eq!(
url,
"https://example.supabase.co/storage/v1/object/public/docs/report.pdf"
);
}
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn mock_client(server: &MockServer) -> StorageClient {
StorageClient::new(&server.uri(), "test-anon-key").unwrap()
}
#[tokio::test]
async fn wiremock_exists_returns_false_on_404() {
let server = MockServer::start().await;
Mock::given(method("HEAD"))
.and(path("/storage/v1/object/avatars/missing.png"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let client = mock_client(&server);
let api = client.from("avatars");
let exists = api.exists("missing.png").await.unwrap();
assert!(!exists);
}
#[tokio::test]
async fn wiremock_exists_returns_false_on_400() {
let server = MockServer::start().await;
Mock::given(method("HEAD"))
.and(path("/storage/v1/object/avatars/bad-path"))
.respond_with(ResponseTemplate::new(400))
.mount(&server)
.await;
let client = mock_client(&server);
let api = client.from("avatars");
let exists = api.exists("bad-path").await.unwrap();
assert!(!exists);
}
#[tokio::test]
async fn wiremock_exists_returns_true_on_200() {
let server = MockServer::start().await;
Mock::given(method("HEAD"))
.and(path("/storage/v1/object/avatars/photo.png"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let client = mock_client(&server);
let api = client.from("avatars");
let exists = api.exists("photo.png").await.unwrap();
assert!(exists);
}
#[tokio::test]
async fn wiremock_exists_returns_error_on_500() {
let server = MockServer::start().await;
Mock::given(method("HEAD"))
.and(path("/storage/v1/object/avatars/error.png"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let client = mock_client(&server);
let api = client.from("avatars");
let err = api.exists("error.png").await.unwrap_err();
match err {
StorageError::Api { status, .. } => assert_eq!(status, 500),
other => panic!("Expected Api error, got: {:?}", other),
}
}
#[tokio::test]
async fn wiremock_list_buckets_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/storage/v1/bucket"))
.respond_with(
ResponseTemplate::new(403)
.set_body_json(serde_json::json!({"message": "Forbidden"})),
)
.mount(&server)
.await;
let client = mock_client(&server);
let err = client.list_buckets().await.unwrap_err();
match err {
StorageError::Api { status, message } => {
assert_eq!(status, 403);
assert_eq!(message, "Forbidden");
}
other => panic!("Expected Api error, got: {:?}", other),
}
}
#[tokio::test]
async fn wiremock_download_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/storage/v1/object/docs/secret.pdf"))
.respond_with(
ResponseTemplate::new(401)
.set_body_json(serde_json::json!({"message": "Unauthorized"})),
)
.mount(&server)
.await;
let client = mock_client(&server);
let api = client.from("docs");
let err = api.download("secret.pdf").await.unwrap_err();
match err {
StorageError::Api { status, message } => {
assert_eq!(status, 401);
assert_eq!(message, "Unauthorized");
}
other => panic!("Expected Api error, got: {:?}", other),
}
}
#[tokio::test]
async fn wiremock_delete_bucket_error_non_json() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/storage/v1/bucket/locked"))
.respond_with(ResponseTemplate::new(409).set_body_string("conflict"))
.mount(&server)
.await;
let client = mock_client(&server);
let err = client.delete_bucket("locked").await.unwrap_err();
match err {
StorageError::Api { status, message } => {
assert_eq!(status, 409);
assert_eq!(message, "HTTP 409");
}
other => panic!("Expected Api error, got: {:?}", other),
}
}
}