cdumay_http_client 0.3.2

A library to call remote web application
Documentation
use std::collections::{BTreeMap, HashMap};
use std::thread;
use std::time::Duration;

use cdumay_error::{Error, ErrorBuilder, ErrorKind, GenericErrors};
use chrono::{DateTime, Utc};
use reqwest::{Method, Url};
use reqwest::blocking::{Client, RequestBuilder, Response};
use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
use serde_json::Value;

use crate::authentication::Authentication;
use crate::ClientError;
use crate::utils::{build_url, merge_headers};

pub struct CallContext {
    pub start: DateTime<Utc>,
    pub url: Url,
    pub method: Method,
}

pub trait ClientBuilder {
    fn new(url_root: &str) -> Result<Self, ClientError>
    where
        Self: Sized;
    fn set_timeout(self, timeout: u64) -> Self;
    fn set_headers(self, headers: HeaderMap) -> Self;
    fn set_auth<A: Authentication + 'static>(self, auth: A) -> Self;
    fn set_try_number(self, try_number: u64) -> Self;
    fn set_retry_delay(self, try_number: u64) -> Self;
}

pub trait BaseClient {
    // To implement
    fn url_root(&self) -> &Url;
    fn timeout(&self) -> &u64;
    fn headers(&self) -> &HeaderMap;
    fn auth(&self) -> Option<&Box<dyn Authentication>>;
    fn try_number(&self) -> u64;
    fn retry_delay(&self) -> u64;
    fn _request_wrapper(&self, req: RequestBuilder) -> Result<Response, Error> {
        Ok(req.send().map_err(|err| ErrorBuilder::from(ClientError::from(err)).build())?)
    }
    fn do_request(&self, method: Method, path: String, params: Option<HashMap<String, String>>, data: Option<String>, headers: Option<HeaderMap>, timeout: Option<u64>, no_retry_on: Option<Vec<ErrorKind>>) -> Result<String, Error> {
        let start = Utc::now();
        let url = build_url(self.url_root(), path, params).map_err(|err| ErrorBuilder::from(err).build())?;
        let context = {
            let mut out: BTreeMap<String, Value> = BTreeMap::new();
            out.insert("url".into(), Value::from(url.to_string()));
            out.insert("method".into(), Value::from(method.to_string()));
            out
        };
        let cli = Client::builder()
            .timeout(Duration::from_secs(timeout.unwrap_or(self.timeout().clone())))
            .default_headers(merge_headers(self.headers(), headers))
            .build().map_err(|err| ErrorBuilder::from(ClientError::from(err)).extra(context.clone().into()).build())?;
        debug!("{} {}", &method, &url.as_str());
        let mut req = cli.request(method.clone(), url.clone());
        if let Some(auth) = self.auth() {
            if let Some((name, value)) = auth.as_header() {
                req = req.header(name, value);
            }
        }
        if let Some(txt) = data {
            req = req.body::<String>(txt);
        }
        let mut last_error: Option<Error> = None;
        for req_try in 1..=self.try_number() {
            info!("[{}] - {} (try: {})", method, url, req_try);
            match req.try_clone() {
                Some(req) => {
                    let resp = self._request_wrapper(req)?;
                    let end = { Utc::now() - start }.to_std().unwrap();
                    let human = humantime::format_duration(end).to_string();
                    let length = resp.content_length().unwrap_or(0);
                    match resp.status().is_success() {
                        true => {
                            info!("{} {} - {} - {} [{}]", &method, &url.as_str(), resp.status(), length, &human);
                            return Ok(
                                resp
                                    .text()
                                    .map_err(
                                        |err| ErrorBuilder::from(ClientError::from(err)).extra(context.clone().into()).build()
                                    )?
                            );
                        }
                        false => {
                            error!("{} {} - {} - {} [{}]", &method, &url.as_str(), resp.status(), length, &human);
                            let mut err_context = context.clone();
                            err_context.insert("try".into(), Value::from(req_try));
                            let err = ErrorBuilder::from(ClientError::from(resp)).extra(err_context.into()).build();
                            if let Some(kinds) = &no_retry_on {
                                if kinds.contains(&err.kind) {
                                    return Err(err);
                                }
                            }
                            last_error = Some(err);
                        }
                    };
                    thread::sleep(Duration::from_secs(self.retry_delay()));
                }
                None => {
                    return Err(
                        ErrorBuilder::from(GenericErrors::UNKNOWN_ERROR)
                            .message("Internal error, failed to clone request".into())
                            .extra(context.into()).build()
                    )
                }
            }
        }
        match last_error {
            Some(err) => {
                error!("Failed to perform request {} on {} after {} retries : {}", method, url,self.try_number() ,err);
                Err(err)
            }
            None => {
                error!("Unexpected error, failed to perform request {} on {} after {} retries", method, url,self.try_number());
                Err(ErrorBuilder::from(GenericErrors::UNKNOWN_ERROR)
                    .extra(context.into()).build()
                )
            }
        }
    }
    fn head(&self, path: String, params: Option<HashMap<String, String>>, headers: Option<HeaderMap>, timeout: Option<u64>, no_retry_on: Option<Vec<ErrorKind>>) -> Result<(), Error> {
        self.do_request(Method::HEAD, path, params, None, headers, timeout, no_retry_on)?;
        Ok(())
    }
}


#[derive(Debug)]
pub struct HttpClient {
    url_root: Url,
    timeout: u64,
    headers: HeaderMap,
    auth: Option<Box<dyn Authentication>>,
    try_number: u64,
    retry_delay: u64,
}


impl ClientBuilder for HttpClient {
    fn new(url_root: &str) -> Result<HttpClient, ClientError> {
        Ok(HttpClient {
            url_root: Url::parse(url_root.trim_end_matches("/")).map_err(|err| {
                ClientError::UrlError(err)
            })?,
            timeout: 10,
            headers: {
                let mut headers = HeaderMap::new();
                headers.append(
                    USER_AGENT,
                    HeaderValue::from_str(&format!("{}/{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")))?,
                );
                headers
            },
            auth: None,
            try_number: 10,
            retry_delay: 30,
        })
    }
    fn set_timeout(mut self, timeout: u64) -> HttpClient {
        self.timeout = timeout;
        self
    }
    fn set_headers(mut self, headers: HeaderMap) -> HttpClient {
        self.headers.extend(headers);
        self
    }
    fn set_auth<A: Authentication + 'static>(mut self, auth: A) -> HttpClient {
        self.auth = Some(Box::new(auth));
        self
    }
    fn set_try_number(mut self, try_number: u64) -> HttpClient {
        if try_number == 0 {
            panic!("Try number MUST be > 0 !");
        }
        self.try_number = try_number;
        self
    }
    fn set_retry_delay(mut self, retry_delay: u64) -> HttpClient {
        self.retry_delay = retry_delay;
        self
    }
}

impl BaseClient for HttpClient {
    fn url_root(&self) -> &Url { &self.url_root }
    fn timeout(&self) -> &u64 { &self.timeout }
    fn headers(&self) -> &HeaderMap { &self.headers }
    fn auth(&self) -> Option<&Box<dyn Authentication>> { self.auth.as_ref() }
    fn try_number(&self) -> u64 { self.try_number }
    fn retry_delay(&self) -> u64 { self.retry_delay }
}

impl HttpClient {
    pub fn get(&self, path: String, params: Option<HashMap<String, String>>, headers: Option<HeaderMap>, timeout: Option<u64>, no_retry_on: Option<Vec<ErrorKind>>) -> Result<String, Error> {
        self.do_request(Method::GET, path, params, None, headers, timeout, no_retry_on)
    }
    pub fn post(&self, path: String, params: Option<HashMap<String, String>>, data: Option<String>, headers: Option<HeaderMap>, timeout: Option<u64>, no_retry_on: Option<Vec<ErrorKind>>) -> Result<String, Error> {
        self.do_request(Method::POST, path, params, data, headers, timeout, no_retry_on)
    }
    pub fn put(&self, path: String, params: Option<HashMap<String, String>>, data: Option<String>, headers: Option<HeaderMap>, timeout: Option<u64>, no_retry_on: Option<Vec<ErrorKind>>) -> Result<String, Error> {
        self.do_request(Method::PUT, path, params, data, headers, timeout, no_retry_on)
    }
    pub fn delete(&self, path: String, params: Option<HashMap<String, String>>, headers: Option<HeaderMap>, timeout: Option<u64>, no_retry_on: Option<Vec<ErrorKind>>) -> Result<String, Error> {
        self.do_request(Method::DELETE, path, params, None, headers, timeout, no_retry_on)
    }
}

#[cfg(test)]
mod test {
    use std::sync::Once;

    use simple_logger::SimpleLogger;

    use crate::{ClientBuilder, HttpClient, HttpStatusCodeErrors};

    static INIT: Once = Once::new();

    fn init_logger() {
        INIT.call_once(|| {
            let _ = SimpleLogger::new().with_level(log::LevelFilter::Info).init();
        });
    }

    #[test]
    fn test_no_auth() {
        init_logger();
        let cli = HttpClient::new("https://www.rust-lang.org").unwrap();
        let result = cli.get("/learn/get-started".into(), None, None, None, None);
        assert_eq!(result.unwrap().starts_with("<!doctype html>"), true);
    }

    #[test]
    fn test_err() {
        init_logger();
        let cli = HttpClient::new("https://www.rust-lang.org")
            .unwrap()
            .set_try_number(2)
            .set_retry_delay(1);
        match cli.get("/sdq".into(), None, None, None, None) {
            Ok(_) => panic!("No error raised!"),
            Err(err) => assert_eq!(err.kind, HttpStatusCodeErrors::UNPROCESSABLE_ENTITY)
        };
    }
    #[test]
    fn test_err_no_retry() {
        init_logger();
        let cli = HttpClient::new("https://www.rust-lang.org")
            .unwrap()
            .set_try_number(2)
            .set_retry_delay(1);
        match cli.get("/sdq".into(), None, None, None, Some(vec![HttpStatusCodeErrors::UNPROCESSABLE_ENTITY])) {
            Ok(_) => panic!("No error raised!"),
            Err(err) => {
                assert_eq!(err.kind, HttpStatusCodeErrors::UNPROCESSABLE_ENTITY);
                match err.extra {
                    Some(data) => {
                        match data.get("try") {
                            Some(value) => {
                                assert_eq!(value.is_u64(), true);
                                assert_eq!(value.as_u64().unwrap(), 1);
                            }
                            None => panic!("No try in error.extra")
                        };
                    }
                    None => panic!("Not error extra found !")
                }
            }
        };
    }
}