Skip to main content

oxigdal_cloud/backends/
http.rs

1//! Enhanced HTTP storage backend with authentication and retry logic
2//!
3//! This module provides read-only HTTP/HTTPS storage with authentication support,
4//! custom headers, and comprehensive retry logic.
5
6use bytes::Bytes;
7use std::collections::HashMap;
8use std::time::Duration;
9
10#[cfg(feature = "http")]
11use reqwest::Client;
12
13use crate::auth::Credentials;
14use crate::error::{CloudError, HttpError, Result};
15use crate::retry::{RetryConfig, RetryExecutor};
16
17use super::CloudStorageBackend;
18
19/// HTTP authentication method
20#[derive(Debug, Clone)]
21pub enum HttpAuth {
22    /// No authentication
23    None,
24    /// Basic authentication
25    Basic {
26        /// Username
27        username: String,
28        /// Password
29        password: String,
30    },
31    /// Bearer token
32    Bearer {
33        /// Token
34        token: String,
35    },
36    /// API key (custom header)
37    ApiKey {
38        /// Header name
39        header_name: String,
40        /// API key value
41        key: String,
42    },
43    /// Custom headers
44    Custom {
45        /// Headers
46        headers: HashMap<String, String>,
47    },
48}
49
50/// HTTP storage backend
51#[derive(Debug, Clone)]
52pub struct HttpBackend {
53    /// Base URL
54    pub base_url: String,
55    /// Authentication method
56    pub auth: HttpAuth,
57    /// Request timeout
58    pub timeout: Duration,
59    /// Retry configuration
60    pub retry_config: RetryConfig,
61    /// Credentials
62    pub credentials: Option<Credentials>,
63    /// Custom headers
64    pub headers: HashMap<String, String>,
65    /// Follow redirects
66    pub follow_redirects: bool,
67    /// Maximum redirects
68    pub max_redirects: usize,
69}
70
71impl HttpBackend {
72    /// Creates a new HTTP backend
73    ///
74    /// # Arguments
75    /// * `base_url` - The base URL for requests
76    #[must_use]
77    pub fn new(base_url: impl Into<String>) -> Self {
78        let mut url = base_url.into();
79        // Ensure URL doesn't end with slash
80        if url.ends_with('/') {
81            url.pop();
82        }
83
84        Self {
85            base_url: url,
86            auth: HttpAuth::None,
87            timeout: Duration::from_secs(300),
88            retry_config: RetryConfig::default(),
89            credentials: None,
90            headers: HashMap::new(),
91            follow_redirects: true,
92            max_redirects: 10,
93        }
94    }
95
96    /// Sets authentication method
97    #[must_use]
98    pub fn with_auth(mut self, auth: HttpAuth) -> Self {
99        self.auth = auth;
100        self
101    }
102
103    /// Sets request timeout
104    #[must_use]
105    pub fn with_timeout(mut self, timeout: Duration) -> Self {
106        self.timeout = timeout;
107        self
108    }
109
110    /// Sets retry configuration
111    #[must_use]
112    pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
113        self.retry_config = config;
114        self
115    }
116
117    /// Adds a custom header
118    #[must_use]
119    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
120        self.headers.insert(name.into(), value.into());
121        self
122    }
123
124    /// Sets whether to follow redirects
125    #[must_use]
126    pub fn with_follow_redirects(mut self, follow: bool) -> Self {
127        self.follow_redirects = follow;
128        self
129    }
130
131    fn full_url(&self, key: &str) -> String {
132        format!("{}/{}", self.base_url, key)
133    }
134
135    #[cfg(feature = "http")]
136    fn create_client(&self) -> Result<Client> {
137        let mut client_builder =
138            Client::builder()
139                .timeout(self.timeout)
140                .redirect(if self.follow_redirects {
141                    reqwest::redirect::Policy::limited(self.max_redirects)
142                } else {
143                    reqwest::redirect::Policy::none()
144                });
145
146        // Build default headers
147        let mut headers = reqwest::header::HeaderMap::new();
148
149        // Add authentication
150        match &self.auth {
151            HttpAuth::None => {}
152            HttpAuth::Basic { username, password } => {
153                let auth_value = format!("{}:{}", username, password);
154                let encoded = base64_encode(auth_value.as_bytes());
155                let header_value = format!("Basic {}", encoded);
156
157                headers.insert(
158                    reqwest::header::AUTHORIZATION,
159                    reqwest::header::HeaderValue::from_str(&header_value).map_err(|e| {
160                        CloudError::Http(HttpError::InvalidHeader {
161                            name: "Authorization".to_string(),
162                            message: format!("{e}"),
163                        })
164                    })?,
165                );
166            }
167            HttpAuth::Bearer { token } => {
168                let header_value = format!("Bearer {}", token);
169
170                headers.insert(
171                    reqwest::header::AUTHORIZATION,
172                    reqwest::header::HeaderValue::from_str(&header_value).map_err(|e| {
173                        CloudError::Http(HttpError::InvalidHeader {
174                            name: "Authorization".to_string(),
175                            message: format!("{e}"),
176                        })
177                    })?,
178                );
179            }
180            HttpAuth::ApiKey { header_name, key } => {
181                let header_name_parsed = reqwest::header::HeaderName::from_bytes(
182                    header_name.as_bytes(),
183                )
184                .map_err(|e| {
185                    CloudError::Http(HttpError::InvalidHeader {
186                        name: header_name.clone(),
187                        message: format!("{e}"),
188                    })
189                })?;
190
191                headers.insert(
192                    header_name_parsed,
193                    reqwest::header::HeaderValue::from_str(key).map_err(|e| {
194                        CloudError::Http(HttpError::InvalidHeader {
195                            name: header_name.clone(),
196                            message: format!("{e}"),
197                        })
198                    })?,
199                );
200            }
201            HttpAuth::Custom {
202                headers: custom_headers,
203            } => {
204                for (name, value) in custom_headers {
205                    let header_name = reqwest::header::HeaderName::from_bytes(name.as_bytes())
206                        .map_err(|e| {
207                            CloudError::Http(HttpError::InvalidHeader {
208                                name: name.clone(),
209                                message: format!("{e}"),
210                            })
211                        })?;
212
213                    headers.insert(
214                        header_name,
215                        reqwest::header::HeaderValue::from_str(value).map_err(|e| {
216                            CloudError::Http(HttpError::InvalidHeader {
217                                name: name.clone(),
218                                message: format!("{e}"),
219                            })
220                        })?,
221                    );
222                }
223            }
224        }
225
226        // Add custom headers
227        for (name, value) in &self.headers {
228            let header_name =
229                reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
230                    CloudError::Http(HttpError::InvalidHeader {
231                        name: name.clone(),
232                        message: format!("{e}"),
233                    })
234                })?;
235
236            headers.insert(
237                header_name,
238                reqwest::header::HeaderValue::from_str(value).map_err(|e| {
239                    CloudError::Http(HttpError::InvalidHeader {
240                        name: name.clone(),
241                        message: format!("{e}"),
242                    })
243                })?,
244            );
245        }
246
247        client_builder = client_builder.default_headers(headers);
248
249        client_builder.build().map_err(|e| {
250            CloudError::Http(HttpError::RequestBuild {
251                message: format!("{e}"),
252            })
253        })
254    }
255}
256
257/// Simple base64 encoding
258fn base64_encode(input: &[u8]) -> String {
259    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
260    let mut result = String::new();
261
262    for chunk in input.chunks(3) {
263        let b1 = chunk[0];
264        let b2 = chunk.get(1).copied().unwrap_or(0);
265        let b3 = chunk.get(2).copied().unwrap_or(0);
266
267        let n = ((b1 as u32) << 16) | ((b2 as u32) << 8) | (b3 as u32);
268
269        result.push(CHARS[((n >> 18) & 63) as usize] as char);
270        result.push(CHARS[((n >> 12) & 63) as usize] as char);
271        result.push(if chunk.len() > 1 {
272            CHARS[((n >> 6) & 63) as usize] as char
273        } else {
274            '='
275        });
276        result.push(if chunk.len() > 2 {
277            CHARS[(n & 63) as usize] as char
278        } else {
279            '='
280        });
281    }
282
283    result
284}
285
286#[cfg(all(feature = "http", feature = "async"))]
287#[async_trait::async_trait]
288impl CloudStorageBackend for HttpBackend {
289    async fn get(&self, key: &str) -> Result<Bytes> {
290        let mut executor = RetryExecutor::new(self.retry_config.clone());
291
292        executor
293            .execute(|| async {
294                let client = self.create_client()?;
295                let url = self.full_url(key);
296
297                let response = client.get(&url).send().await.map_err(|e| {
298                    CloudError::Http(HttpError::Network {
299                        message: format!("HTTP GET failed for '{url}': {e}"),
300                    })
301                })?;
302
303                let status = response.status();
304                if !status.is_success() {
305                    return Err(CloudError::Http(HttpError::Status {
306                        status: status.as_u16(),
307                        message: format!("HTTP GET failed for '{url}'"),
308                    }));
309                }
310
311                let bytes = response.bytes().await.map_err(|e| {
312                    CloudError::Http(HttpError::ResponseParse {
313                        message: format!("Failed to read response body: {e}"),
314                    })
315                })?;
316
317                Ok(bytes)
318            })
319            .await
320    }
321
322    async fn put(&self, _key: &str, _data: &[u8]) -> Result<()> {
323        // HTTP backend is typically read-only
324        Err(CloudError::NotSupported {
325            operation: "HTTP backend is read-only".to_string(),
326        })
327    }
328
329    async fn delete(&self, _key: &str) -> Result<()> {
330        // HTTP backend is typically read-only
331        Err(CloudError::NotSupported {
332            operation: "HTTP backend is read-only".to_string(),
333        })
334    }
335
336    async fn exists(&self, key: &str) -> Result<bool> {
337        let client = self.create_client()?;
338        let url = self.full_url(key);
339
340        match client.head(&url).send().await {
341            Ok(response) => Ok(response.status().is_success()),
342            Err(_) => Ok(false),
343        }
344    }
345
346    async fn list_prefix(&self, _prefix: &str) -> Result<Vec<String>> {
347        // HTTP doesn't support listing
348        Err(CloudError::NotSupported {
349            operation: "HTTP backend does not support listing".to_string(),
350        })
351    }
352
353    fn is_readonly(&self) -> bool {
354        true
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn test_http_backend_new() {
364        let backend = HttpBackend::new("https://example.com/data");
365        assert_eq!(backend.base_url, "https://example.com/data");
366    }
367
368    #[test]
369    fn test_http_backend_builder() {
370        let backend = HttpBackend::new("https://example.com")
371            .with_auth(HttpAuth::Bearer {
372                token: "token123".to_string(),
373            })
374            .with_header("User-Agent", "OxiGDAL/1.0")
375            .with_timeout(Duration::from_secs(600))
376            .with_follow_redirects(false);
377
378        assert!(matches!(backend.auth, HttpAuth::Bearer { .. }));
379        assert_eq!(backend.headers.len(), 1);
380        assert_eq!(backend.timeout, Duration::from_secs(600));
381        assert!(!backend.follow_redirects);
382    }
383
384    #[test]
385    fn test_http_backend_full_url() {
386        let backend = HttpBackend::new("https://example.com/data");
387        assert_eq!(
388            backend.full_url("file.txt"),
389            "https://example.com/data/file.txt"
390        );
391    }
392
393    #[test]
394    fn test_base64_encode() {
395        assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
396        assert_eq!(base64_encode(b"world"), "d29ybGQ=");
397        assert_eq!(base64_encode(b"user:pass"), "dXNlcjpwYXNz");
398    }
399}