Skip to main content

oxirs_samm/
cloud_backends_http.rs

1//! Generic HTTP REST storage backend.
2//!
3//! Useful for integration tests, custom REST APIs, and CI mocks.
4
5use crate::cloud_backends_common::url_encode;
6use crate::cloud_storage::CloudStorageBackend;
7use async_trait::async_trait;
8use reqwest::{Client, StatusCode};
9use tracing::debug;
10
11/// Configuration for a generic HTTP REST storage backend.
12///
13/// This backend is useful for:
14/// - Integration tests with a local HTTP server
15/// - Custom storage servers that expose a REST API
16/// - Mocking cloud storage in CI environments
17///
18/// Expected server conventions:
19/// - `PUT  <base_url>/<key>`            – upload
20/// - `GET  <base_url>/<key>`            – download
21/// - `HEAD <base_url>/<key>`            – exists check (200 = yes, 404 = no)
22/// - `DELETE <base_url>/<key>`          – delete
23/// - `GET  <base_url>?prefix=<prefix>`  – list (returns JSON array of strings)
24#[derive(Debug, Clone)]
25pub struct HttpConfig {
26    /// Base URL of the storage server (e.g. `"http://localhost:8080/storage"`).
27    pub base_url: String,
28    /// Optional `Authorization` header value.
29    pub auth_header: Option<String>,
30    /// Optional path prefix prepended to every object key.
31    pub path_prefix: String,
32}
33
34impl HttpConfig {
35    /// Create a basic `HttpConfig` with no authentication.
36    pub fn new(base_url: impl Into<String>) -> Self {
37        Self {
38            base_url: base_url.into(),
39            auth_header: None,
40            path_prefix: String::new(),
41        }
42    }
43
44    /// Create an `HttpConfig` with Bearer token authentication.
45    pub fn with_bearer(base_url: impl Into<String>, token: impl Into<String>) -> Self {
46        Self {
47            base_url: base_url.into(),
48            auth_header: Some(format!("Bearer {}", token.into())),
49            path_prefix: String::new(),
50        }
51    }
52
53    pub(crate) fn full_key(&self, key: &str) -> String {
54        if self.path_prefix.is_empty() {
55            key.to_string()
56        } else {
57            format!("{}{}", self.path_prefix, key)
58        }
59    }
60
61    fn object_url(&self, key: &str) -> String {
62        format!("{}/{}", self.base_url.trim_end_matches('/'), key)
63    }
64}
65
66/// Generic HTTP REST storage backend.
67pub struct HttpBackend {
68    config: HttpConfig,
69    client: Client,
70}
71
72impl HttpBackend {
73    /// Create a new `HttpBackend`.
74    pub fn new(config: HttpConfig) -> Result<Self, String> {
75        if config.base_url.is_empty() {
76            return Err("HttpConfig.base_url must not be empty".to_string());
77        }
78        let client = crate::cloud_backends_common::build_tls_client()?;
79        Ok(Self { config, client })
80    }
81
82    fn add_auth(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
83        if let Some(ref auth) = self.config.auth_header {
84            builder.header("Authorization", auth)
85        } else {
86            builder
87        }
88    }
89
90    async fn http_put(&self, key: &str, data: Vec<u8>) -> Result<(), String> {
91        let url = self.config.object_url(key);
92        debug!("HTTP PUT {}", url);
93
94        let request = self
95            .client
96            .put(&url)
97            .header("Content-Type", "application/octet-stream")
98            .body(data);
99        let request = self.add_auth(request);
100
101        let response = request
102            .send()
103            .await
104            .map_err(|e| format!("HTTP PUT failed: {e}"))?;
105
106        if response.status().is_success() {
107            Ok(())
108        } else {
109            let status = response.status();
110            let body = response.text().await.unwrap_or_default();
111            Err(format!("HTTP PUT returned {status}: {body}"))
112        }
113    }
114
115    async fn http_get(&self, key: &str) -> Result<Vec<u8>, String> {
116        let url = self.config.object_url(key);
117        debug!("HTTP GET {}", url);
118
119        let request = self.client.get(&url);
120        let request = self.add_auth(request);
121
122        let response = request
123            .send()
124            .await
125            .map_err(|e| format!("HTTP GET failed: {e}"))?;
126
127        if response.status().is_success() {
128            let bytes = response
129                .bytes()
130                .await
131                .map_err(|e| format!("Failed to read HTTP GET body: {e}"))?;
132            Ok(bytes.to_vec())
133        } else if response.status() == StatusCode::NOT_FOUND {
134            Err(format!("HTTP object not found: {key}"))
135        } else {
136            let status = response.status();
137            let body = response.text().await.unwrap_or_default();
138            Err(format!("HTTP GET returned {status}: {body}"))
139        }
140    }
141
142    async fn http_head(&self, key: &str) -> Result<bool, String> {
143        let url = self.config.object_url(key);
144        debug!("HTTP HEAD {}", url);
145
146        let request = self.client.head(&url);
147        let request = self.add_auth(request);
148
149        let response = request
150            .send()
151            .await
152            .map_err(|e| format!("HTTP HEAD failed: {e}"))?;
153
154        match response.status() {
155            StatusCode::OK => Ok(true),
156            StatusCode::NOT_FOUND => Ok(false),
157            other => Err(format!("HTTP HEAD returned unexpected status: {other}")),
158        }
159    }
160
161    async fn http_delete(&self, key: &str) -> Result<(), String> {
162        let url = self.config.object_url(key);
163        debug!("HTTP DELETE {}", url);
164
165        let request = self.client.delete(&url);
166        let request = self.add_auth(request);
167
168        let response = request
169            .send()
170            .await
171            .map_err(|e| format!("HTTP DELETE failed: {e}"))?;
172
173        if response.status().is_success() || response.status() == StatusCode::NOT_FOUND {
174            Ok(())
175        } else {
176            let status = response.status();
177            let body = response.text().await.unwrap_or_default();
178            Err(format!("HTTP DELETE returned {status}: {body}"))
179        }
180    }
181
182    async fn http_list(&self, prefix: &str) -> Result<Vec<String>, String> {
183        let url = if prefix.is_empty() {
184            self.config.base_url.clone()
185        } else {
186            format!("{}?prefix={}", self.config.base_url, url_encode(prefix))
187        };
188        debug!("HTTP LIST {}", url);
189
190        let request = self.client.get(&url);
191        let request = self.add_auth(request);
192
193        let response = request
194            .send()
195            .await
196            .map_err(|e| format!("HTTP LIST failed: {e}"))?;
197
198        if response.status().is_success() {
199            let keys: Vec<String> = response
200                .json()
201                .await
202                .map_err(|e| format!("Failed to parse HTTP LIST response as JSON: {e}"))?;
203            Ok(keys)
204        } else if response.status() == StatusCode::NOT_FOUND {
205            Ok(vec![])
206        } else {
207            let status = response.status();
208            let body = response.text().await.unwrap_or_default();
209            Err(format!("HTTP LIST returned {status}: {body}"))
210        }
211    }
212}
213
214#[async_trait]
215impl CloudStorageBackend for HttpBackend {
216    async fn upload(&self, key: &str, data: Vec<u8>) -> std::result::Result<(), String> {
217        let full_key = self.config.full_key(key);
218        self.http_put(&full_key, data).await
219    }
220
221    async fn download(&self, key: &str) -> std::result::Result<Vec<u8>, String> {
222        let full_key = self.config.full_key(key);
223        self.http_get(&full_key).await
224    }
225
226    async fn exists(&self, key: &str) -> std::result::Result<bool, String> {
227        let full_key = self.config.full_key(key);
228        self.http_head(&full_key).await
229    }
230
231    async fn delete(&self, key: &str) -> std::result::Result<(), String> {
232        let full_key = self.config.full_key(key);
233        self.http_delete(&full_key).await
234    }
235
236    async fn list(&self, prefix: &str) -> std::result::Result<Vec<String>, String> {
237        let full_prefix = self.config.full_key(prefix);
238        self.http_list(&full_prefix).await
239    }
240}