use bytes::Bytes;
use std::collections::HashMap;
use std::time::Duration;
#[cfg(feature = "http")]
use reqwest::Client;
use crate::auth::Credentials;
use crate::error::{CloudError, HttpError, Result};
use crate::retry::{RetryConfig, RetryExecutor};
use super::CloudStorageBackend;
#[derive(Debug, Clone)]
pub enum HttpAuth {
None,
Basic {
username: String,
password: String,
},
Bearer {
token: String,
},
ApiKey {
header_name: String,
key: String,
},
Custom {
headers: HashMap<String, String>,
},
}
#[derive(Debug, Clone)]
pub struct HttpBackend {
pub base_url: String,
pub auth: HttpAuth,
pub timeout: Duration,
pub retry_config: RetryConfig,
pub credentials: Option<Credentials>,
pub headers: HashMap<String, String>,
pub follow_redirects: bool,
pub max_redirects: usize,
}
impl HttpBackend {
#[must_use]
pub fn new(base_url: impl Into<String>) -> Self {
let mut url = base_url.into();
if url.ends_with('/') {
url.pop();
}
Self {
base_url: url,
auth: HttpAuth::None,
timeout: Duration::from_secs(300),
retry_config: RetryConfig::default(),
credentials: None,
headers: HashMap::new(),
follow_redirects: true,
max_redirects: 10,
}
}
#[must_use]
pub fn with_auth(mut self, auth: HttpAuth) -> Self {
self.auth = auth;
self
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
self.retry_config = config;
self
}
#[must_use]
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.insert(name.into(), value.into());
self
}
#[must_use]
pub fn with_follow_redirects(mut self, follow: bool) -> Self {
self.follow_redirects = follow;
self
}
fn full_url(&self, key: &str) -> String {
format!("{}/{}", self.base_url, key)
}
#[cfg(feature = "http")]
fn create_client(&self) -> Result<Client> {
let mut client_builder =
Client::builder()
.timeout(self.timeout)
.redirect(if self.follow_redirects {
reqwest::redirect::Policy::limited(self.max_redirects)
} else {
reqwest::redirect::Policy::none()
});
let mut headers = reqwest::header::HeaderMap::new();
match &self.auth {
HttpAuth::None => {}
HttpAuth::Basic { username, password } => {
let auth_value = format!("{}:{}", username, password);
let encoded = base64_encode(auth_value.as_bytes());
let header_value = format!("Basic {}", encoded);
headers.insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_str(&header_value).map_err(|e| {
CloudError::Http(HttpError::InvalidHeader {
name: "Authorization".to_string(),
message: format!("{e}"),
})
})?,
);
}
HttpAuth::Bearer { token } => {
let header_value = format!("Bearer {}", token);
headers.insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_str(&header_value).map_err(|e| {
CloudError::Http(HttpError::InvalidHeader {
name: "Authorization".to_string(),
message: format!("{e}"),
})
})?,
);
}
HttpAuth::ApiKey { header_name, key } => {
let header_name_parsed = reqwest::header::HeaderName::from_bytes(
header_name.as_bytes(),
)
.map_err(|e| {
CloudError::Http(HttpError::InvalidHeader {
name: header_name.clone(),
message: format!("{e}"),
})
})?;
headers.insert(
header_name_parsed,
reqwest::header::HeaderValue::from_str(key).map_err(|e| {
CloudError::Http(HttpError::InvalidHeader {
name: header_name.clone(),
message: format!("{e}"),
})
})?,
);
}
HttpAuth::Custom {
headers: custom_headers,
} => {
for (name, value) in custom_headers {
let header_name = reqwest::header::HeaderName::from_bytes(name.as_bytes())
.map_err(|e| {
CloudError::Http(HttpError::InvalidHeader {
name: name.clone(),
message: format!("{e}"),
})
})?;
headers.insert(
header_name,
reqwest::header::HeaderValue::from_str(value).map_err(|e| {
CloudError::Http(HttpError::InvalidHeader {
name: name.clone(),
message: format!("{e}"),
})
})?,
);
}
}
}
for (name, value) in &self.headers {
let header_name =
reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
CloudError::Http(HttpError::InvalidHeader {
name: name.clone(),
message: format!("{e}"),
})
})?;
headers.insert(
header_name,
reqwest::header::HeaderValue::from_str(value).map_err(|e| {
CloudError::Http(HttpError::InvalidHeader {
name: name.clone(),
message: format!("{e}"),
})
})?,
);
}
client_builder = client_builder.default_headers(headers);
client_builder.build().map_err(|e| {
CloudError::Http(HttpError::RequestBuild {
message: format!("{e}"),
})
})
}
}
fn base64_encode(input: &[u8]) -> String {
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut result = String::new();
for chunk in input.chunks(3) {
let b1 = chunk[0];
let b2 = chunk.get(1).copied().unwrap_or(0);
let b3 = chunk.get(2).copied().unwrap_or(0);
let n = ((b1 as u32) << 16) | ((b2 as u32) << 8) | (b3 as u32);
result.push(CHARS[((n >> 18) & 63) as usize] as char);
result.push(CHARS[((n >> 12) & 63) as usize] as char);
result.push(if chunk.len() > 1 {
CHARS[((n >> 6) & 63) as usize] as char
} else {
'='
});
result.push(if chunk.len() > 2 {
CHARS[(n & 63) as usize] as char
} else {
'='
});
}
result
}
#[cfg(all(feature = "http", feature = "async"))]
#[async_trait::async_trait]
impl CloudStorageBackend for HttpBackend {
async fn get(&self, key: &str) -> Result<Bytes> {
let mut executor = RetryExecutor::new(self.retry_config.clone());
executor
.execute(|| async {
let client = self.create_client()?;
let url = self.full_url(key);
let response = client.get(&url).send().await.map_err(|e| {
CloudError::Http(HttpError::Network {
message: format!("HTTP GET failed for '{url}': {e}"),
})
})?;
let status = response.status();
if !status.is_success() {
return Err(CloudError::Http(HttpError::Status {
status: status.as_u16(),
message: format!("HTTP GET failed for '{url}'"),
}));
}
let bytes = response.bytes().await.map_err(|e| {
CloudError::Http(HttpError::ResponseParse {
message: format!("Failed to read response body: {e}"),
})
})?;
Ok(bytes)
})
.await
}
async fn put(&self, _key: &str, _data: &[u8]) -> Result<()> {
Err(CloudError::NotSupported {
operation: "HTTP backend is read-only".to_string(),
})
}
async fn delete(&self, _key: &str) -> Result<()> {
Err(CloudError::NotSupported {
operation: "HTTP backend is read-only".to_string(),
})
}
async fn exists(&self, key: &str) -> Result<bool> {
let client = self.create_client()?;
let url = self.full_url(key);
match client.head(&url).send().await {
Ok(response) => Ok(response.status().is_success()),
Err(_) => Ok(false),
}
}
async fn list_prefix(&self, _prefix: &str) -> Result<Vec<String>> {
Err(CloudError::NotSupported {
operation: "HTTP backend does not support listing".to_string(),
})
}
fn is_readonly(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_http_backend_new() {
let backend = HttpBackend::new("https://example.com/data");
assert_eq!(backend.base_url, "https://example.com/data");
}
#[test]
fn test_http_backend_builder() {
let backend = HttpBackend::new("https://example.com")
.with_auth(HttpAuth::Bearer {
token: "token123".to_string(),
})
.with_header("User-Agent", "OxiGDAL/1.0")
.with_timeout(Duration::from_secs(600))
.with_follow_redirects(false);
assert!(matches!(backend.auth, HttpAuth::Bearer { .. }));
assert_eq!(backend.headers.len(), 1);
assert_eq!(backend.timeout, Duration::from_secs(600));
assert!(!backend.follow_redirects);
}
#[test]
fn test_http_backend_full_url() {
let backend = HttpBackend::new("https://example.com/data");
assert_eq!(
backend.full_url("file.txt"),
"https://example.com/data/file.txt"
);
}
#[test]
fn test_base64_encode() {
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
assert_eq!(base64_encode(b"world"), "d29ybGQ=");
assert_eq!(base64_encode(b"user:pass"), "dXNlcjpwYXNz");
}
}