Skip to main content

ferro_storage/
facade.rs

1//! Storage facade for managing multiple disks.
2
3use crate::config::StorageConfig;
4use crate::drivers::{LocalDriver, MemoryDriver};
5#[cfg(feature = "s3")]
6use crate::env_helpers::env_with_fallback;
7use crate::storage::{FileMetadata, PutOptions, StorageDriver};
8use crate::Error;
9use bytes::Bytes;
10use dashmap::DashMap;
11use std::sync::Arc;
12use std::time::Duration;
13
14/// Storage disk configuration.
15#[derive(Debug, Clone)]
16pub struct DiskConfig {
17    /// Disk driver type.
18    pub driver: DiskDriver,
19    /// Root path for local driver.
20    pub root: Option<String>,
21    /// URL base for generating URLs.
22    pub url: Option<String>,
23    /// CDN base URL for generating edge URLs (falls back to url when None).
24    pub cdn_url: Option<String>,
25    /// S3 bucket for S3 driver.
26    #[cfg(feature = "s3")]
27    pub bucket: Option<String>,
28    /// S3 region.
29    #[cfg(feature = "s3")]
30    pub region: Option<String>,
31}
32
33/// Available disk drivers.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum DiskDriver {
36    /// Local filesystem.
37    Local,
38    /// In-memory (for testing).
39    Memory,
40    /// Amazon S3.
41    #[cfg(feature = "s3")]
42    S3,
43}
44
45impl Default for DiskConfig {
46    fn default() -> Self {
47        Self {
48            driver: DiskDriver::Local,
49            root: Some("storage".to_string()),
50            url: None,
51            cdn_url: None,
52            #[cfg(feature = "s3")]
53            bucket: None,
54            #[cfg(feature = "s3")]
55            region: None,
56        }
57    }
58}
59
60impl DiskConfig {
61    /// Create a local disk config.
62    pub fn local(root: impl Into<String>) -> Self {
63        Self {
64            driver: DiskDriver::Local,
65            root: Some(root.into()),
66            url: None,
67            cdn_url: None,
68            #[cfg(feature = "s3")]
69            bucket: None,
70            #[cfg(feature = "s3")]
71            region: None,
72        }
73    }
74
75    /// Create a memory disk config.
76    pub fn memory() -> Self {
77        Self {
78            driver: DiskDriver::Memory,
79            root: None,
80            url: None,
81            cdn_url: None,
82            #[cfg(feature = "s3")]
83            bucket: None,
84            #[cfg(feature = "s3")]
85            region: None,
86        }
87    }
88
89    /// Set URL base.
90    pub fn with_url(mut self, url: impl Into<String>) -> Self {
91        self.url = Some(url.into());
92        self
93    }
94
95    /// Set the CDN base URL.
96    pub fn with_cdn_url(mut self, cdn_url: impl Into<String>) -> Self {
97        self.cdn_url = Some(cdn_url.into());
98        self
99    }
100}
101
102/// Storage facade for file operations.
103#[derive(Clone)]
104pub struct Storage {
105    inner: Arc<StorageInner>,
106}
107
108struct StorageInner {
109    disks: DashMap<String, (Arc<dyn StorageDriver>, Option<String>)>,
110    default_disk: String,
111}
112
113impl Default for Storage {
114    fn default() -> Self {
115        Self::new()
116    }
117}
118
119impl Storage {
120    /// Create a new storage instance with a default local disk.
121    pub fn new() -> Self {
122        let inner = StorageInner {
123            disks: DashMap::new(),
124            default_disk: "local".to_string(),
125        };
126
127        let storage = Self {
128            inner: Arc::new(inner),
129        };
130
131        // Add default local disk
132        let local = LocalDriver::new("storage");
133        storage
134            .inner
135            .disks
136            .insert("local".to_string(), (Arc::new(local), None));
137
138        storage
139    }
140
141    /// Create storage with custom configuration.
142    pub fn with_config(default_disk: &str, configs: Vec<(&str, DiskConfig)>) -> Self {
143        let inner = StorageInner {
144            disks: DashMap::new(),
145            default_disk: default_disk.to_string(),
146        };
147
148        let storage = Self {
149            inner: Arc::new(inner),
150        };
151
152        for (name, config) in configs {
153            let driver = Self::create_driver(&config);
154            storage
155                .inner
156                .disks
157                .insert(name.to_string(), (driver, config.cdn_url.clone()));
158        }
159
160        storage
161    }
162
163    /// Create storage from a StorageConfig (typically loaded from environment).
164    ///
165    /// # Example
166    ///
167    /// ```rust,ignore
168    /// use ferro_storage::{Storage, StorageConfig};
169    ///
170    /// // Load configuration from environment variables
171    /// let config = StorageConfig::from_env();
172    /// let storage = Storage::with_storage_config(config);
173    ///
174    /// // Or use the builder pattern
175    /// let config = StorageConfig::new("local")
176    ///     .disk("local", DiskConfig::local("./storage"))
177    ///     .disk("public", DiskConfig::local("./public").with_url("/storage"));
178    /// let storage = Storage::with_storage_config(config);
179    /// ```
180    pub fn with_storage_config(config: StorageConfig) -> Self {
181        let inner = StorageInner {
182            disks: DashMap::new(),
183            default_disk: config.default,
184        };
185
186        let storage = Self {
187            inner: Arc::new(inner),
188        };
189
190        for (name, disk_config) in config.disks {
191            let driver = Self::create_driver(&disk_config);
192            storage
193                .inner
194                .disks
195                .insert(name, (driver, disk_config.cdn_url.clone()));
196        }
197
198        storage
199    }
200
201    /// Create a driver from disk configuration.
202    fn create_driver(config: &DiskConfig) -> Arc<dyn StorageDriver> {
203        match config.driver {
204            DiskDriver::Local => {
205                let root = config.root.clone().unwrap_or_else(|| "storage".to_string());
206                let mut driver = LocalDriver::new(root);
207                if let Some(url) = &config.url {
208                    driver = driver.with_url_base(url);
209                }
210                Arc::new(driver)
211            }
212            DiskDriver::Memory => {
213                let mut driver = MemoryDriver::new();
214                if let Some(url) = &config.url {
215                    driver = driver.with_url_base(url);
216                }
217                Arc::new(driver)
218            }
219            #[cfg(feature = "s3")]
220            DiskDriver::S3 => {
221                let bucket = config.bucket.clone().unwrap_or_default();
222                let region = config
223                    .region
224                    .clone()
225                    .unwrap_or_else(|| "us-east-1".to_string());
226                let url_base = config.url.clone();
227                let endpoint_url = env_with_fallback("STORAGE_ENDPOINT", &["AWS_URL"]);
228                Arc::new(crate::drivers::S3Driver::new(
229                    bucket,
230                    region,
231                    url_base,
232                    endpoint_url,
233                ))
234            }
235        }
236    }
237
238    /// Get a specific disk.
239    pub fn disk(&self, name: &str) -> Result<Disk, Error> {
240        let entry = self
241            .inner
242            .disks
243            .get(name)
244            .ok_or_else(|| Error::disk_not_configured(name))?;
245        let (driver, cdn_url) = entry.clone();
246
247        Ok(Disk::new(driver, cdn_url))
248    }
249
250    /// Get the default disk.
251    pub fn default_disk(&self) -> Result<Disk, Error> {
252        self.disk(&self.inner.default_disk)
253    }
254
255    /// Register a disk without a CDN URL. `cdn_url()` on this disk falls back to the origin URL.
256    pub fn register_disk(&self, name: impl Into<String>, driver: Arc<dyn StorageDriver>) {
257        self.inner.disks.insert(name.into(), (driver, None));
258    }
259
260    /// Register a disk with an optional CDN base URL.
261    ///
262    /// When `cdn_url` is `Some`, `Disk::cdn_url()` returns `{cdn_url}/{path}` instead of
263    /// falling back to the origin URL.
264    pub fn register_disk_with_cdn(
265        &self,
266        name: impl Into<String>,
267        driver: Arc<dyn StorageDriver>,
268        cdn_url: Option<String>,
269    ) {
270        self.inner.disks.insert(name.into(), (driver, cdn_url));
271    }
272
273    // Convenience methods that operate on the default disk
274
275    /// Check if a file exists.
276    pub async fn exists(&self, path: &str) -> Result<bool, Error> {
277        self.default_disk()?.exists(path).await
278    }
279
280    /// Get file contents.
281    pub async fn get(&self, path: &str) -> Result<Bytes, Error> {
282        self.default_disk()?.get(path).await
283    }
284
285    /// Get file as string.
286    pub async fn get_string(&self, path: &str) -> Result<String, Error> {
287        self.default_disk()?.get_string(path).await
288    }
289
290    /// Put file contents.
291    pub async fn put(&self, path: &str, contents: impl Into<Bytes>) -> Result<(), Error> {
292        self.default_disk()?.put(path, contents).await
293    }
294
295    /// Put with options.
296    pub async fn put_with_options(
297        &self,
298        path: &str,
299        contents: impl Into<Bytes>,
300        options: PutOptions,
301    ) -> Result<(), Error> {
302        self.default_disk()?
303            .put_with_options(path, contents, options)
304            .await
305    }
306
307    /// Delete a file.
308    pub async fn delete(&self, path: &str) -> Result<(), Error> {
309        self.default_disk()?.delete(path).await
310    }
311
312    /// Copy a file.
313    pub async fn copy(&self, from: &str, to: &str) -> Result<(), Error> {
314        self.default_disk()?.copy(from, to).await
315    }
316
317    /// Move a file.
318    pub async fn rename(&self, from: &str, to: &str) -> Result<(), Error> {
319        self.default_disk()?.rename(from, to).await
320    }
321
322    /// Get file URL.
323    pub async fn url(&self, path: &str) -> Result<String, Error> {
324        self.default_disk()?.url(path).await
325    }
326
327    /// Get the CDN edge URL for a path (origin fallback when no CDN configured).
328    pub async fn cdn_url(&self, path: &str) -> Result<String, Error> {
329        self.default_disk()?.cdn_url(path).await
330    }
331}
332
333/// A handle to a specific disk.
334#[derive(Clone)]
335pub struct Disk {
336    driver: Arc<dyn StorageDriver>,
337    cdn_url: Option<String>,
338}
339
340impl Disk {
341    /// Create a disk from a driver.
342    pub fn new(driver: Arc<dyn StorageDriver>, cdn_url: Option<String>) -> Self {
343        Self { driver, cdn_url }
344    }
345
346    /// Check if a file exists.
347    pub async fn exists(&self, path: &str) -> Result<bool, Error> {
348        self.driver.exists(path).await
349    }
350
351    /// Get file contents.
352    pub async fn get(&self, path: &str) -> Result<Bytes, Error> {
353        self.driver.get(path).await
354    }
355
356    /// Get file as string.
357    pub async fn get_string(&self, path: &str) -> Result<String, Error> {
358        self.driver.get_string(path).await
359    }
360
361    /// Put file contents.
362    pub async fn put(&self, path: &str, contents: impl Into<Bytes>) -> Result<(), Error> {
363        self.driver
364            .put(path, contents.into(), PutOptions::new())
365            .await
366    }
367
368    /// Put with options.
369    pub async fn put_with_options(
370        &self,
371        path: &str,
372        contents: impl Into<Bytes>,
373        options: PutOptions,
374    ) -> Result<(), Error> {
375        self.driver.put(path, contents.into(), options).await
376    }
377
378    /// Delete a file.
379    pub async fn delete(&self, path: &str) -> Result<(), Error> {
380        self.driver.delete(path).await
381    }
382
383    /// Copy a file.
384    pub async fn copy(&self, from: &str, to: &str) -> Result<(), Error> {
385        self.driver.copy(from, to).await
386    }
387
388    /// Move a file.
389    pub async fn rename(&self, from: &str, to: &str) -> Result<(), Error> {
390        self.driver.rename(from, to).await
391    }
392
393    /// Get file size.
394    pub async fn size(&self, path: &str) -> Result<u64, Error> {
395        self.driver.size(path).await
396    }
397
398    /// Get file metadata.
399    pub async fn metadata(&self, path: &str) -> Result<FileMetadata, Error> {
400        self.driver.metadata(path).await
401    }
402
403    /// Get file URL.
404    pub async fn url(&self, path: &str) -> Result<String, Error> {
405        self.driver.url(path).await
406    }
407
408    /// Returns the CDN edge URL for a stored path, falling back to the origin URL when no CDN is configured.
409    pub async fn cdn_url(&self, path: &str) -> Result<String, Error> {
410        match &self.cdn_url {
411            Some(base) => {
412                let path = path.trim_start_matches('/');
413                Ok(format!("{}/{}", base.trim_end_matches('/'), path))
414            }
415            None => self.url(path).await,
416        }
417    }
418
419    /// Get temporary URL.
420    pub async fn temporary_url(&self, path: &str, expiration: Duration) -> Result<String, Error> {
421        self.driver.temporary_url(path, expiration).await
422    }
423
424    /// List files in a directory.
425    pub async fn files(&self, directory: &str) -> Result<Vec<String>, Error> {
426        self.driver.files(directory).await
427    }
428
429    /// List all files recursively.
430    pub async fn all_files(&self, directory: &str) -> Result<Vec<String>, Error> {
431        self.driver.all_files(directory).await
432    }
433
434    /// List directories.
435    pub async fn directories(&self, directory: &str) -> Result<Vec<String>, Error> {
436        self.driver.directories(directory).await
437    }
438
439    /// Create a directory.
440    pub async fn make_directory(&self, path: &str) -> Result<(), Error> {
441        self.driver.make_directory(path).await
442    }
443
444    /// Delete a directory.
445    pub async fn delete_directory(&self, path: &str) -> Result<(), Error> {
446        self.driver.delete_directory(path).await
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    #[tokio::test]
455    async fn cdn_url_returns_cdn_when_configured() {
456        let storage = Storage::with_config(
457            "memory",
458            vec![(
459                "memory",
460                DiskConfig::memory()
461                    .with_url("https://origin.example.com")
462                    .with_cdn_url("https://cdn.example.com"),
463            )],
464        );
465        let url = storage
466            .disk("memory")
467            .unwrap()
468            .cdn_url("images/photo.jpg")
469            .await
470            .unwrap();
471        assert_eq!(url, "https://cdn.example.com/images/photo.jpg");
472    }
473
474    #[tokio::test]
475    async fn cdn_url_falls_back_to_origin() {
476        let storage = Storage::with_config(
477            "memory",
478            vec![(
479                "memory",
480                DiskConfig::memory().with_url("https://origin.example.com"),
481            )],
482        );
483        let url = storage
484            .disk("memory")
485            .unwrap()
486            .cdn_url("images/photo.jpg")
487            .await
488            .unwrap();
489        assert_eq!(url, "https://origin.example.com/images/photo.jpg");
490    }
491
492    #[tokio::test]
493    async fn cdn_url_no_double_slash() {
494        let storage = Storage::with_config(
495            "memory",
496            vec![(
497                "memory",
498                DiskConfig::memory().with_cdn_url("https://cdn.example.com/"),
499            )],
500        );
501        let url = storage
502            .disk("memory")
503            .unwrap()
504            .cdn_url("/index.html")
505            .await
506            .unwrap();
507        assert_eq!(url, "https://cdn.example.com/index.html");
508    }
509
510    #[tokio::test]
511    async fn cdn_url_via_storage_facade() {
512        let storage = Storage::with_config(
513            "memory",
514            vec![(
515                "memory",
516                DiskConfig::memory()
517                    .with_url("https://origin.example.com")
518                    .with_cdn_url("https://cdn.example.com"),
519            )],
520        );
521        let url = storage.cdn_url("images/photo.jpg").await.unwrap();
522        assert_eq!(url, "https://cdn.example.com/images/photo.jpg");
523    }
524
525    #[tokio::test]
526    async fn test_storage_default_disk() {
527        let storage = Storage::with_config("memory", vec![("memory", DiskConfig::memory())]);
528
529        storage.put("test.txt", "hello world").await.unwrap();
530        let contents = storage.get_string("test.txt").await.unwrap();
531        assert_eq!(contents, "hello world");
532    }
533
534    #[tokio::test]
535    async fn test_storage_multiple_disks() {
536        let storage = Storage::with_config(
537            "primary",
538            vec![
539                ("primary", DiskConfig::memory()),
540                ("backup", DiskConfig::memory()),
541            ],
542        );
543
544        // Write to primary
545        storage
546            .disk("primary")
547            .unwrap()
548            .put("data.txt", "primary data")
549            .await
550            .unwrap();
551
552        // Write to backup
553        storage
554            .disk("backup")
555            .unwrap()
556            .put("data.txt", "backup data")
557            .await
558            .unwrap();
559
560        // Verify they're independent
561        let primary = storage
562            .disk("primary")
563            .unwrap()
564            .get_string("data.txt")
565            .await
566            .unwrap();
567        let backup = storage
568            .disk("backup")
569            .unwrap()
570            .get_string("data.txt")
571            .await
572            .unwrap();
573
574        assert_eq!(primary, "primary data");
575        assert_eq!(backup, "backup data");
576    }
577
578    #[tokio::test]
579    async fn test_disk_not_configured() {
580        let storage = Storage::with_config("memory", vec![("memory", DiskConfig::memory())]);
581        let result = storage.disk("nonexistent");
582        assert!(result.is_err());
583    }
584
585    #[tokio::test]
586    async fn test_register_disk() {
587        let storage = Storage::new();
588        let memory_driver = Arc::new(MemoryDriver::new());
589        storage.register_disk("test", memory_driver);
590
591        storage
592            .disk("test")
593            .unwrap()
594            .put("file.txt", "content")
595            .await
596            .unwrap();
597
598        assert!(storage
599            .disk("test")
600            .unwrap()
601            .exists("file.txt")
602            .await
603            .unwrap());
604    }
605
606    /// register_disk_with_cdn(Some(url)) makes cdn_url() return the CDN-prefixed URL.
607    #[tokio::test]
608    async fn test_register_disk_with_cdn_url() {
609        let storage = Storage::new();
610        let driver = Arc::new(MemoryDriver::new());
611        storage.register_disk_with_cdn(
612            "cdn-disk",
613            driver,
614            Some("https://cdn.example.com".to_string()),
615        );
616        let url = storage
617            .disk("cdn-disk")
618            .unwrap()
619            .cdn_url("assets/app.js")
620            .await
621            .unwrap();
622        assert_eq!(url, "https://cdn.example.com/assets/app.js");
623    }
624
625    /// register_disk_with_cdn(None) behaves identically to register_disk.
626    #[tokio::test]
627    async fn test_register_disk_with_cdn_none_falls_back() {
628        let storage = Storage::new();
629        storage.register_disk_with_cdn("no-cdn", Arc::new(MemoryDriver::new()), None);
630        // Disk is registered and accessible; cdn_url() delegates to driver.url() (origin fallback).
631        assert!(storage.disk("no-cdn").is_ok());
632    }
633}