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)]
pub struct Client {
host: String,
api_key: Option<String>,
secret_key: Option<String>,
recv_window: Option<u16>,
inner_client: reqwest::Client,
}
impl Client {
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(),
}
}
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
}
}
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,
})
}
_ => {
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]
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]
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(¶ms, key).unwrap();
assert_eq!(
signature,
"c8db56825ae71d6d79447849e617115f4a920fa2acdcab2b053c4b2838bd6b71"
);
}
}