Skip to main content

shaperail_runtime/storage/
object_store_backend.rs

1use super::backend::{FileMetadata, StorageError};
2use object_store::path::Path as ObjectPath;
3use object_store::signer::Signer;
4use object_store::{ObjectStore, ObjectStoreExt, PutPayload};
5use std::time::Duration;
6
7/// Helper: upload via any ObjectStore impl.
8async fn do_upload(
9    store: &dyn ObjectStore,
10    path: &str,
11    data: &[u8],
12    mime_type: &str,
13) -> Result<FileMetadata, StorageError> {
14    let obj_path = ObjectPath::from(path);
15    let payload = PutPayload::from(data.to_vec());
16    store
17        .put(&obj_path, payload)
18        .await
19        .map_err(|e| StorageError::Backend(format!("Upload failed: {e}")))?;
20
21    let filename = path.rsplit('/').next().unwrap_or(path).to_string();
22
23    Ok(FileMetadata {
24        path: path.to_string(),
25        filename,
26        mime_type: mime_type.to_string(),
27        size: data.len() as u64,
28    })
29}
30
31/// Helper: download via any ObjectStore impl.
32async fn do_download(store: &dyn ObjectStore, path: &str) -> Result<Vec<u8>, StorageError> {
33    let obj_path = ObjectPath::from(path);
34    let result = store.get(&obj_path).await.map_err(|e| {
35        if e.to_string().contains("not found") || e.to_string().contains("404") {
36            StorageError::NotFound(path.to_string())
37        } else {
38            StorageError::Backend(format!("Download failed: {e}"))
39        }
40    })?;
41    result
42        .bytes()
43        .await
44        .map(|b| b.to_vec())
45        .map_err(|e| StorageError::Backend(format!("Failed to read bytes: {e}")))
46}
47
48/// Helper: delete via any ObjectStore impl.
49async fn do_delete(store: &dyn ObjectStore, path: &str) -> Result<(), StorageError> {
50    let obj_path = ObjectPath::from(path);
51    store.delete(&obj_path).await.map_err(|e| {
52        if e.to_string().contains("not found") || e.to_string().contains("404") {
53            StorageError::NotFound(path.to_string())
54        } else {
55            StorageError::Backend(format!("Delete failed: {e}"))
56        }
57    })
58}
59
60/// Helper: signed URL via any Signer impl, with fallback to base_url.
61async fn do_signed_url(
62    signer: &dyn Signer,
63    path: &str,
64    expires_secs: u64,
65    base_url: &str,
66) -> Result<String, StorageError> {
67    let obj_path = ObjectPath::from(path);
68    let duration = Duration::from_secs(expires_secs);
69    match signer
70        .signed_url(http::Method::GET, &obj_path, duration)
71        .await
72    {
73        Ok(url) => Ok(url.to_string()),
74        Err(_) => Ok(format!("{}/{}", base_url.trim_end_matches('/'), path)),
75    }
76}
77
78/// Amazon S3 storage backend via the `object_store` crate.
79///
80/// Configured via environment variables:
81/// - `AWS_ACCESS_KEY_ID`
82/// - `AWS_SECRET_ACCESS_KEY`
83/// - `AWS_DEFAULT_REGION` or `SHAPERAIL_STORAGE_REGION`
84/// - `SHAPERAIL_STORAGE_BUCKET`
85pub struct S3Storage {
86    store: object_store::aws::AmazonS3,
87    base_url: String,
88}
89
90impl S3Storage {
91    /// Create from environment variables.
92    pub fn from_env() -> Result<Self, StorageError> {
93        let bucket = std::env::var("SHAPERAIL_STORAGE_BUCKET").map_err(|_| {
94            StorageError::Backend("SHAPERAIL_STORAGE_BUCKET env var required for S3".to_string())
95        })?;
96        let region = std::env::var("SHAPERAIL_STORAGE_REGION")
97            .or_else(|_| std::env::var("AWS_DEFAULT_REGION"))
98            .unwrap_or_else(|_| "us-east-1".to_string());
99
100        let store = object_store::aws::AmazonS3Builder::from_env()
101            .with_bucket_name(&bucket)
102            .with_region(&region)
103            .build()
104            .map_err(|e| StorageError::Backend(format!("Failed to build S3 client: {e}")))?;
105
106        let base_url = format!("https://{bucket}.s3.{region}.amazonaws.com");
107
108        Ok(Self { store, base_url })
109    }
110
111    pub async fn upload(
112        &self,
113        path: &str,
114        data: &[u8],
115        mime_type: &str,
116    ) -> Result<FileMetadata, StorageError> {
117        do_upload(&self.store, path, data, mime_type).await
118    }
119
120    pub async fn download(&self, path: &str) -> Result<Vec<u8>, StorageError> {
121        do_download(&self.store, path).await
122    }
123
124    pub async fn delete(&self, path: &str) -> Result<(), StorageError> {
125        do_delete(&self.store, path).await
126    }
127
128    pub async fn signed_url(&self, path: &str, expires_secs: u64) -> Result<String, StorageError> {
129        do_signed_url(&self.store, path, expires_secs, &self.base_url).await
130    }
131}
132
133/// Google Cloud Storage backend via the `object_store` crate.
134///
135/// Configured via environment variables:
136/// - `GOOGLE_SERVICE_ACCOUNT` or `GOOGLE_APPLICATION_CREDENTIALS`
137/// - `SHAPERAIL_STORAGE_BUCKET`
138pub struct GcsStorage {
139    store: object_store::gcp::GoogleCloudStorage,
140    base_url: String,
141}
142
143impl GcsStorage {
144    /// Create from environment variables.
145    pub fn from_env() -> Result<Self, StorageError> {
146        let bucket = std::env::var("SHAPERAIL_STORAGE_BUCKET").map_err(|_| {
147            StorageError::Backend("SHAPERAIL_STORAGE_BUCKET env var required for GCS".to_string())
148        })?;
149
150        let store = object_store::gcp::GoogleCloudStorageBuilder::from_env()
151            .with_bucket_name(&bucket)
152            .build()
153            .map_err(|e| StorageError::Backend(format!("Failed to build GCS client: {e}")))?;
154
155        let base_url = format!("https://storage.googleapis.com/{bucket}");
156
157        Ok(Self { store, base_url })
158    }
159
160    pub async fn upload(
161        &self,
162        path: &str,
163        data: &[u8],
164        mime_type: &str,
165    ) -> Result<FileMetadata, StorageError> {
166        do_upload(&self.store, path, data, mime_type).await
167    }
168
169    pub async fn download(&self, path: &str) -> Result<Vec<u8>, StorageError> {
170        do_download(&self.store, path).await
171    }
172
173    pub async fn delete(&self, path: &str) -> Result<(), StorageError> {
174        do_delete(&self.store, path).await
175    }
176
177    pub async fn signed_url(&self, path: &str, expires_secs: u64) -> Result<String, StorageError> {
178        do_signed_url(&self.store, path, expires_secs, &self.base_url).await
179    }
180}
181
182/// Azure Blob Storage backend via the `object_store` crate.
183///
184/// Configured via environment variables:
185/// - `AZURE_STORAGE_ACCOUNT_NAME`
186/// - `AZURE_STORAGE_ACCESS_KEY`
187/// - `SHAPERAIL_STORAGE_BUCKET` (container name)
188pub struct AzureStorage {
189    store: object_store::azure::MicrosoftAzure,
190    base_url: String,
191}
192
193impl AzureStorage {
194    /// Create from environment variables.
195    pub fn from_env() -> Result<Self, StorageError> {
196        let container = std::env::var("SHAPERAIL_STORAGE_BUCKET").map_err(|_| {
197            StorageError::Backend("SHAPERAIL_STORAGE_BUCKET env var required for Azure".to_string())
198        })?;
199        let account = std::env::var("AZURE_STORAGE_ACCOUNT_NAME")
200            .unwrap_or_else(|_| "devstoreaccount1".to_string());
201
202        let store = object_store::azure::MicrosoftAzureBuilder::from_env()
203            .with_container_name(&container)
204            .build()
205            .map_err(|e| StorageError::Backend(format!("Failed to build Azure client: {e}")))?;
206
207        let base_url = format!("https://{account}.blob.core.windows.net/{container}");
208
209        Ok(Self { store, base_url })
210    }
211
212    pub async fn upload(
213        &self,
214        path: &str,
215        data: &[u8],
216        mime_type: &str,
217    ) -> Result<FileMetadata, StorageError> {
218        do_upload(&self.store, path, data, mime_type).await
219    }
220
221    pub async fn download(&self, path: &str) -> Result<Vec<u8>, StorageError> {
222        do_download(&self.store, path).await
223    }
224
225    pub async fn delete(&self, path: &str) -> Result<(), StorageError> {
226        do_delete(&self.store, path).await
227    }
228
229    pub async fn signed_url(&self, path: &str, expires_secs: u64) -> Result<String, StorageError> {
230        do_signed_url(&self.store, path, expires_secs, &self.base_url).await
231    }
232}