use bytes::Bytes;
use super::object_store::{CloudCredentials, CloudError, ObjectMetadata, ObjectUrl};
use super::retry::{RetryPolicy, RetryState};
pub struct HttpObjectStore {
client: reqwest::Client,
retry: RetryPolicy,
}
impl Default for HttpObjectStore {
fn default() -> Self {
Self::new()
}
}
impl HttpObjectStore {
pub fn new() -> Self {
HttpObjectStore {
client: reqwest::Client::new(),
retry: RetryPolicy::new(),
}
}
pub fn with_retry(retry: RetryPolicy) -> Self {
HttpObjectStore {
client: reqwest::Client::new(),
retry,
}
}
fn apply_credentials(
builder: reqwest::RequestBuilder,
credentials: &CloudCredentials,
url_str: &str,
) -> Result<reqwest::RequestBuilder, CloudError> {
match credentials {
CloudCredentials::Anonymous => Ok(builder),
CloudCredentials::Bearer { token } => {
Ok(builder.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}")))
}
CloudCredentials::AccessKey { access_key_id, .. } => {
Ok(builder.header("X-Access-Key", access_key_id.as_str()))
}
CloudCredentials::SasToken { .. } => {
Ok(builder)
}
CloudCredentials::AzureSharedKey { account_name, .. } => {
Err(CloudError::UnsupportedCredentials(format!(
"Azure Shared Key signing is not yet implemented for account '{account_name}'. \
Use SasToken, Bearer, or Anonymous credentials, or generate a presigned URL."
)))
}
CloudCredentials::ServiceAccountFile { path } => {
Err(CloudError::UnsupportedCredentials(format!(
"Service account JSON file credentials ('{path}') are not yet supported for \
direct HTTP transport. Use Bearer token credentials obtained from the GCS \
OAuth2 token endpoint, or generate a presigned URL via PresignedUrlGenerator."
)))
}
#[allow(unreachable_patterns)]
_ => Err(CloudError::UnsupportedCredentials(format!(
"unsupported credential type for URL: {url_str}"
))),
}
}
fn is_retryable_status(status: u16) -> bool {
matches!(status, 429 | 503)
}
fn build_url(url: &ObjectUrl) -> String {
use super::object_store::CloudScheme;
match &url.scheme {
CloudScheme::Http => {
if let Some(ep) = url.endpoint.as_deref() {
let base = ep.trim_end_matches('/');
format!("{base}/{}/{}", url.bucket, url.key)
} else {
let key = url.key.trim_start_matches('/');
format!("http://{}/{}", url.bucket, key)
}
}
_ => url.to_https_url(url.endpoint.as_deref()),
}
}
pub async fn get_range(
&self,
url: &ObjectUrl,
start: u64,
end_inclusive: u64,
credentials: &CloudCredentials,
) -> Result<Bytes, CloudError> {
let url_str = Self::build_url(url);
let range_header = format!("bytes={start}-{end_inclusive}");
let mut state = RetryState::new(self.retry.clone());
loop {
let builder = self
.client
.get(&url_str)
.header(reqwest::header::RANGE, &range_header);
let builder = Self::apply_credentials(builder, credentials, &url_str)?;
let response = builder
.send()
.await
.map_err(|e| CloudError::TransportError(e.to_string()))?;
let status = response.status().as_u16();
if response.status().is_success() {
let body = response
.bytes()
.await
.map_err(|e| CloudError::TransportError(e.to_string()))?;
return Ok(body);
}
if Self::is_retryable_status(status) {
if let Some(delay) = state.next_delay() {
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
continue;
}
return Err(CloudError::RetryExhausted {
attempts: self.retry.max_attempts,
});
}
return Err(CloudError::HttpError {
status,
url: url_str,
});
}
}
pub async fn get_object(
&self,
url: &ObjectUrl,
credentials: &CloudCredentials,
) -> Result<Bytes, CloudError> {
let url_str = Self::build_url(url);
let mut state = RetryState::new(self.retry.clone());
loop {
let builder = self.client.get(&url_str);
let builder = Self::apply_credentials(builder, credentials, &url_str)?;
let response = builder
.send()
.await
.map_err(|e| CloudError::TransportError(e.to_string()))?;
let status = response.status().as_u16();
if response.status().is_success() {
let body = response
.bytes()
.await
.map_err(|e| CloudError::TransportError(e.to_string()))?;
return Ok(body);
}
if Self::is_retryable_status(status) {
if let Some(delay) = state.next_delay() {
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
continue;
}
return Err(CloudError::RetryExhausted {
attempts: self.retry.max_attempts,
});
}
return Err(CloudError::HttpError {
status,
url: url_str,
});
}
}
pub async fn head(
&self,
url: &ObjectUrl,
credentials: &CloudCredentials,
) -> Result<ObjectMetadata, CloudError> {
let url_str = Self::build_url(url);
let mut state = RetryState::new(self.retry.clone());
loop {
let builder = self.client.head(&url_str);
let builder = Self::apply_credentials(builder, credentials, &url_str)?;
let response = builder
.send()
.await
.map_err(|e| CloudError::TransportError(e.to_string()))?;
let status = response.status().as_u16();
if response.status().is_success() {
let headers = response.headers();
let size: u64 = headers
.get(reqwest::header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
let etag: Option<String> = headers
.get(reqwest::header::ETAG)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned());
let content_type: Option<String> = headers
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned());
let last_modified: Option<u64> =
parse_http_date_to_unix(headers.get(reqwest::header::LAST_MODIFIED));
return Ok(ObjectMetadata {
url: url.clone(),
size,
content_type,
etag,
last_modified,
user_metadata: std::collections::HashMap::new(),
});
}
if Self::is_retryable_status(status) {
if let Some(delay) = state.next_delay() {
if !delay.is_zero() {
tokio::time::sleep(delay).await;
}
continue;
}
return Err(CloudError::RetryExhausted {
attempts: self.retry.max_attempts,
});
}
return Err(CloudError::HttpError {
status,
url: url_str,
});
}
}
}
fn parse_http_date_to_unix(header: Option<&reqwest::header::HeaderValue>) -> Option<u64> {
let raw = header?.to_str().ok()?;
parse_rfc1123_date(raw)
}
fn parse_rfc1123_date(s: &str) -> Option<u64> {
let s = if let Some(idx) = s.find(',') {
s[idx + 1..].trim_start()
} else {
s.trim_start()
};
let parts: Vec<&str> = s.splitn(5, ' ').collect();
if parts.len() < 4 {
return None;
}
let day: u64 = parts[0].parse().ok()?;
let month: u64 = month_name_to_number(parts[1])?;
let year: u64 = parts[2].parse().ok()?;
let time_parts: Vec<&str> = parts[3].splitn(3, ':').collect();
if time_parts.len() < 3 {
return None;
}
let hour: u64 = time_parts[0].parse().ok()?;
let minute: u64 = time_parts[1].parse().ok()?;
let second: u64 = time_parts[2].parse().ok()?;
if year < 1970 {
return None;
}
let days = days_from_civil(year, month, day)?;
let unix_secs = days
.checked_mul(86_400)?
.checked_add(hour.checked_mul(3600)?)?
.checked_add(minute.checked_mul(60)?)?
.checked_add(second)?;
Some(unix_secs)
}
fn month_name_to_number(name: &str) -> Option<u64> {
match name {
"Jan" => Some(1),
"Feb" => Some(2),
"Mar" => Some(3),
"Apr" => Some(4),
"May" => Some(5),
"Jun" => Some(6),
"Jul" => Some(7),
"Aug" => Some(8),
"Sep" => Some(9),
"Oct" => Some(10),
"Nov" => Some(11),
"Dec" => Some(12),
_ => None,
}
}
fn days_from_civil(y: u64, m: u64, d: u64) -> Option<u64> {
let (y_adj, m_adj) = if m <= 2 {
(y.checked_sub(1)?, m + 9)
} else {
(y, m - 3)
};
let era = y_adj / 400;
let yoe = y_adj % 400; let doy = (153 * m_adj + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; let jdn = era * 146_097 + doe;
const UNIX_EPOCH_JDN: u64 = 719_468;
jdn.checked_sub(UNIX_EPOCH_JDN)
}