biolib 1.3.279

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, Instant};

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 {
    agent: ureq::Agent,
}

impl HttpClient {
    pub fn shared() -> Self {
        SHARED_CLIENT.clone()
    }

    fn from_config(_config: &crate::Config) -> Self {
        #[allow(unused_mut)]
        let mut builder = ureq::Agent::config_builder()
            .timeout_global(Some(Duration::from_secs(180)))
            .http_status_as_error(false);

        #[cfg(any(feature = "native-tls", feature = "rustls"))]
        {
            #[allow(unused_mut)]
            let mut tls_builder = ureq::tls::TlsConfig::builder();

            #[cfg(all(feature = "native-tls", not(feature = "rustls")))]
            {
                tls_builder = tls_builder.provider(ureq::tls::TlsProvider::NativeTls);
            }

            if let Some(ref path) = _config.ca_bundle {
                match std::fs::read(path) {
                    Ok(pem_data) => {
                        let certs: Vec<ureq::tls::Certificate<'static>> =
                            ureq::tls::parse_pem(&pem_data)
                                .filter_map(|item| match item {
                                    Ok(ureq::tls::PemItem::Certificate(cert)) => Some(cert),
                                    _ => None,
                                })
                                .collect();
                        if !certs.is_empty() {
                            tls_builder = tls_builder
                                .root_certs(ureq::tls::RootCerts::new_with_certs(&certs));
                        }
                    }
                    Err(err) => {
                        crate::logging::error(&format!(
                            "Failed to read CA bundle from {path}: {err}"
                        ));
                    }
                }
            }

            builder = builder.tls_config(tls_builder.build());
        }

        let agent = builder.build().new_agent();

        Self { agent }
    }

    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 has_content_type = headers
            .map(|h| h.contains_key("content-type"))
            .unwrap_or(false);

        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 = ureq::http::Request::builder().method(method).uri(url);

            if let Some(hdrs) = headers {
                for (key, value) in hdrs {
                    builder = builder.header(key.as_str(), value.as_str());
                }
            }

            if data.is_some() && !has_content_type {
                builder = builder
                    .header("content-type", "application/json")
                    .header("accept", "application/json");
            }

            let send_start = Instant::now();
            let result = if let Some(body) = data {
                let request = builder
                    .body(body.to_vec())
                    .map_err(|err| BioLibError::General(err.to_string()))?;
                self.run_request(request, timeout_secs)
            } else {
                let request = builder
                    .body(())
                    .map_err(|err| BioLibError::General(err.to_string()))?;
                self.run_request(request, timeout_secs)
            };

            match result {
                Ok(mut response) => {
                    let status = response.status().as_u16();
                    crate::logging::debug(&format!(
                        "{method} {url} -> {status} ({}ms)",
                        send_start.elapsed().as_millis()
                    ));
                    if status == 429 || status == 502 || status == 503 || status == 504 {
                        let _ = response.body_mut().read_to_string();
                        crate::logging::warning(&format!(
                            "Retryable HTTP {status} from {method} {url}"
                        ));
                        last_error = Some(BioLibError::Http {
                            status,
                            message: format!("HTTP {method} request failed with status {status}"),
                        });
                        continue;
                    }
                    if status >= 400 {
                        let body_text = response.body_mut().read_to_string().unwrap_or_default();
                        crate::logging::debug(&format!(
                            "HTTP {status} error from {method} {url}: {body_text}"
                        ));
                        return Err(BioLibError::Http {
                            status,
                            message: body_text,
                        });
                    }
                    return HttpResponse::from_ureq_response(response);
                }
                Err(err) => {
                    let elapsed = send_start.elapsed();
                    crate::logging::warning(&format!(
                        "Request failed after {}ms: {method} {url}: {err}",
                        elapsed.as_millis()
                    ));
                    last_error = Some(BioLibError::Request(err.to_string()));
                    continue;
                }
            }
        }

        Err(last_error.unwrap_or_else(|| {
            BioLibError::General(format!("Request failed after {retries} retries"))
        }))
    }

    fn run_request<B: ureq::AsSendBody>(
        &self,
        request: ureq::http::Request<B>,
        timeout_secs: Option<u64>,
    ) -> Result<ureq::http::Response<ureq::Body>, ureq::Error> {
        if let Some(secs) = timeout_secs {
            let request = self
                .agent
                .configure_request(request)
                .timeout_global(Some(Duration::from_secs(secs)))
                .build();
            self.agent.run(request)
        } else {
            self.agent.run(request)
        }
    }

    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_ureq_response(mut response: ureq::http::Response<ureq::Body>) -> 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
            .body_mut()
            .with_config()
            .limit(512 * 1024 * 1024)
            .read_to_vec()
            .map_err(|err| BioLibError::General(err.to_string()))?;
        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)
    }
}