openlimits-coinbase 0.3.0

Coinbase implementation for OpenLimits.
Documentation
use hmac::{Hmac, Mac, NewMac};
use reqwest::header;
use reqwest::{Method, RequestBuilder, Response, StatusCode};
use serde::de::DeserializeOwned;
use serde::Serialize;
use sha2::Sha256;
use std::time::SystemTime;
use url::Url;
use crate::CoinbaseContentError;
use openlimits_exchange::errors::OpenLimitsError;
use super::shared::Result;

type HmacSha256 = Hmac<Sha256>;

#[derive(Clone, Debug)]
pub struct Transport {
    api_secret: Option<String>,
    client: reqwest::Client,
    base_url: String,
}

impl Transport {
    pub fn new(sandbox: bool) -> Result<Self> {
        let default_headers = Transport::default_headers();

        let client = reqwest::Client::builder()
            .default_headers(default_headers)
            .build()?;

        Ok(Transport {
            client,
            api_secret: None,
            base_url: Transport::get_base_url(sandbox),
        })
    }

    pub fn with_credential(
        api_key: &str,
        api_secret: &str,
        passphrase: &str,
        sandbox: bool,
    ) -> Result<Self> {
        let default_headers = Transport::default_headers_with_auth(&api_key, &passphrase);
        let client = reqwest::Client::builder()
            .default_headers(default_headers)
            .build()?;

        Ok(Transport {
            api_secret: Some(String::from(api_secret)),
            client,
            base_url: Transport::get_base_url(sandbox),
        })
    }

    pub fn default_headers() -> header::HeaderMap<header::HeaderValue> {
        let mut headers = header::HeaderMap::new();
        headers.insert(
            "USER-AGENT",
            header::HeaderValue::from_str("openlimit")
                .expect("Couldn't create USER-AGENT header from string."),
        );

        headers
    }

    fn get_base_url(sandbox: bool) -> String {
        if sandbox {
            String::from("https://api-public.sandbox.exchange.coinbase.com")
        } else {
            String::from("https://api.exchange.coinbase.com")
        }
    }

    pub fn default_headers_with_auth(
        api_key: &str,
        passphrase: &str,
    ) -> header::HeaderMap<header::HeaderValue> {
        let mut headers = header::HeaderMap::new();
        headers.insert(
            "USER-AGENT",
            header::HeaderValue::from_str("openlimit")
                .expect("Couldn't create USER-AGENT header from string."),
        );

        headers.insert(
            "CB-ACCESS-KEY",
            header::HeaderValue::from_str(api_key)
                .expect("Couldn't create CB-ACCESS-KEY header from string."),
        );

        headers.insert(
            "CB-ACCESS-PASSPHRASE",
            header::HeaderValue::from_str(passphrase)
                .expect("Couldn't create CB-ACCESS-PASSPHRASE header from string."),
        );

        headers
    }

    pub async fn get<O, S>(&self, endpoint: &str, params: Option<&S>) -> Result<O>
    where
        O: DeserializeOwned,
        S: Serialize,
    {
        let url = self.get_url(endpoint, params)?;
        let request = self.client.get(url).send().await?;

        Ok(self.response_handler(request).await?)
    }

    pub async fn signed_get<O, S>(&self, endpoint: &str, params: Option<&S>) -> Result<O>
    where
        O: DeserializeOwned,
        S: Serialize,
    {
        let url = self.get_url(endpoint, params)?;

        let request = self.build_request::<()>(url, Method::GET, None)?;

        let resp = request.send().await?;

        Ok(self.response_handler(resp).await?)
    }

    pub async fn signed_post<O, P, D>(
        &self,
        endpoint: &str,
        params: Option<&P>,
        data: Option<&D>,
    ) -> Result<O>
    where
        O: DeserializeOwned,
        P: Serialize,
        D: Serialize,
    {
        let url = self.get_url(endpoint, params)?;
        let request = self.build_request(url, Method::POST, data)?;
        let resp = request.send().await?;

        Ok(self.response_handler(resp).await?)
    }

    pub async fn signed_delete<O, P, D>(
        &self,
        endpoint: &str,
        params: Option<&P>,
        data: Option<&D>,
    ) -> Result<O>
    where
        O: DeserializeOwned,
        P: Serialize,
        D: Serialize + std::fmt::Debug,
    {
        let url = self.get_url(endpoint, params)?;
        let request = self.build_request(url, Method::DELETE, data)?;
        let request = request.send().await?;

        Ok(self.response_handler(request).await?)
    }

    pub fn build_request<D>(
        &self,
        url: Url,
        method: Method,
        data: Option<&D>,
    ) -> Result<RequestBuilder>
    where
        D: Serialize,
    {
        let since_epoch_seconds = SystemTime::now()
            .duration_since(SystemTime::UNIX_EPOCH)
            .expect("Invalid SystemTime.")
            .as_secs();

        let signature = self.signature(&url, since_epoch_seconds, &method, data)?;

        let mut request = self
            .client
            .request(method, url)
            .header("CB-ACCESS-SIGN", signature)
            .header("CB-ACCESS-TIMESTAMP", since_epoch_seconds.to_string());

        request = if data.is_some() {
            request.json(&data)
        } else {
            request
        };

        Ok(request)
    }

    pub fn get_url<Q>(&self, endpoint: &str, params: Option<&Q>) -> Result<Url>
    where
        Q: Serialize,
    {
        let url = format!("{}{}", self.base_url, endpoint);

        let mut url = Url::parse(&url)?;

        if params.is_some() {
            let query = serde_urlencoded::to_string(params)?;
            url.set_query(Some(&query));
        };

        Ok(url)
    }

    pub fn signature<D>(
        &self,
        url: &Url,
        timestamp: u64,
        method: &Method,
        body: Option<&D>,
    ) -> Result<String>
    where
        D: Serialize,
    {
        let api_secret = match self.api_secret.as_ref() {
            None => Err(OpenLimitsError::NoApiKeySet()),
            Some(v) => Ok(v),
        }?;
        let key = base64::decode(api_secret).expect("Failed to base64 decode Coinbase API secret");
        let mut mac = HmacSha256::new_varkey(&key).expect("Couldn't create HMAC-SHA256.");

        let prefix: String = timestamp.to_string() + method.as_str();

        let body = if body.is_some() {
            serde_json::to_string(&body)?
        } else {
            String::from("")
        };
        let path = match url.query() {
            Some(q) => format!("{}?{}", url.path(), q),
            None => url.path().to_string(),
        };

        let sign_message = format!("{}{}{}", prefix, path, body);

        mac.update(sign_message.as_bytes());
        let signature = base64::encode(mac.finalize().into_bytes());
        Ok(signature)
    }

    async fn response_handler<O>(&self, response: Response) -> Result<O>
    where
        O: DeserializeOwned,
    {
        match response.status() {
            StatusCode::OK => {
                let text = response.text().await?;
                serde_json::from_str::<O>(&text).map_err(move |err| {
                    OpenLimitsError::NotParsableResponse(format!("Error:{} Payload: {}", err, text))
                })
            }

            StatusCode::INTERNAL_SERVER_ERROR => Err(OpenLimitsError::InternalServerError()),
            StatusCode::SERVICE_UNAVAILABLE => Err(OpenLimitsError::ServiceUnavailable()),
            StatusCode::UNAUTHORIZED => {
                let text = response.text().await?;
                println!("{}", text);
                Err(OpenLimitsError::Unauthorized())
            }
            StatusCode::BAD_REQUEST => {
                let error: CoinbaseContentError = response.json().await?;
                Err(OpenLimitsError::Generic(Box::new(error)))
            }
            s => {
                let text = response.text().await?;
                Err(OpenLimitsError::UnkownResponse(format!(
                    "Received response: {:?}, value: {}",
                    s, text
                )))
            }
        }
    }
}