Skip to main content

rustrails_storage/service/
memory.rs

1//! In-memory storage service used by tests and ephemeral flows.
2
3use std::{sync::Arc, time::Duration};
4
5use async_trait::async_trait;
6use bytes::Bytes;
7use dashmap::DashMap;
8use url::Url;
9
10use super::{StorageError, StorageService, checked_key};
11
12/// Thread-safe in-memory storage backend.
13#[derive(Debug, Clone)]
14pub struct MemoryService {
15    name: String,
16    objects: Arc<DashMap<String, Bytes>>,
17    base_url: Url,
18}
19
20impl MemoryService {
21    /// Creates an empty in-memory service.
22    ///
23    /// # Errors
24    ///
25    /// Returns an error when the default base URL cannot be constructed.
26    pub fn new(name: impl Into<String>) -> Result<Self, StorageError> {
27        let base_url = Url::parse("memory://storage/")
28            .map_err(|error| StorageError::InvalidUrl(error.to_string()))?;
29        Ok(Self {
30            name: name.into(),
31            objects: Arc::new(DashMap::new()),
32            base_url,
33        })
34    }
35
36    /// Returns a copy with a custom public base URL.
37    #[must_use]
38    pub fn with_base_url(mut self, base_url: Url) -> Self {
39        self.base_url = base_url;
40        self
41    }
42
43    /// Returns the number of stored keys.
44    #[must_use]
45    pub fn len(&self) -> usize {
46        self.objects.len()
47    }
48
49    /// Returns whether the service currently stores no keys.
50    #[must_use]
51    pub fn is_empty(&self) -> bool {
52        self.objects.is_empty()
53    }
54}
55
56#[async_trait]
57impl StorageService for MemoryService {
58    fn name(&self) -> &str {
59        &self.name
60    }
61
62    async fn upload(&self, key: &str, data: Bytes) -> Result<(), StorageError> {
63        let key = checked_key(key)?.to_owned();
64        if self.objects.contains_key(&key) {
65            return Err(StorageError::DuplicateKey(key));
66        }
67        self.objects.insert(key, data);
68        Ok(())
69    }
70
71    async fn download(&self, key: &str) -> Result<Bytes, StorageError> {
72        let key = checked_key(key)?;
73        self.objects
74            .get(key)
75            .map(|entry| entry.value().clone())
76            .ok_or_else(|| StorageError::NotFound(key.to_owned()))
77    }
78
79    async fn delete(&self, key: &str) -> Result<(), StorageError> {
80        let key = checked_key(key)?;
81        let _ = self.objects.remove(key);
82        Ok(())
83    }
84
85    async fn exists(&self, key: &str) -> Result<bool, StorageError> {
86        let key = checked_key(key)?;
87        Ok(self.objects.contains_key(key))
88    }
89
90    async fn url(&self, key: &str, expires_in: Duration) -> Result<Url, StorageError> {
91        let key = checked_key(key)?;
92        let mut url = self.base_url.clone();
93        url.set_path(key);
94        url.query_pairs_mut()
95            .append_pair("service", &self.name)
96            .append_pair("expires_in", &expires_in.as_secs().to_string());
97        Ok(url)
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    fn service() -> MemoryService {
106        MemoryService::new("memory").expect("service should build")
107    }
108
109    #[tokio::test]
110    async fn test_upload_and_download_round_trip() {
111        let service = service();
112        service
113            .upload("a.txt", Bytes::from_static(b"hello"))
114            .await
115            .expect("upload should succeed");
116        assert_eq!(
117            service
118                .download("a.txt")
119                .await
120                .expect("download should succeed"),
121            Bytes::from_static(b"hello")
122        );
123    }
124
125    #[tokio::test]
126    async fn test_upload_rejects_duplicate_key() {
127        let service = service();
128        service
129            .upload("a.txt", Bytes::from_static(b"one"))
130            .await
131            .expect("upload should succeed");
132        let error = service
133            .upload("a.txt", Bytes::from_static(b"two"))
134            .await
135            .expect_err("duplicate should fail");
136        assert!(matches!(error, StorageError::DuplicateKey(key) if key == "a.txt"));
137    }
138
139    #[tokio::test]
140    async fn test_download_missing_key_returns_not_found() {
141        let service = service();
142        let error = service
143            .download("missing")
144            .await
145            .expect_err("download should fail");
146        assert!(matches!(error, StorageError::NotFound(key) if key == "missing"));
147    }
148
149    #[tokio::test]
150    async fn test_delete_removes_value() {
151        let service = service();
152        service
153            .upload("a.txt", Bytes::from_static(b"hello"))
154            .await
155            .expect("upload should succeed");
156        service
157            .delete("a.txt")
158            .await
159            .expect("delete should succeed");
160        assert!(
161            !service
162                .exists("a.txt")
163                .await
164                .expect("exists should succeed")
165        );
166    }
167
168    #[tokio::test]
169    async fn test_delete_missing_key_is_a_noop() {
170        let service = service();
171        service
172            .delete("missing")
173            .await
174            .expect("delete should succeed");
175        assert!(service.is_empty());
176    }
177
178    #[tokio::test]
179    async fn test_exists_tracks_presence() {
180        let service = service();
181        assert!(
182            !service
183                .exists("a.txt")
184                .await
185                .expect("exists should succeed")
186        );
187        service
188            .upload("a.txt", Bytes::from_static(b"hello"))
189            .await
190            .expect("upload should succeed");
191        assert!(
192            service
193                .exists("a.txt")
194                .await
195                .expect("exists should succeed")
196        );
197    }
198
199    #[tokio::test]
200    async fn test_url_includes_expiry_and_service_name() {
201        let service = service();
202        let url = service
203            .url("a.txt", Duration::from_secs(30))
204            .await
205            .expect("url should build");
206        assert_eq!(
207            url.as_str(),
208            "memory://storage/a.txt?service=memory&expires_in=30"
209        );
210    }
211
212    #[tokio::test]
213    async fn test_zero_byte_upload_is_supported() {
214        let service = service();
215        service
216            .upload("empty", Bytes::new())
217            .await
218            .expect("upload should succeed");
219        assert_eq!(
220            service
221                .download("empty")
222                .await
223                .expect("download should succeed"),
224            Bytes::new()
225        );
226    }
227
228    #[tokio::test]
229    async fn test_len_tracks_number_of_entries() {
230        let service = service();
231        service
232            .upload("a", Bytes::from_static(b"a"))
233            .await
234            .expect("upload should succeed");
235        service
236            .upload("b", Bytes::from_static(b"b"))
237            .await
238            .expect("upload should succeed");
239        assert_eq!(service.len(), 2);
240    }
241
242    #[tokio::test]
243    async fn test_custom_base_url_is_respected() {
244        let service = MemoryService::new("memory")
245            .expect("service should build")
246            .with_base_url(Url::parse("https://memory.example/").expect("url should parse"));
247        let url = service
248            .url("a.txt", Duration::from_secs(5))
249            .await
250            .expect("url should build");
251        assert_eq!(
252            url.as_str(),
253            "https://memory.example/a.txt?service=memory&expires_in=5"
254        );
255    }
256
257    #[tokio::test]
258    async fn test_invalid_empty_key_rejected() {
259        let service = service();
260        let error = service
261            .upload("", Bytes::from_static(b"hello"))
262            .await
263            .expect_err("upload should fail");
264        assert!(matches!(error, StorageError::InvalidUrl(_)));
265    }
266}