alopex-cli 0.6.0

Command-line interface for Alopex DB
use std::time::Duration;

use reqwest::{Client, Method, RequestBuilder, Response, Url};
use serde::de::DeserializeOwned;
use serde::Serialize;
use tokio::time::sleep;

use crate::client::auth::{AuthConfig, AuthError};
use crate::profile::config::ServerConfig;

#[derive(thiserror::Error, Debug)]
pub enum ClientError {
    #[error("invalid server url: {0}")]
    InvalidUrl(String),
    #[error("failed to build HTTP client: {0}")]
    Build(String),
    #[error("authentication error: {0}")]
    Auth(#[from] AuthError),
    #[error("request failed after {retries} retries: {source}")]
    Request {
        retries: usize,
        #[source]
        source: reqwest::Error,
    },
    #[error("unexpected response status {status}: {body}")]
    HttpStatus {
        status: reqwest::StatusCode,
        body: String,
    },
}

pub type ClientResult<T> = Result<T, ClientError>;

#[derive(Debug, Clone)]
struct RetryPolicy {
    delays: Vec<Duration>,
}

impl RetryPolicy {
    fn default() -> Self {
        Self {
            delays: vec![
                Duration::from_secs(1),
                Duration::from_secs(2),
                Duration::from_secs(4),
            ],
        }
    }

    fn attempts(&self) -> usize {
        self.delays.len() + 1
    }
}

pub struct HttpClient {
    base_url: Url,
    auth: AuthConfig,
    client: Client,
    retry_policy: RetryPolicy,
}

impl HttpClient {
    pub fn new(config: &ServerConfig) -> ClientResult<Self> {
        let base_url =
            Url::parse(&config.url).map_err(|err| ClientError::InvalidUrl(err.to_string()))?;
        validate_base_url(&base_url, config.insecure)?;
        let auth = AuthConfig::from_server_config(config)?;
        let builder = Client::builder()
            .pool_idle_timeout(Duration::from_secs(90))
            .pool_max_idle_per_host(8);
        let builder = auth.apply_to_builder(builder)?;
        let client = builder
            .build()
            .map_err(|err| ClientError::Build(err.to_string()))?;

        Ok(Self {
            base_url,
            auth,
            client,
            retry_policy: RetryPolicy::default(),
        })
    }

    #[allow(dead_code)]
    pub fn new_with_client(config: &ServerConfig, client: Client) -> ClientResult<Self> {
        let base_url =
            Url::parse(&config.url).map_err(|err| ClientError::InvalidUrl(err.to_string()))?;
        validate_base_url(&base_url, config.insecure)?;
        let auth = AuthConfig::from_server_config(config)?;

        Ok(Self {
            base_url,
            auth,
            client,
            retry_policy: RetryPolicy::default(),
        })
    }

    fn request(&self, method: Method, path: &str) -> ClientResult<RequestBuilder> {
        let url = self
            .base_url
            .join(path)
            .map_err(|err| ClientError::InvalidUrl(err.to_string()))?;
        let request = self.client.request(method, url);
        Ok(self.auth.apply_to_request(request)?)
    }

    async fn send_with_retry<F>(&self, mut build: F) -> ClientResult<Response>
    where
        F: FnMut() -> ClientResult<RequestBuilder>,
    {
        let mut last_err: Option<reqwest::Error> = None;
        for (attempt, delay) in self.retry_policy.delays.iter().enumerate() {
            match build()?.send().await {
                Ok(response) => return Ok(response),
                Err(err) => {
                    last_err = Some(err);
                    sleep(*delay).await;
                    tracing::warn!(
                        attempt = attempt + 1,
                        "HTTP request failed, retrying after {:?}",
                        delay
                    );
                }
            }
        }
        match build()?.send().await {
            Ok(response) => Ok(response),
            Err(err) => Err(ClientError::Request {
                retries: self.retry_policy.attempts(),
                source: last_err.unwrap_or(err),
            }),
        }
    }

    async fn send_and_check<F>(&self, build: F) -> ClientResult<Response>
    where
        F: FnMut() -> ClientResult<RequestBuilder>,
    {
        let response = self.send_with_retry(build).await?;
        if response.status().is_success() {
            Ok(response)
        } else {
            let status = response.status();
            let body = response.text().await.unwrap_or_default();
            Err(ClientError::HttpStatus { status, body })
        }
    }

    #[allow(dead_code)]
    pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> ClientResult<T> {
        let response = self
            .send_and_check(|| self.request(Method::GET, path))
            .await?;
        response
            .json::<T>()
            .await
            .map_err(|err| ClientError::Request {
                retries: 0,
                source: err,
            })
    }

    #[allow(dead_code)]
    pub async fn get_text(&self, path: &str) -> ClientResult<String> {
        let response = self
            .send_and_check(|| self.request(Method::GET, path))
            .await?;
        response.text().await.map_err(|err| ClientError::Request {
            retries: 0,
            source: err,
        })
    }

    pub async fn post_json<B: Serialize, T: DeserializeOwned>(
        &self,
        path: &str,
        body: &B,
    ) -> ClientResult<T> {
        let response = self
            .send_and_check(|| self.request(Method::POST, path).map(|req| req.json(body)))
            .await?;
        response
            .json::<T>()
            .await
            .map_err(|err| ClientError::Request {
                retries: 0,
                source: err,
            })
    }

    pub async fn post_json_stream<B: Serialize>(
        &self,
        path: &str,
        body: &B,
    ) -> ClientResult<Response> {
        self.send_and_check(|| self.request(Method::POST, path).map(|req| req.json(body)))
            .await
    }
}

fn validate_base_url(base_url: &Url, insecure: bool) -> ClientResult<()> {
    match base_url.scheme() {
        "https" => Ok(()),
        "http" => {
            if insecure {
                eprintln!("Warning: using insecure HTTP connection to {}", base_url);
                Ok(())
            } else {
                Err(ClientError::InvalidUrl(
                    "server url must use https scheme (use --insecure to allow http)".to_string(),
                ))
            }
        }
        _ => Err(ClientError::InvalidUrl(
            "server url must use http or https scheme".to_string(),
        )),
    }
}