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