use reqwest::Client;
use serde::de::DeserializeOwned;
use std::time::Duration;
use url::Url;
use crate::auth::Credentials;
use crate::rest::error::AsterDexError;
use crate::rest::request::parse_response;
use crate::rest::response::ApiResponse;
const DEFAULT_BASE_URL: &str = "https://fapi.asterdex.com";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
const TCP_KEEPALIVE: Duration = Duration::from_secs(60);
const POOL_MAX_IDLE_PER_HOST: usize = 32;
pub struct RestClient {
pub(crate) base_url: Url,
pub(crate) credentials: Option<Credentials>,
pub(crate) http: Client,
}
impl RestClient {
pub fn from_env() -> Result<Self, AsterDexError> {
let creds = Credentials::from_env()?;
let base_url_str = std::env::var("ASTERDEX_BASE_URL")
.unwrap_or_else(|_| DEFAULT_BASE_URL.to_string());
Self::new_with_credentials(&base_url_str, creds)
}
pub fn new(base_url: &str, credentials: Credentials) -> Result<Self, AsterDexError> {
Self::new_with_credentials(base_url, credentials)
}
pub fn new_public(base_url: &str) -> Result<Self, AsterDexError> {
let url = normalize_url(base_url)?;
let http = build_http_client()?;
Ok(Self {
base_url: url,
credentials: None,
http,
})
}
fn new_with_credentials(
base_url: &str,
creds: Credentials,
) -> Result<Self, AsterDexError> {
let url = normalize_url(base_url)?;
let http = build_http_client()?;
Ok(Self {
base_url: url,
credentials: Some(creds),
http,
})
}
pub(crate) async fn get<T: DeserializeOwned>(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<ApiResponse<T>, AsterDexError> {
let url = self.build_url(path, params)?;
tracing::debug!(method = "GET", path = path, signed = false, "REST request");
let response = self
.http
.get(url)
.send()
.await
.map_err(AsterDexError::from_reqwest)?;
let result = parse_response::<T>(response).await;
self.log_response(&result);
result
}
pub(crate) async fn post<T: DeserializeOwned>(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<ApiResponse<T>, AsterDexError> {
let url = self.build_url(path, params)?;
tracing::debug!(method = "POST", path = path, signed = false, "REST request");
let response = self
.http
.post(url)
.send()
.await
.map_err(AsterDexError::from_reqwest)?;
parse_response::<T>(response).await
}
pub(crate) async fn delete<T: DeserializeOwned>(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<ApiResponse<T>, AsterDexError> {
let url = self.build_url(path, params)?;
tracing::debug!(method = "DELETE", path = path, signed = false, "REST request");
let response = self
.http
.delete(url)
.send()
.await
.map_err(AsterDexError::from_reqwest)?;
parse_response::<T>(response).await
}
pub(crate) async fn signed_get<T: DeserializeOwned>(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<ApiResponse<T>, AsterDexError> {
let full_url = self.build_signed_url(path, params)?;
tracing::debug!(method = "GET", path = path, signed = true, "REST request");
let response = self
.http
.get(&full_url)
.send()
.await
.map_err(AsterDexError::from_reqwest)?;
let result = parse_response::<T>(response).await;
self.log_response(&result);
result
}
pub(crate) async fn signed_post<T: DeserializeOwned>(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<ApiResponse<T>, AsterDexError> {
let full_url = self.build_signed_url(path, params)?;
tracing::debug!(method = "POST", path = path, signed = true, "REST request");
let response = self
.http
.post(&full_url)
.header("Content-Type", "application/x-www-form-urlencoded")
.send()
.await
.map_err(AsterDexError::from_reqwest)?;
let result = parse_response::<T>(response).await;
self.log_response(&result);
result
}
pub(crate) async fn signed_delete<T: DeserializeOwned>(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<ApiResponse<T>, AsterDexError> {
let full_url = self.build_signed_url(path, params)?;
tracing::debug!(method = "DELETE", path = path, signed = true, "REST request");
let response = self
.http
.delete(&full_url)
.send()
.await
.map_err(AsterDexError::from_reqwest)?;
let result = parse_response::<T>(response).await;
self.log_response(&result);
result
}
pub(crate) async fn signed_put<T: DeserializeOwned>(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<ApiResponse<T>, AsterDexError> {
let full_url = self.build_signed_url(path, params)?;
tracing::debug!(method = "PUT", path = path, signed = true, "REST request");
let response = self
.http
.put(&full_url)
.send()
.await
.map_err(AsterDexError::from_reqwest)?;
let result = parse_response::<T>(response).await;
self.log_response(&result);
result
}
pub async fn raw_get(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<ApiResponse<serde_json::Value>, AsterDexError> {
self.get(path, params).await
}
pub async fn raw_post(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<ApiResponse<serde_json::Value>, AsterDexError> {
self.post(path, params).await
}
pub async fn raw_delete(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<ApiResponse<serde_json::Value>, AsterDexError> {
self.delete(path, params).await
}
pub async fn raw_signed_get(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<ApiResponse<serde_json::Value>, AsterDexError> {
self.signed_get(path, params).await
}
pub async fn raw_signed_post(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<ApiResponse<serde_json::Value>, AsterDexError> {
self.signed_post(path, params).await
}
pub async fn raw_signed_delete(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<ApiResponse<serde_json::Value>, AsterDexError> {
self.signed_delete(path, params).await
}
fn build_signed_url(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<String, AsterDexError> {
let creds = self.require_credentials()?;
let signed_query = creds.sign_query(params)?;
let base = self.base_url.as_str().trim_end_matches('/');
Ok(format!("{base}{path}?{signed_query}"))
}
fn require_credentials(&self) -> Result<&Credentials, AsterDexError> {
self.credentials
.as_ref()
.ok_or_else(|| AsterDexError::ConfigError {
message: "credentials required for signed endpoint".to_string(),
})
}
fn build_url(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<String, AsterDexError> {
let base = self.base_url.as_str().trim_end_matches('/');
if params.is_empty() {
Ok(format!("{base}{path}"))
} else {
let qs = url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(params)
.finish();
Ok(format!("{base}{path}?{qs}"))
}
}
fn log_response<T>(&self, result: &Result<ApiResponse<T>, AsterDexError>) {
match result {
Ok(r) => tracing::debug!(
used_weight = ?r.used_weight,
order_count = ?r.order_count,
"REST response OK"
),
Err(e) => tracing::warn!(error_kind = ?e, "REST error"),
}
}
}
fn normalize_url(url: &str) -> Result<Url, AsterDexError> {
let url = if url.ends_with('/') {
url.to_string()
} else {
format!("{url}/")
};
Url::parse(&url).map_err(|e| AsterDexError::ConfigError {
message: format!("invalid base URL: {e}"),
})
}
fn build_http_client() -> Result<Client, AsterDexError> {
Client::builder()
.use_rustls_tls()
.timeout(REQUEST_TIMEOUT)
.connect_timeout(CONNECT_TIMEOUT)
.pool_idle_timeout(POOL_IDLE_TIMEOUT)
.pool_max_idle_per_host(POOL_MAX_IDLE_PER_HOST)
.tcp_keepalive(TCP_KEEPALIVE)
.build()
.map_err(|e| AsterDexError::ConfigError {
message: format!("failed to build HTTP client: {e}"),
})
}
#[cfg(test)]
mod tests {
use super::*;
use mockito::Server;
#[tokio::test]
async fn test_public_get_200() {
let mut server = Server::new_async().await;
let mock = server
.mock("GET", "/fapi/v3/ping")
.with_status(200)
.with_header("X-MBX-USED-WEIGHT-1MINUTE", "10")
.with_header("X-MBX-ORDER-COUNT-1MINUTE", "2")
.with_body("{}")
.create_async()
.await;
let client = RestClient::new_public(&server.url()).unwrap();
let result = client
.get::<serde_json::Value>("/fapi/v3/ping", &[])
.await
.unwrap();
assert_eq!(result.used_weight, Some(10));
assert_eq!(result.order_count, Some(2));
mock.assert_async().await;
}
#[tokio::test]
async fn test_api_error_4xx() {
let mut server = Server::new_async().await;
let mock = server
.mock("GET", "/fapi/v3/depth")
.with_status(400)
.with_header("content-type", "application/json")
.with_body(
r#"{"code":-1102,"msg":"Mandatory parameter 'symbol' was not sent"}"#,
)
.create_async()
.await;
let client = RestClient::new_public(&server.url()).unwrap();
let result: Result<ApiResponse<serde_json::Value>, _> =
client.get("/fapi/v3/depth", &[]).await;
assert!(matches!(
result,
Err(AsterDexError::ApiError { code: -1102, .. })
));
if let Err(AsterDexError::ApiError { msg, .. }) = &result {
assert_eq!(msg, "Mandatory parameter 'symbol' was not sent");
}
mock.assert_async().await;
}
#[tokio::test]
async fn test_rate_limited_429() {
let mut server = Server::new_async().await;
let mock = server
.mock("GET", "/fapi/v3/ping")
.with_status(429)
.with_header("Retry-After", "30")
.create_async()
.await;
let client = RestClient::new_public(&server.url()).unwrap();
let result: Result<ApiResponse<serde_json::Value>, _> =
client.get("/fapi/v3/ping", &[]).await;
assert!(
matches!(result, Err(AsterDexError::RateLimited { retry_after: Some(d) }) if d.as_secs() == 30)
);
mock.assert_async().await;
}
#[tokio::test]
async fn test_ip_banned_418() {
let mut server = Server::new_async().await;
let mock = server
.mock("GET", "/fapi/v3/ping")
.with_status(418)
.with_header("Retry-After", "7200")
.create_async()
.await;
let client = RestClient::new_public(&server.url()).unwrap();
let result: Result<ApiResponse<serde_json::Value>, _> =
client.get("/fapi/v3/ping", &[]).await;
assert!(
matches!(result, Err(AsterDexError::IpBanned { retry_after: Some(d) }) if d.as_secs() == 7200)
);
mock.assert_async().await;
}
#[tokio::test]
async fn test_server_error_5xx() {
let mut server = Server::new_async().await;
let mock = server
.mock("GET", "/fapi/v3/ping")
.with_status(500)
.with_body("Internal Server Error")
.create_async()
.await;
let client = RestClient::new_public(&server.url()).unwrap();
let result: Result<ApiResponse<serde_json::Value>, _> =
client.get("/fapi/v3/ping", &[]).await;
assert!(
matches!(result, Err(AsterDexError::HttpError { status: 500, ref body }) if body == "Internal Server Error")
);
mock.assert_async().await;
}
}