biolib 1.3.117

BioLib client library and CLI for running applications on BioLib
Documentation
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
}