Skip to main content

modo/storage/
buckets.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use crate::error::{Error, Result};
5
6use super::config::BucketConfig;
7use super::facade::Storage;
8
9/// Named collection of `Storage` instances for multi-bucket apps.
10///
11/// Cheaply cloneable (wraps `Arc`). Each entry is a `Storage` keyed by name.
12pub struct Buckets {
13    inner: Arc<HashMap<String, Storage>>,
14}
15
16impl Clone for Buckets {
17    fn clone(&self) -> Self {
18        Self {
19            inner: Arc::clone(&self.inner),
20        }
21    }
22}
23
24impl Buckets {
25    /// Create from a list of bucket configs.
26    ///
27    /// Each config must have a unique, non-empty `name` field.
28    ///
29    /// # Errors
30    ///
31    /// Returns an error if any config has an empty `name`, if names are
32    /// duplicated, or if any individual [`BucketConfig`] fails validation.
33    pub fn new(configs: &[BucketConfig]) -> Result<Self> {
34        let mut map = HashMap::with_capacity(configs.len());
35        for config in configs {
36            if config.name.is_empty() {
37                return Err(Error::internal(
38                    "bucket config must have a name when used with Buckets",
39                ));
40            }
41            if map.contains_key(&config.name) {
42                return Err(Error::internal(format!(
43                    "duplicate bucket name '{}'",
44                    config.name
45                )));
46            }
47            let storage = Storage::new(config)?;
48            map.insert(config.name.clone(), storage);
49        }
50        Ok(Self {
51            inner: Arc::new(map),
52        })
53    }
54
55    /// Get a [`Storage`] by name (cloned -- cheap `Arc` clone).
56    ///
57    /// # Errors
58    ///
59    /// Returns an error if no bucket with that name is configured.
60    pub fn get(&self, name: &str) -> Result<Storage> {
61        self.inner
62            .get(name)
63            .cloned()
64            .ok_or_else(|| Error::internal(format!("bucket '{name}' not configured")))
65    }
66
67    /// Create named in-memory buckets for testing.
68    #[cfg(any(test, feature = "test-helpers"))]
69    pub fn memory(names: &[&str]) -> Self {
70        let mut map = HashMap::with_capacity(names.len());
71        for name in names {
72            map.insert((*name).to_string(), Storage::memory());
73        }
74        Self {
75            inner: Arc::new(map),
76        }
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::super::facade::PutInput;
83    use super::*;
84
85    fn test_input() -> PutInput {
86        PutInput {
87            data: bytes::Bytes::from_static(b"hello"),
88            prefix: "test/".into(),
89            filename: Some("test.txt".into()),
90            content_type: "text/plain".into(),
91        }
92    }
93
94    #[tokio::test]
95    async fn memory_buckets_get_existing() {
96        let buckets = Buckets::memory(&["avatars", "docs"]);
97        let store = buckets.get("avatars").unwrap();
98        let key = store.put(&test_input()).await.unwrap();
99        assert!(store.exists(&key).await.unwrap());
100    }
101
102    #[test]
103    fn get_unknown_name_returns_error() {
104        let buckets = Buckets::memory(&["avatars"]);
105        assert!(buckets.get("nonexistent").is_err());
106    }
107
108    #[tokio::test]
109    async fn buckets_are_isolated() {
110        let buckets = Buckets::memory(&["a", "b"]);
111        let store_a = buckets.get("a").unwrap();
112        let store_b = buckets.get("b").unwrap();
113
114        let key = store_a.put(&test_input()).await.unwrap();
115
116        assert!(store_a.exists(&key).await.unwrap());
117        assert!(!store_b.exists(&key).await.unwrap());
118    }
119
120    #[test]
121    fn empty_names_vec_is_valid() {
122        let buckets = Buckets::memory(&[]);
123        assert!(buckets.get("anything").is_err());
124    }
125
126    #[test]
127    fn clone_is_cheap() {
128        let buckets = Buckets::memory(&["a"]);
129        let cloned = buckets.clone();
130        assert!(cloned.get("a").is_ok());
131    }
132
133    #[test]
134    fn new_rejects_empty_name() {
135        let configs = vec![BucketConfig {
136            bucket: "b1".into(),
137            endpoint: "https://s3.example.com".into(),
138            ..Default::default()
139        }];
140        assert!(Buckets::new(&configs).is_err());
141    }
142}