use std::time::Duration;
use async_trait::async_trait;
use fraiseql_error::FileError;
pub mod local;
#[cfg(feature = "aws-s3")]
pub mod s3;
#[cfg(feature = "gcs")]
pub mod gcs;
#[cfg(feature = "azure-blob")]
pub mod azure;
#[cfg(test)]
mod tests;
pub use local::LocalStorageBackend;
#[cfg(feature = "azure-blob")]
pub use self::azure::AzureBlobStorageBackend;
#[cfg(feature = "gcs")]
pub use self::gcs::GcsStorageBackend;
#[cfg(feature = "aws-s3")]
pub use self::s3::S3StorageBackend;
pub type StorageResult<T> = Result<T, FileError>;
#[async_trait]
pub trait StorageBackend: Send + Sync {
async fn upload(&self, key: &str, data: &[u8], content_type: &str) -> StorageResult<String>;
async fn download(&self, key: &str) -> StorageResult<Vec<u8>>;
async fn delete(&self, key: &str) -> StorageResult<()>;
async fn exists(&self, key: &str) -> StorageResult<bool>;
async fn presigned_url(&self, key: &str, expiry: Duration) -> StorageResult<String>;
}
pub fn validate_key(key: &str) -> StorageResult<()> {
if key.is_empty() {
return Err(FileError::Storage {
message: "Storage key must not be empty".to_string(),
source: None,
});
}
if key.contains("..") || key.starts_with('/') || key.starts_with('\\') {
return Err(FileError::Storage {
message: "Invalid storage key: must be a relative path without '..'".to_string(),
source: None,
});
}
Ok(())
}
const S3_COMPAT_BACKENDS: &[&str] = &[
"s3",
"r2",
"hetzner",
"scaleway",
"ovh",
"exoscale",
"backblaze",
];
#[cfg(any(feature = "aws-s3", test))]
fn default_s3_endpoint(backend: &str, region: Option<&str>) -> Option<String> {
match backend {
"r2" => {
None
},
"hetzner" => {
let r = region.unwrap_or("fsn1");
Some(format!("https://{r}.your-objectstorage.com"))
},
"scaleway" => {
let r = region.unwrap_or("fr-par");
Some(format!("https://s3.{r}.scw.cloud"))
},
"ovh" => {
let r = region.unwrap_or("gra");
Some(format!("https://s3.{r}.perf.cloud.ovh.net"))
},
"exoscale" => {
let r = region.unwrap_or("de-fra-1");
Some(format!("https://sos-{r}.exo.io"))
},
"backblaze" => {
let r = region.unwrap_or("us-west-004");
Some(format!("https://s3.{r}.backblazeb2.com"))
},
_ => None,
}
}
pub async fn create_backend(
config: &crate::config::StorageConfig,
) -> StorageResult<Box<dyn StorageBackend>> {
let backend_name = config.backend.as_str();
match backend_name {
"local" => {
let path = config.path.as_deref().ok_or_else(|| FileError::Storage {
message: "Local storage backend requires 'path' configuration".to_string(),
source: None,
})?;
Ok(Box::new(LocalStorageBackend::new(path)))
},
#[cfg(feature = "aws-s3")]
b if S3_COMPAT_BACKENDS.contains(&b) => {
let bucket = config.bucket.as_deref().ok_or_else(|| FileError::Storage {
message: format!("{b} storage backend requires 'bucket' configuration"),
source: None,
})?;
let endpoint = config
.endpoint
.as_deref()
.map(str::to_owned)
.or_else(|| default_s3_endpoint(b, config.region.as_deref()));
let backend =
S3StorageBackend::new(bucket, config.region.as_deref(), endpoint.as_deref()).await;
Ok(Box::new(backend))
},
#[cfg(feature = "gcs")]
"gcs" => {
let bucket = config.bucket.as_deref().ok_or_else(|| FileError::Storage {
message: "GCS storage backend requires 'bucket' configuration".to_string(),
source: None,
})?;
let backend = GcsStorageBackend::new(bucket)?;
Ok(Box::new(backend))
},
#[cfg(feature = "azure-blob")]
"azure" => {
let container = config.bucket.as_deref().ok_or_else(|| FileError::Storage {
message: "Azure Blob storage requires 'bucket' (container) configuration"
.to_string(),
source: None,
})?;
let account = config.account_name.as_deref().ok_or_else(|| FileError::Storage {
message: "Azure Blob storage requires 'account_name' configuration".to_string(),
source: None,
})?;
let backend = AzureBlobStorageBackend::new(account, container)?;
Ok(Box::new(backend))
},
#[cfg(not(feature = "aws-s3"))]
b if S3_COMPAT_BACKENDS.contains(&b) => Err(FileError::Storage {
message: format!("{b} storage backend requires the 'aws-s3' feature"),
source: None,
}),
#[cfg(not(feature = "gcs"))]
"gcs" => Err(FileError::Storage {
message: "GCS storage backend requires the 'gcs' feature".to_string(),
source: None,
}),
#[cfg(not(feature = "azure-blob"))]
"azure" => Err(FileError::Storage {
message: "Azure Blob storage backend requires the 'azure-blob' feature".to_string(),
source: None,
}),
other => Err(FileError::Storage {
message: format!("Unknown storage backend: {other}"),
source: None,
}),
}
}