use std::collections::HashMap;
use std::sync::LazyLock;
use std::thread;
use std::time::Duration;
use reqwest::blocking::{Client, Response};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
#[cfg(any(feature = "native-tls", feature = "rustls"))]
use reqwest::tls::Certificate;
use crate::error::BioLibError;
static SHARED_CLIENT: LazyLock<HttpClient> = LazyLock::new(|| {
let config = crate::Config::load();
HttpClient::from_config(&config)
});
#[derive(Clone)]
pub struct HttpClient {
client: Client,
}
impl HttpClient {
pub fn shared() -> Self {
SHARED_CLIENT.clone()
}
fn from_config(_config: &crate::Config) -> Self {
#[allow(unused_mut)]
let mut builder = Client::builder().timeout(Duration::from_secs(180));
#[cfg(any(feature = "native-tls", feature = "rustls"))]
if let Some(ref path) = _config.ca_bundle {
match std::fs::read(path) {
Ok(pem_data) => {
for cert in parse_pem_certificates(&pem_data) {
builder = builder.add_root_certificate(cert);
}
}
Err(err) => {
crate::logging::error(&format!("Failed to read CA bundle from {path}: {err}"));
}
}
}
let client = builder.build().expect("Failed to create HTTP client");
Self { client }
}
pub fn request(
&self,
method: &str,
url: &str,
data: Option<&[u8]>,
headers: Option<&HashMap<String, String>>,
retries: u32,
timeout_secs: Option<u64>,
) -> crate::Result<HttpResponse> {
let mut header_map = HeaderMap::new();
if let Some(hdrs) = headers {
for (key, value) in hdrs {
if let (Ok(name), Ok(val)) = (
HeaderName::from_bytes(key.as_bytes()),
HeaderValue::from_str(value),
) {
header_map.insert(name, val);
}
}
}
if data.is_some() && !header_map.contains_key("content-type") {
header_map.insert("content-type", HeaderValue::from_static("application/json"));
header_map.insert("accept", HeaderValue::from_static("application/json"));
}
let timeout = Duration::from_secs(timeout_secs.unwrap_or(180));
let mut last_error: Option<BioLibError> = None;
crate::logging::debug(&format!("{method} {url}"));
for retry_count in 0..=retries {
if retry_count > 0 {
crate::logging::debug(&format!("Retrying {method} {url} (attempt {retry_count})"));
thread::sleep(Duration::from_secs(5 * retry_count as u64));
}
let mut builder = match method {
"GET" => self.client.get(url),
"POST" => self.client.post(url),
"PATCH" => self.client.patch(url),
"PUT" => self.client.put(url),
"DELETE" => self.client.delete(url),
_ => {
return Err(BioLibError::General(format!(
"Unsupported method: {method}"
)))
}
};
builder = builder.headers(header_map.clone()).timeout(timeout);
if let Some(body) = data {
builder = builder.body(body.to_vec());
}
match builder.send() {
Ok(response) => {
let status = response.status().as_u16();
if status == 429 || status == 502 || status == 503 || status == 504 {
last_error = Some(BioLibError::Http {
status,
message: format!("HTTP {method} request failed with status {status}"),
});
continue;
}
if status >= 400 {
let body_text = response.text().unwrap_or_default();
return Err(BioLibError::Http {
status,
message: body_text,
});
}
return HttpResponse::from_response(response);
}
Err(err) => {
if err.is_timeout() {
crate::logging::debug(&format!("Request timed out: {method} {url}"));
last_error = Some(BioLibError::Request(err));
continue;
}
return Err(BioLibError::Request(err));
}
}
}
Err(last_error.unwrap_or_else(|| {
BioLibError::General(format!("Request failed after {retries} retries"))
}))
}
pub fn get(
&self,
url: &str,
headers: Option<&HashMap<String, String>>,
) -> crate::Result<HttpResponse> {
self.request("GET", url, None, headers, 10, None)
}
pub fn get_with_range(&self, url: &str, start: u64, end: u64) -> crate::Result<HttpResponse> {
let mut headers = HashMap::new();
headers.insert("range".to_string(), format!("bytes={start}-{end}"));
self.request("GET", url, None, Some(&headers), 5, None)
}
}
pub struct HttpResponse {
pub status_code: u16,
pub body: Vec<u8>,
pub headers: HashMap<String, String>,
}
impl HttpResponse {
fn from_response(response: Response) -> crate::Result<Self> {
let status_code = response.status().as_u16();
let mut headers = HashMap::new();
for (key, value) in response.headers() {
if let Ok(v) = value.to_str() {
headers.insert(key.to_string(), v.to_string());
}
}
let body = response.bytes()?.to_vec();
Ok(Self {
status_code,
body,
headers,
})
}
pub fn text(&self) -> String {
String::from_utf8_lossy(&self.body).to_string()
}
pub fn json<T: serde::de::DeserializeOwned>(&self) -> crate::Result<T> {
serde_json::from_slice(&self.body).map_err(BioLibError::Json)
}
}
#[cfg(any(feature = "native-tls", feature = "rustls"))]
fn parse_pem_certificates(pem_data: &[u8]) -> Vec<Certificate> {
let pem_str = String::from_utf8_lossy(pem_data);
let mut certs = Vec::new();
let mut current = String::new();
let mut in_cert = false;
for line in pem_str.lines() {
if line.contains("BEGIN CERTIFICATE") {
in_cert = true;
current.clear();
current.push_str(line);
current.push('\n');
} else if line.contains("END CERTIFICATE") {
current.push_str(line);
current.push('\n');
if let Ok(cert) = Certificate::from_pem(current.as_bytes()) {
certs.push(cert);
}
in_cert = false;
current.clear();
} else if in_cert {
current.push_str(line);
current.push('\n');
}
}
certs
}