binance-api 0.1.0

Yet another async Binance API
Documentation
use std::time::{SystemTime, SystemTimeError, UNIX_EPOCH};

use hmac::Mac as _;
use reqwest::{
    header::{HeaderMap, HeaderName, HeaderValue, CONTENT_TYPE, USER_AGENT},
    Response, StatusCode,
};
use serde::de::DeserializeOwned;

use crate::{
    endpoints::Endpoint,
    error::{Error, ResponseError, Result},
};

#[derive(Debug, Clone)]
/// The HTTP client able to call the [endpoints][Endpoint].
///
/// The client will automatically call the required method
/// for an endpoint with the optional data to ensure security needs.
pub struct Client {
    host: String,
    api_key: Option<String>,
    secret_key: Option<String>,
    recv_window: Option<u16>,
    inner_client: reqwest::Client,
}

impl Client {
    /// Create a new client with an optional API key.
    pub fn new(host: String, api_key: impl Into<Option<String>>) -> Self {
        Self {
            host,
            api_key: api_key.into(),
            secret_key: None,
            recv_window: None,
            inner_client: reqwest::Client::new(),
        }
    }

    /// Add a secret key to sign the queries.
    ///
    /// An additional parameter, `recv_window`, may be sent to specify the number of milliseconds
    /// after timestamp the request is valid for.
    /// If recv_window will not be sent, it defaults to 5_000.
    pub fn with_signed(self, key: String, recv_window: impl Into<Option<u16>>) -> Self {
        Self {
            secret_key: Some(key),
            recv_window: recv_window.into(),
            ..self
        }
    }

    /// Request data from [endpoint][Endpoint].
    ///
    /// For better experience use the [url_query!][crate::url_query] macro
    /// to construct key-value pairs of the URL query.
    pub async fn request<T: DeserializeOwned, E: Endpoint>(
        &self,
        endpoint: &E,
        query: &[(&dyn ToString, &dyn ToString)],
    ) -> Result<T> {
        let query_pairs = self.construct_query(endpoint, query)?;
        let url = format!("{}{}", self.host, endpoint.as_endpoint());
        let method = endpoint.http_verb();

        let security = endpoint.security_type();
        let headers = self.build_headers(security.is_key_required())?;

        let response = self
            .inner_client
            .request(method, url)
            .query(&query_pairs)
            .headers(headers)
            .send()
            .await?;

        self.response_handler(response).await
    }

    const TIMESTAMP_KEY: &'static str = "timestamp";
    const RECEIVE_WINDOW_KEY: &'static str = "recvWindow";
    const SIGNATURE_KEY: &'static str = "signature";

    fn construct_query<E: Endpoint>(
        &self,
        endpoint: &E,
        query: &[(&dyn ToString, &dyn ToString)],
    ) -> Result<Vec<(String, String)>> {
        let mut query_pairs: Vec<_> = query
            .iter()
            .map(|(key, value)| (key.to_string(), value.to_string()))
            .collect();

        let security = endpoint.security_type();
        if security.is_signature_required() {
            let timestamp = get_timestamp_millis().map_err(|_| Error::CannotGetTimestamp)?;
            query_pairs.push((Self::TIMESTAMP_KEY.into(), timestamp.to_string()));

            if let Some(recv_window) = self.recv_window {
                query_pairs.push((Self::RECEIVE_WINDOW_KEY.into(), recv_window.to_string()));
            }

            let query = serde_urlencoded::to_string(&query_pairs)?;
            let signature = self.get_signature(&query)?;
            query_pairs.push((Self::SIGNATURE_KEY.into(), signature));
        }

        Ok(query_pairs)
    }

    fn get_signature(&self, query: &str) -> Result<String> {
        if let Some(secret_key) = &self.secret_key {
            get_hmac_signature(query, secret_key)
        } else {
            Err(Error::MissingSecretKey)
        }
    }

    const API_KEY_HEADER: &'static str = "X-MBX-APIKEY";
    const USER_AGENT: &'static str = "binance-api rust client";

    fn build_headers(&self, with_api_key: bool) -> Result<HeaderMap> {
        let mut headers = HeaderMap::from_iter([
            (USER_AGENT, HeaderValue::from_static(Self::USER_AGENT)),
            (
                CONTENT_TYPE,
                HeaderValue::from_static("application/x-www-form-urlencoded"),
            ),
        ]);
        if with_api_key {
            if let Some(api_key) = &self.api_key {
                let api_key = HeaderValue::from_str(api_key)
                    .map_err(|_| Error::InvalidApiKey(api_key.clone()))?;
                let _ = headers.insert(HeaderName::from_static(Self::API_KEY_HEADER), api_key);
            } else {
                return Err(Error::MissingApiKey);
            }
        }

        Ok(headers)
    }

    async fn response_handler<T: DeserializeOwned>(&self, response: Response) -> Result<T> {
        let status = response.status();
        match status {
            StatusCode::OK => Ok(response.json().await?),
            StatusCode::BAD_REQUEST => {
                let error: ResponseError = response.json().await?;
                Err(Error::Server {
                    inner: error,
                    http_code: status,
                })
            }
            // all other codes are not guaranteed to return valid JSON
            _ => {
                let text_err = response.text().await;
                if let Ok(err) = text_err {
                    if let Ok(json_err) = serde_json::from_str(&err) {
                        Err(Error::Server {
                            inner: json_err,
                            http_code: status,
                        })
                    } else {
                        Err(Error::ServerPlain {
                            inner: err,
                            http_code: status,
                        })
                    }
                } else {
                    Err(Error::ServerUnknown(status))
                }
            }
        }
    }
}

type Hmac256 = hmac::Hmac<sha2::Sha256>;

fn get_hmac_signature(data: &str, key: &str) -> Result<String> {
    let mut signed_key = Hmac256::new_from_slice(key.as_bytes())
        .map_err(|_| Error::InvalidSecretKey { size: key.len() })?;

    signed_key.update(data.as_bytes());

    Ok(hex::encode(signed_key.finalize().into_bytes()))
}

fn get_timestamp_millis() -> std::result::Result<u64, SystemTimeError> {
    let now = SystemTime::now();
    let since_epoch = now.duration_since(UNIX_EPOCH)?;
    Ok(since_epoch.as_secs() * 1000 + u64::from(since_epoch.subsec_millis()))
}

#[macro_export]
/// Allow to construct a [Vec][std::vec::Vec] of pairs
/// to later feed them to the request:
///
/// ```
/// # use binance_api::url_query;
/// let query = url_query!(foo="VALUE", bar=1, baz=0.05);
///
/// let encoded: String = query.iter().map(|(k, v)| {
///     format!("{}={}&", k.to_string(), v.to_string())
/// }).collect();
/// assert_eq!(encoded, "foo=VALUE&bar=1&baz=0.05&");
/// ```
macro_rules! url_query {
    ( $( $key:tt = $value:expr ),+ $(,)? ) => {{
        let query: Vec<(&dyn ToString, &dyn ToString)> = vec![
            $(

                (&stringify!($key), &$value),
            )+
        ];
        query
    }}
}

#[cfg(test)]
fn sign_query(query: &[(&dyn ToString, &dyn ToString)], key: &str) -> Result<String> {
    let query_pairs: Vec<_> = query
        .iter()
        .map(|(key, value)| (key.to_string(), value.to_string()))
        .collect();

    let query = serde_urlencoded::to_string(&query_pairs)?;
    get_hmac_signature(&query, key)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    /// https://binance-docs.github.io/apidocs/spot/en/#signed-endpoint-examples-for-post-api-v3-order
    fn reproduce_example_signature() {
        let params = url_query!(
            symbol = "LTCBTC",
            side = "BUY",
            type = "LIMIT",
            timeInForce = "GTC",
            quantity = 1,
            price = 0.1,
            recvWindow = 5000,
            timestamp = 1499827319559_u64,
        );

        let key = "NhqPtmdSJYdKjVHjA7PZj4Mge3R5YNiP1e3UZjInClVN65XAbvqqM6A7H5fATj0j";
        let signature = sign_query(&params, key).unwrap();

        assert_eq!(
            signature,
            "c8db56825ae71d6d79447849e617115f4a920fa2acdcab2b053c4b2838bd6b71"
        );
    }
}