use adminx_attachments::BlobStore;
use adminx_core::storage::StorageError;
use async_trait::async_trait;
use object_store::{path::Path as ObjPath, ObjectStore, ObjectStoreExt, PutPayload};
use std::sync::Arc;
pub struct CloudBlobStore {
inner: Arc<dyn ObjectStore>,
prefix: Option<String>,
}
impl CloudBlobStore {
pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
let p = prefix.into();
self.prefix = if p.is_empty() { None } else { Some(p) };
self
}
fn path(&self, key: &str) -> Result<ObjPath, StorageError> {
let full = match &self.prefix {
Some(p) => format!("{}/{}", p.trim_end_matches('/'), key),
None => key.to_string(),
};
ObjPath::parse(&full)
.map_err(|e| StorageError::Backend(format!("invalid object key {full:?}: {e}")))
}
}
pub fn from_object_store(store: Arc<dyn ObjectStore>) -> Box<CloudBlobStore> {
Box::new(CloudBlobStore {
inner: store,
prefix: None,
})
}
#[async_trait]
impl BlobStore for CloudBlobStore {
async fn put(&self, key: &str, bytes: &[u8]) -> Result<(), StorageError> {
let path = self.path(key)?;
self.inner
.put(&path, PutPayload::from(bytes.to_vec()))
.await
.map(|_| ())
.map_err(map_err)
}
async fn get(&self, key: &str) -> Result<Vec<u8>, StorageError> {
let path = self.path(key)?;
let result = self.inner.get(&path).await.map_err(map_err)?;
let bytes = result.bytes().await.map_err(map_err)?;
Ok(bytes.to_vec())
}
async fn delete(&self, key: &str) -> Result<(), StorageError> {
let path = self.path(key)?;
match self.inner.delete(&path).await {
Ok(()) => Ok(()),
Err(object_store::Error::NotFound { .. }) => Ok(()),
Err(e) => Err(map_err(e)),
}
}
}
fn map_err(e: object_store::Error) -> StorageError {
match e {
object_store::Error::NotFound { .. } => StorageError::NotFound,
other => StorageError::Backend(format!("object store: {other}")),
}
}
#[cfg(feature = "aws")]
pub fn s3_from_env(bucket: impl Into<String>) -> Result<Box<CloudBlobStore>, StorageError> {
let bucket = bucket.into();
let store = object_store::aws::AmazonS3Builder::from_env()
.with_bucket_name(&bucket)
.build()
.map_err(|e| StorageError::Backend(format!("S3 init ({bucket}): {e}")))?;
tracing::info!("adminx-cloud: attachments -> S3 bucket `{bucket}`");
Ok(from_object_store(Arc::new(store)))
}
#[cfg(feature = "gcp")]
pub fn gcs_from_env(bucket: impl Into<String>) -> Result<Box<CloudBlobStore>, StorageError> {
let bucket = bucket.into();
let store = object_store::gcp::GoogleCloudStorageBuilder::from_env()
.with_bucket_name(&bucket)
.build()
.map_err(|e| StorageError::Backend(format!("GCS init ({bucket}): {e}")))?;
tracing::info!("adminx-cloud: attachments -> GCS bucket `{bucket}`");
Ok(from_object_store(Arc::new(store)))
}
#[cfg(feature = "azure")]
pub fn azure_from_env(container: impl Into<String>) -> Result<Box<CloudBlobStore>, StorageError> {
let container = container.into();
let store = object_store::azure::MicrosoftAzureBuilder::from_env()
.with_container_name(&container)
.build()
.map_err(|e| StorageError::Backend(format!("Azure init ({container}): {e}")))?;
tracing::info!("adminx-cloud: attachments -> Azure container `{container}`");
Ok(from_object_store(Arc::new(store)))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn round_trips_and_honours_prefix_over_the_in_memory_store() {
let mem = Arc::new(object_store::memory::InMemory::new());
let store = from_object_store(mem.clone()).with_prefix("env1/attachments");
store.put("posts/1/cover/abc", b"hello").await.unwrap();
assert_eq!(store.get("posts/1/cover/abc").await.unwrap(), b"hello");
let raw = mem
.get(&ObjPath::parse("env1/attachments/posts/1/cover/abc").unwrap())
.await
.unwrap()
.bytes()
.await
.unwrap();
assert_eq!(&raw[..], b"hello");
assert!(matches!(
store.get("posts/1/cover/missing").await,
Err(StorageError::NotFound)
));
store.delete("posts/1/cover/abc").await.unwrap();
assert!(matches!(
store.get("posts/1/cover/abc").await,
Err(StorageError::NotFound)
));
store.delete("posts/1/cover/abc").await.unwrap(); }
}