Skip to main content

liter_llm_proxy/
file_store.rs

1use std::str::FromStr;
2
3use bytes::Bytes;
4use opendal::Operator;
5
6use crate::config::FileStorageConfig;
7
8/// OpenDAL-backed file storage for the proxy server.
9///
10/// Supports any OpenDAL backend (memory, S3, GCS, local filesystem, etc.)
11/// through the `FileStorageConfig` backend configuration.
12pub struct FileStore {
13    operator: Operator,
14    prefix: String,
15}
16
17impl FileStore {
18    /// Build a `FileStore` from proxy file storage configuration.
19    ///
20    /// Parses the backend scheme from `config.backend`, builds an OpenDAL
21    /// operator with the provided `backend_config`, and stores `config.prefix`
22    /// for path prefixing.
23    ///
24    /// # Errors
25    ///
26    /// Returns an error string if the scheme is unknown or the operator
27    /// cannot be constructed.
28    pub fn from_config(config: &FileStorageConfig) -> Result<Self, String> {
29        let scheme = opendal::Scheme::from_str(&config.backend)
30            .map_err(|e| format!("unknown storage backend '{}': {e}", config.backend))?;
31
32        let operator = Operator::via_iter(scheme, config.backend_config.clone())
33            .map_err(|e| format!("failed to build storage operator for '{}': {e}", config.backend))?;
34
35        Ok(Self {
36            operator,
37            prefix: config.prefix.clone(),
38        })
39    }
40
41    /// Resolve the full path by prepending the prefix to the key.
42    fn full_path(&self, key: &str) -> String {
43        format!("{}{key}", self.prefix)
44    }
45
46    /// Write data to the store under the given key.
47    ///
48    /// # Errors
49    ///
50    /// Returns an error string if the write operation fails.
51    pub async fn write(&self, key: &str, data: Bytes) -> Result<(), String> {
52        let path = self.full_path(key);
53        self.operator
54            .write(&path, data)
55            .await
56            .map(|_| ())
57            .map_err(|e| format!("failed to write '{path}': {e}"))
58    }
59
60    /// Read data from the store for the given key.
61    ///
62    /// # Errors
63    ///
64    /// Returns an error string if the key does not exist or the read fails.
65    pub async fn read(&self, key: &str) -> Result<Bytes, String> {
66        let path = self.full_path(key);
67        let buf = self
68            .operator
69            .read(&path)
70            .await
71            .map_err(|e| format!("failed to read '{path}': {e}"))?;
72        Ok(buf.to_bytes())
73    }
74
75    /// Delete a key from the store.
76    ///
77    /// # Errors
78    ///
79    /// Returns an error string if the delete operation fails.
80    pub async fn delete(&self, key: &str) -> Result<(), String> {
81        let path = self.full_path(key);
82        self.operator
83            .delete(&path)
84            .await
85            .map_err(|e| format!("failed to delete '{path}': {e}"))
86    }
87
88    /// List keys under an optional prefix (relative to the store prefix).
89    ///
90    /// Returns the full key paths (without the store prefix).
91    ///
92    /// # Errors
93    ///
94    /// Returns an error string if the list operation fails.
95    pub async fn list(&self, prefix: Option<&str>) -> Result<Vec<String>, String> {
96        let scan_prefix = match prefix {
97            Some(p) => format!("{}{p}", self.prefix),
98            None => self.prefix.clone(),
99        };
100        let entries = self
101            .operator
102            .list(&scan_prefix)
103            .await
104            .map_err(|e| format!("failed to list '{scan_prefix}': {e}"))?;
105
106        let store_prefix_len = self.prefix.len();
107        let keys: Vec<String> = entries
108            .into_iter()
109            .filter(|entry| !entry.path().ends_with('/'))
110            .filter_map(|entry| {
111                let path = entry.path();
112                if path.len() > store_prefix_len {
113                    Some(path[store_prefix_len..].to_string())
114                } else {
115                    None
116                }
117            })
118            .collect();
119
120        Ok(keys)
121    }
122
123    /// Check whether a key exists in the store.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error string if the existence check fails.
128    pub async fn exists(&self, key: &str) -> Result<bool, String> {
129        let path = self.full_path(key);
130        self.operator
131            .exists(&path)
132            .await
133            .map_err(|e| format!("failed to check existence of '{path}': {e}"))
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::config::FileStorageConfig;
141
142    fn memory_store() -> FileStore {
143        let config = FileStorageConfig::default();
144        FileStore::from_config(&config).expect("memory backend should build")
145    }
146
147    #[tokio::test]
148    async fn write_then_read_returns_same_data() {
149        let store = memory_store();
150        let data = Bytes::from_static(b"hello world");
151        store
152            .write("test.txt", data.clone())
153            .await
154            .expect("write should succeed");
155        let read_data = store.read("test.txt").await.expect("read should succeed");
156        assert_eq!(read_data, data);
157    }
158
159    #[tokio::test]
160    async fn read_nonexistent_key_returns_error() {
161        let store = memory_store();
162        let result = store.read("does-not-exist.txt").await;
163        assert!(result.is_err());
164    }
165
166    #[tokio::test]
167    async fn delete_then_exists_returns_false() {
168        let store = memory_store();
169        store
170            .write("to-delete.txt", Bytes::from_static(b"data"))
171            .await
172            .expect("write should succeed");
173        assert!(
174            store.exists("to-delete.txt").await.expect("exists check"),
175            "key should exist after write"
176        );
177        store.delete("to-delete.txt").await.expect("delete should succeed");
178        assert!(
179            !store.exists("to-delete.txt").await.expect("exists check"),
180            "key should not exist after delete"
181        );
182    }
183
184    #[tokio::test]
185    async fn list_returns_written_keys() {
186        let store = memory_store();
187        store.write("a.txt", Bytes::from_static(b"aaa")).await.expect("write a");
188        store.write("b.txt", Bytes::from_static(b"bbb")).await.expect("write b");
189
190        let mut keys = store.list(None).await.expect("list should succeed");
191        keys.sort();
192        assert_eq!(keys, vec!["a.txt", "b.txt"]);
193    }
194
195    #[tokio::test]
196    async fn exists_returns_false_for_missing_key() {
197        let store = memory_store();
198        let result = store.exists("nope.txt").await.expect("exists check");
199        assert!(!result);
200    }
201
202    #[tokio::test]
203    async fn exists_returns_true_after_write() {
204        let store = memory_store();
205        store
206            .write("present.txt", Bytes::from_static(b"here"))
207            .await
208            .expect("write");
209        let result = store.exists("present.txt").await.expect("exists check");
210        assert!(result);
211    }
212
213    #[tokio::test]
214    async fn overwrite_replaces_data() {
215        let store = memory_store();
216        store
217            .write("file.txt", Bytes::from_static(b"original"))
218            .await
219            .expect("write");
220        store
221            .write("file.txt", Bytes::from_static(b"replaced"))
222            .await
223            .expect("overwrite");
224        let data = store.read("file.txt").await.expect("read");
225        assert_eq!(data, Bytes::from_static(b"replaced"));
226    }
227
228    #[test]
229    fn from_config_rejects_unknown_backend() {
230        let config = FileStorageConfig {
231            backend: "nonexistent_xyz".to_string(),
232            prefix: "test/".to_string(),
233            backend_config: std::collections::HashMap::new(),
234        };
235        let result = FileStore::from_config(&config);
236        assert!(result.is_err());
237    }
238}