pixeluvw_supabase 0.1.0

A production-ready, high-performance Supabase SDK for Rust with middleware, retry logic, and Arc<Inner> architecture
Documentation
//! Storage module for Supabase file storage operations.
//!
//! This module provides functionality for managing files in Supabase Storage, including:
//! - Listing buckets
//! - Uploading files
//! - Downloading files
//! - Deleting files
//! - Getting public URLs
//!
//! # Example
//!
//! ```rust,no_run
//! use pixeluvw_supabase::SupabaseClient;
//!
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! dotenv::dotenv().ok();
//! let client = SupabaseClient::from_env()?;
//! let storage = client.storage();
//!
//! // List all buckets
//! let buckets = storage.list_buckets().await?;
//! for bucket in &buckets {
//!     println!("Bucket: {} (public: {})", bucket.id, bucket.public);
//! }
//!
//! // Work with a specific bucket
//! let bucket = storage.from("avatars");
//!
//! // Upload a file
//! let uploaded = bucket.upload("user-123.png", "/path/to/local/file.png", None).await?;
//! println!("Uploaded: {}", uploaded.key);
//!
//! // Get public URL
//! let url = bucket.get_public_url("user-123.png")?;
//! println!("Public URL: {}", url);
//! # Ok(())
//! # }
//! ```

use crate::core::SupabaseClient;
use crate::error::{Result, SupaError};
use reqwest::multipart;
use serde::Deserialize;
use std::path::Path;
// use tokio::fs::File;
// use tokio::io::AsyncReadExt;

/// Storage client for Supabase file operations.
///
/// Access via `client.storage()`.
///
/// # Example
///
/// ```rust,no_run
/// # async fn example(client: pixeluvw_supabase::SupabaseClient) -> Result<(), Box<dyn std::error::Error>> {
/// let storage = client.storage();
///
/// // List buckets
/// let buckets = storage.list_buckets().await?;
///
/// // Get a specific bucket
/// let avatars = storage.from("avatars");
/// # Ok(())
/// # }
/// ```
#[derive(Clone)]
pub struct StorageClient {
    client: SupabaseClient,
}

impl StorageClient {
    pub(crate) fn new(client: SupabaseClient) -> Self {
        Self { client }
    }

    /// Select a bucket to operate on.
    ///
    /// # Arguments
    ///
    /// * `id` - The bucket ID/name
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # async fn example(client: pixeluvw_supabase::SupabaseClient) -> Result<(), Box<dyn std::error::Error>> {
    /// let bucket = client.storage().from("avatars");
    ///
    /// // Now you can upload, download, delete files in this bucket
    /// # Ok(())
    /// # }
    /// ```
    pub fn from(&self, id: &str) -> StorageBucket {
        StorageBucket::new(self.client.clone(), id)
    }

    /// List all buckets in the project.
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # async fn example(client: pixeluvw_supabase::SupabaseClient) -> Result<(), Box<dyn std::error::Error>> {
    /// let buckets = client.storage().list_buckets().await?;
    ///
    /// for bucket in &buckets {
    ///     println!("{}: public={}", bucket.id, bucket.public);
    /// }
    /// # Ok(())
    /// # }
    /// ```
    pub async fn list_buckets(&self) -> Result<Vec<Bucket>> {
        let url = format!("{}/storage/v1/bucket", self.client.inner.url);
        let resp = self
            .client
            .inner
            .http
            .get(&url)
            .header("apikey", &self.client.inner.key)
            .header("Authorization", self.client.auth_header())
            .send()
            .await?;

        if !resp.status().is_success() {
            let status = resp.status();
            let text = resp.text().await.unwrap_or_default();
            return Err(SupaError::ApiError {
                code: status.as_u16(),
                message: format!("List Buckets Error: {}", text),
                details: None,
            });
        }

        let buckets = resp.json().await?;
        Ok(buckets)
    }
}

/// A handle to a specific storage bucket.
///
/// Provides methods to upload, download, delete, and list files.
///
/// # Example
///
/// ```rust,no_run
/// # async fn example(client: pixeluvw_supabase::SupabaseClient) -> Result<(), Box<dyn std::error::Error>> {
/// let bucket = client.storage().from("avatars");
///
/// // Upload
/// bucket.upload("profile.png", "/local/path.png", None).await?;
///
/// // Download
/// let bytes = bucket.download("profile.png").await?;
///
/// // Delete
/// bucket.delete("profile.png").await?;
///
/// // Get public URL
/// let url = bucket.get_public_url("profile.png")?;
/// # Ok(())
/// # }
/// ```
pub struct StorageBucket {
    client: SupabaseClient,
    pub id: String,
}

impl StorageBucket {
    pub(crate) fn new(client: SupabaseClient, id: &str) -> Self {
        Self {
            client,
            id: id.to_string(),
        }
    }

    /// Upload a file to the bucket.
    ///
    /// # Arguments
    /// * `path` - The path inside the bucket (e.g., "folder/image.png").
    /// * `file_path` - The local path to the file to upload.
    /// * `mime_type` - Optional MIME type. If None, it will be guessed.
    pub async fn upload<P: AsRef<Path>>(
        &self,
        path: &str,
        file_path: P,
        mime_type: Option<&str>,
    ) -> Result<UploadedFile> {
        let path_ref = file_path.as_ref();

        let filename = path_ref
            .file_name()
            .and_then(|n| n.to_str())
            .ok_or_else(|| SupaError::ClientError {
                message: format!("Invalid filename for path: {}", path_ref.display()),
            })?
            .to_string();

        let file_bytes = tokio::fs::read(path_ref)
            .await
            .map_err(|e| SupaError::ClientError {
                message: format!("Failed to read file {}: {}", path_ref.display(), e),
            })?;

        let mime = if let Some(m) = mime_type {
            m.to_string()
        } else {
            mime_guess::from_path(path_ref)
                .first_or_octet_stream()
                .to_string()
        };

        let part = reqwest::multipart::Part::bytes(file_bytes)
            .file_name(filename)
            .mime_str(&mime)?;

        let form = multipart::Form::new().part("file", part);

        let url = format!(
            "{}/storage/v1/object/{}/{}",
            self.client.inner.url, self.id, path
        );

        let resp = self
            .client
            .inner
            .http
            .post(&url)
            .header("apikey", &self.client.inner.key)
            .header("Authorization", self.client.auth_header())
            .multipart(form)
            .send()
            .await?;

        let status = resp.status();
        if !status.is_success() {
            let error_text = resp.text().await.unwrap_or_default();
            return Err(SupaError::ApiError {
                code: status.as_u16(),
                message: format!("Upload Error: {}", error_text),
                details: None,
            });
        }

        let uploaded: UploadedFile = resp.json().await?;
        Ok(uploaded)
    }

    /// Download a file from the bucket.
    pub async fn download(&self, path: &str) -> Result<Vec<u8>> {
        let url = format!(
            "{}/storage/v1/object/{}/{}",
            self.client.inner.url, self.id, path
        );

        let resp = self
            .client
            .inner
            .http
            .get(&url)
            .header("apikey", &self.client.inner.key)
            .header("Authorization", self.client.auth_header())
            .send()
            .await?;

        let status = resp.status();
        if !status.is_success() {
            let error_text = resp.text().await.unwrap_or_default();
            return Err(SupaError::ApiError {
                code: status.as_u16(),
                message: format!("Download Error: {}", error_text),
                details: None,
            });
        }

        let bytes = resp.bytes().await?;
        Ok(bytes.to_vec())
    }

    /// Get public URL for a file.
    pub fn get_public_url(&self, path: &str) -> Result<String> {
        let path_url = format!("storage/v1/object/public/{}/{}", self.id, path);
        let url = self.client.inner.url.join(&path_url)?;
        Ok(url.to_string())
    }

    /// Create a signed URL for a file.
    ///
    /// # Arguments
    /// * `path` - The path inside the bucket.
    /// * `expires_in` - Number of seconds until the URL expires.
    /// * `options` - Enable download or transform options (currently unused, pass None).
    pub async fn create_signed_url(&self, path: &str, expires_in: i32) -> Result<String> {
        let url = format!(
            "{}/storage/v1/object/sign/{}/{}",
            self.client.inner.url, self.id, path
        );

        let body = serde_json::json!({
            "expiresIn": expires_in,
        });

        let resp = self
            .client
            .inner
            .http
            .post(&url)
            .header("apikey", &self.client.inner.key)
            .header("Authorization", self.client.auth_header())
            .json(&body)
            .send()
            .await?;

        if !resp.status().is_success() {
            let status = resp.status();
            let text = resp.text().await.unwrap_or_default();
            return Err(SupaError::ApiError {
                code: status.as_u16(),
                message: format!("Create Signed URL Error: {}", text),
                details: None,
            });
        }

        // Response format: { "signedURL": "..." } with relative path usually, need to prepend host if relative
        // Actually Supabase returns absolute-ish path usually but let's parse safely.
        let json: serde_json::Value = resp.json().await?;
        let signed_path = json
            .get("signedURL")
            .and_then(|v| v.as_str())
            .ok_or_else(|| SupaError::ApiError {
                code: 500,
                message: "Missing signedURL in response".to_string(),
                details: None,
            })?;

        // The return is usually something like "/storage/v1/object/sign/..." including the token as query param
        // We need to join it with the base URL.
        let full_url = self.client.inner.url.join(signed_path)?;
        Ok(full_url.to_string())
    }

    /// Delete a file from the bucket.
    pub async fn delete(&self, path: &str) -> Result<()> {
        let url = format!(
            "{}/storage/v1/object/{}/{}",
            self.client.inner.url, self.id, path
        );

        let resp = self
            .client
            .inner
            .http
            .delete(&url)
            .header("apikey", &self.client.inner.key)
            .header("Authorization", self.client.auth_header())
            .send()
            .await?;

        if !resp.status().is_success() {
            let status = resp.status();
            let text = resp.text().await.unwrap_or_default();
            return Err(SupaError::ApiError {
                code: status.as_u16(),
                message: format!("Delete Error: {}", text),
                details: None,
            });
        }

        Ok(())
    }
}

#[derive(Debug, Deserialize)]
pub struct Bucket {
    pub id: String,
    pub name: String,
    pub public: bool,
    pub created_at: String,
    pub updated_at: String,
}

#[derive(Debug, Deserialize)]
pub struct UploadedFile {
    pub id: String,
    pub key: String,
}