wowsql 1.1.0

Official Rust SDK for WOWSQL - MySQL Backend-as-a-Service with S3 Storage
Documentation
use crate::errors::WOWSQLError;
use crate::models::{FileUploadResult, StorageFile, StorageQuota};
use reqwest::Client;
use serde_json::Value;
use std::time::Duration;

/// S3 Storage client for WOWSQL
pub struct WOWSQLStorage {
    base_url: String,
    api_key: String,
    client: Client,
    auto_check_quota: bool,
    project_slug: String,
}

impl WOWSQLStorage {
    /// Create a new storage client
    pub fn new(project_url: &str, api_key: &str) -> Result<Self, WOWSQLError> {
        let mut url = project_url.trim().to_string();
        if url.ends_with('/') {
            url.pop();
        }

        // Extract project slug from URL
        let project_slug =
            if let Some(host) = url.split("://").nth(1).and_then(|s| s.split('/').next()) {
                if host.contains('.') {
                    host.split('.').next().unwrap_or(host).to_string()
                } else {
                    host.to_string()
                }
            } else {
                url.clone()
            };

        let client = Client::builder()
            .timeout(Duration::from_secs(60))
            .build()
            .map_err(|e| WOWSQLError::Network(format!("Failed to create HTTP client: {}", e)))?;

        Ok(Self {
            base_url: "https://api.wowsql.com".to_string(),
            api_key: api_key.to_string(),
            client,
            auto_check_quota: true,
            project_slug,
        })
    }

    /// Get storage quota information
    pub async fn get_quota(&self, _force_refresh: bool) -> Result<StorageQuota, WOWSQLError> {
        let url = format!(
            "{}/api/v1/storage/s3/projects/{}/quota",
            self.base_url, self.project_slug
        );
        self.execute_request(&url, "GET", None).await
    }

    /// Check if upload is allowed based on quota
    pub async fn check_upload_allowed(
        &self,
        file_size_bytes: i64,
    ) -> Result<(bool, String), WOWSQLError> {
        let quota = self.get_quota(true).await?;
        let file_size_gb = file_size_bytes as f64 / (1024.0 * 1024.0 * 1024.0);

        if file_size_gb > quota.storage_available_gb {
            return Ok((false, format!(
                "Storage limit exceeded! File size: {:.4} GB, Available: {:.4} GB. Upgrade your plan to get more storage.",
                file_size_gb, quota.storage_available_gb
            )));
        }

        Ok((
            true,
            format!(
                "Upload allowed. {:.4} GB available.",
                quota.storage_available_gb
            ),
        ))
    }

    /// Upload file from path
    pub async fn upload_from_path(
        &self,
        file_path: &str,
        file_key: Option<&str>,
        folder: Option<&str>,
        content_type: Option<&str>,
        check_quota: Option<bool>,
    ) -> Result<FileUploadResult, WOWSQLError> {
        use std::fs;
        let bytes = fs::read(file_path)
            .map_err(|e| WOWSQLError::Storage(format!("Failed to read file: {}", e)))?;

        let key = file_key.unwrap_or_else(|| {
            std::path::Path::new(file_path)
                .file_name()
                .and_then(|n| n.to_str())
                .unwrap_or("file")
        });

        self.upload_bytes(&bytes, key, folder, content_type, check_quota)
            .await
    }

    /// Upload bytes to storage
    pub async fn upload_bytes(
        &self,
        bytes: &[u8],
        key: &str,
        folder: Option<&str>,
        content_type: Option<&str>,
        check_quota: Option<bool>,
    ) -> Result<FileUploadResult, WOWSQLError> {
        // Check quota if enabled
        let should_check = check_quota.unwrap_or(self.auto_check_quota);
        if should_check {
            let (allowed, message) = self.check_upload_allowed(bytes.len() as i64).await?;
            if !allowed {
                let quota = self.get_quota(false).await?;
                return Err(WOWSQLError::StorageLimitExceeded {
                    message,
                    required_bytes: bytes.len() as i64,
                    available_bytes: quota.storage_available_bytes(),
                });
            }
        }

        let mut url = format!(
            "{}/api/v1/storage/s3/projects/{}/upload",
            self.base_url, self.project_slug
        );
        if let Some(folder) = folder {
            use urlencoding::encode;
            url = format!("{}?folder={}", url, encode(folder));
        }
        let file_name = key.split('/').next_back().unwrap_or("file").to_string();
        let mut form = reqwest::multipart::Form::new()
            .text("key", key.to_string())
            .part(
                "file",
                reqwest::multipart::Part::bytes(bytes.to_vec()).file_name(file_name),
            );

        if let Some(ct) = content_type {
            form = form.text("content_type", ct.to_string());
        }

        let response = self
            .client
            .post(&url)
            .header("Authorization", format!("Bearer {}", self.api_key))
            .multipart(form)
            .send()
            .await
            .map_err(|e| WOWSQLError::Network(format!("Upload failed: {}", e)))?;

        let status = response.status();
        let text = response
            .text()
            .await
            .map_err(|e| WOWSQLError::Network(format!("Failed to read response: {}", e)))?;

        if !status.is_success() {
            return Err(self.handle_error(status.as_u16(), &text));
        }

        serde_json::from_str(&text)
            .map_err(|e| WOWSQLError::Storage(format!("Failed to parse upload response: {}", e)))
    }

    /// List files with optional prefix
    pub async fn list_files(
        &self,
        prefix: Option<&str>,
        max_keys: Option<usize>,
    ) -> Result<Vec<StorageFile>, WOWSQLError> {
        use urlencoding::encode;
        let max_keys = max_keys.unwrap_or(1000);
        let mut url = format!(
            "{}/api/v1/storage/s3/projects/{}/files?max_keys={}",
            self.base_url, self.project_slug, max_keys
        );
        if let Some(prefix) = prefix {
            url = format!("{}&prefix={}", url, encode(prefix));
        }

        let response: Value = self.execute_request(&url, "GET", None).await?;

        if let Some(files_array) = response.get("files").and_then(|v| v.as_array()) {
            let mut files = Vec::new();
            for file_value in files_array {
                if let Ok(file) = serde_json::from_value(file_value.clone()) {
                    files.push(file);
                }
            }
            return Ok(files);
        }

        Ok(vec![])
    }

    /// Get file URL (presigned URL)
    pub async fn get_file_url(
        &self,
        key: &str,
        expires_in: Option<u64>,
    ) -> Result<Value, WOWSQLError> {
        use urlencoding::encode;
        let expires_in = expires_in.unwrap_or(3600);
        let url = format!(
            "{}/api/v1/storage/s3/projects/{}/files/{}/url?expires_in={}",
            self.base_url,
            self.project_slug,
            encode(key),
            expires_in
        );
        self.execute_request(&url, "GET", None).await
    }

    /// Get presigned URL for file operations
    pub async fn get_presigned_url(
        &self,
        key: &str,
        expires_in: Option<u64>,
        operation: Option<&str>,
    ) -> Result<String, WOWSQLError> {
        let url = format!(
            "{}/api/v1/storage/s3/projects/{}/presigned-url",
            self.base_url, self.project_slug
        );
        let body = serde_json::json!({
            "file_key": key,
            "expires_in": expires_in.unwrap_or(3600),
            "operation": operation.unwrap_or("get_object")
        });

        let response: Value = self.execute_request(&url, "POST", Some(body)).await?;

        response
            .get("url")
            .and_then(|v| v.as_str())
            .map(|s| s.to_string())
            .ok_or_else(|| WOWSQLError::Storage("Invalid presigned URL response".to_string()))
    }

    /// Download file (alias for get_presigned_url)
    pub async fn download(
        &self,
        key: &str,
        expires_in: Option<u64>,
    ) -> Result<String, WOWSQLError> {
        self.get_presigned_url(key, expires_in, Some("get_object"))
            .await
    }

    /// Get storage information
    pub async fn get_storage_info(&self) -> Result<Value, WOWSQLError> {
        let url = format!(
            "{}/api/v1/storage/s3/projects/{}/info",
            self.base_url, self.project_slug
        );
        self.execute_request(&url, "GET", None).await
    }

    /// Provision S3 storage for the project
    pub async fn provision_storage(&self, region: Option<&str>) -> Result<Value, WOWSQLError> {
        let url = format!(
            "{}/api/v1/storage/s3/projects/{}/provision",
            self.base_url, self.project_slug
        );
        let body = serde_json::json!({ "region": region.unwrap_or("us-east-1") });
        self.execute_request(&url, "POST", Some(body)).await
    }

    /// Get available S3 regions
    pub async fn get_available_regions(&self) -> Result<Vec<Value>, WOWSQLError> {
        let url = format!("{}/api/v1/storage/s3/regions", self.base_url);
        let response: Value = self.execute_request(&url, "GET", None).await?;

        if let Some(regions_array) = response.get("regions").and_then(|v| v.as_array()) {
            return Ok(regions_array.clone());
        }

        Ok(vec![])
    }

    /// Delete a file
    pub async fn delete_file(&self, key: &str) -> Result<(), WOWSQLError> {
        use urlencoding::encode;
        let url = format!(
            "{}/api/v1/storage/s3/projects/{}/files/{}",
            self.base_url,
            self.project_slug,
            encode(key)
        );
        self.execute_request::<Value>(&url, "DELETE", None).await?;
        Ok(())
    }

    /// Execute a request
    async fn execute_request<T: serde::de::DeserializeOwned>(
        &self,
        url: &str,
        method: &str,
        body: Option<Value>,
    ) -> Result<T, WOWSQLError> {
        let mut request = self
            .client
            .request(
                method
                    .parse()
                    .map_err(|_| WOWSQLError::General("Invalid method".to_string()))?,
                url,
            )
            .header("Content-Type", "application/json")
            .header("Accept", "application/json")
            .header("Authorization", format!("Bearer {}", self.api_key));

        if let Some(body) = body {
            request = request.json(&body);
        }

        let response = request
            .send()
            .await
            .map_err(|e| WOWSQLError::Network(format!("Request failed: {}", e)))?;

        let status = response.status();
        let text = response
            .text()
            .await
            .map_err(|e| WOWSQLError::Network(format!("Failed to read response: {}", e)))?;

        if !status.is_success() {
            return Err(self.handle_error(status.as_u16(), &text));
        }

        serde_json::from_str(&text)
            .map_err(|e| WOWSQLError::General(format!("Failed to parse response: {}", e)))
    }

    fn handle_error(&self, status_code: u16, body: &str) -> WOWSQLError {
        let error_response: Value = serde_json::from_str(body).unwrap_or(Value::Null);

        let message = error_response
            .get("error")
            .and_then(|v| v.as_str())
            .or_else(|| error_response.get("message").and_then(|v| v.as_str()))
            .or_else(|| error_response.get("detail").and_then(|v| v.as_str()))
            .unwrap_or(&format!("Request failed with status {}", status_code))
            .to_string();

        match status_code {
            401 | 403 => WOWSQLError::Authentication(message),
            404 => WOWSQLError::NotFound(message),
            413 => WOWSQLError::StorageLimitExceeded {
                message,
                required_bytes: 0,
                available_bytes: 0,
            },
            429 => WOWSQLError::RateLimit(message),
            _ => WOWSQLError::Storage(message),
        }
    }
}