1use reqwest::Client;
4use serde::de::DeserializeOwned;
5use std::time::Duration;
6use url::Url;
7
8use crate::auth::Credentials;
9use crate::rest::error::AsterDexError;
10use crate::rest::request::parse_response;
11use crate::rest::response::ApiResponse;
12
13const DEFAULT_BASE_URL: &str = "https://fapi.asterdex.com";
14const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
17const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
18const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
19const TCP_KEEPALIVE: Duration = Duration::from_secs(60);
20const POOL_MAX_IDLE_PER_HOST: usize = 32;
21
22pub struct RestClient {
27 pub(crate) base_url: Url,
28 pub(crate) credentials: Option<Credentials>,
29 pub(crate) http: Client,
30}
31
32impl RestClient {
33 pub fn from_env() -> Result<Self, AsterDexError> {
41 let creds = Credentials::from_env()?;
42 let base_url_str = std::env::var("ASTERDEX_BASE_URL")
43 .unwrap_or_else(|_| DEFAULT_BASE_URL.to_string());
44 Self::new_with_credentials(&base_url_str, creds)
45 }
46
47 pub fn new(base_url: &str, credentials: Credentials) -> Result<Self, AsterDexError> {
52 Self::new_with_credentials(base_url, credentials)
53 }
54
55 pub fn new_public(base_url: &str) -> Result<Self, AsterDexError> {
63 let url = normalize_url(base_url)?;
64 let http = build_http_client()?;
65 Ok(Self {
66 base_url: url,
67 credentials: None,
68 http,
69 })
70 }
71
72 fn new_with_credentials(
73 base_url: &str,
74 creds: Credentials,
75 ) -> Result<Self, AsterDexError> {
76 let url = normalize_url(base_url)?;
77 let http = build_http_client()?;
78 Ok(Self {
79 base_url: url,
80 credentials: Some(creds),
81 http,
82 })
83 }
84
85 pub(crate) async fn get<T: DeserializeOwned>(
87 &self,
88 path: &str,
89 params: &[(&str, &str)],
90 ) -> Result<ApiResponse<T>, AsterDexError> {
91 let url = self.build_url(path, params)?;
92 tracing::debug!(method = "GET", path = path, signed = false, "REST request");
93 let response = self
94 .http
95 .get(url)
96 .send()
97 .await
98 .map_err(AsterDexError::from_reqwest)?;
99 let result = parse_response::<T>(response).await;
100 self.log_response(&result);
101 result
102 }
103
104 pub(crate) async fn post<T: DeserializeOwned>(
106 &self,
107 path: &str,
108 params: &[(&str, &str)],
109 ) -> Result<ApiResponse<T>, AsterDexError> {
110 let url = self.build_url(path, params)?;
111 tracing::debug!(method = "POST", path = path, signed = false, "REST request");
112 let response = self
113 .http
114 .post(url)
115 .send()
116 .await
117 .map_err(AsterDexError::from_reqwest)?;
118 parse_response::<T>(response).await
119 }
120
121 pub(crate) async fn delete<T: DeserializeOwned>(
123 &self,
124 path: &str,
125 params: &[(&str, &str)],
126 ) -> Result<ApiResponse<T>, AsterDexError> {
127 let url = self.build_url(path, params)?;
128 tracing::debug!(method = "DELETE", path = path, signed = false, "REST request");
129 let response = self
130 .http
131 .delete(url)
132 .send()
133 .await
134 .map_err(AsterDexError::from_reqwest)?;
135 parse_response::<T>(response).await
136 }
137
138 pub(crate) async fn signed_get<T: DeserializeOwned>(
140 &self,
141 path: &str,
142 params: &[(&str, &str)],
143 ) -> Result<ApiResponse<T>, AsterDexError> {
144 let full_url = self.build_signed_url(path, params)?;
145 tracing::debug!(method = "GET", path = path, signed = true, "REST request");
146 let response = self
147 .http
148 .get(&full_url)
149 .send()
150 .await
151 .map_err(AsterDexError::from_reqwest)?;
152 let result = parse_response::<T>(response).await;
153 self.log_response(&result);
154 result
155 }
156
157 pub(crate) async fn signed_post<T: DeserializeOwned>(
159 &self,
160 path: &str,
161 params: &[(&str, &str)],
162 ) -> Result<ApiResponse<T>, AsterDexError> {
163 let full_url = self.build_signed_url(path, params)?;
164 tracing::debug!(method = "POST", path = path, signed = true, "REST request");
165 let response = self
166 .http
167 .post(&full_url)
168 .header("Content-Type", "application/x-www-form-urlencoded")
169 .send()
170 .await
171 .map_err(AsterDexError::from_reqwest)?;
172 let result = parse_response::<T>(response).await;
173 self.log_response(&result);
174 result
175 }
176
177 pub(crate) async fn signed_delete<T: DeserializeOwned>(
179 &self,
180 path: &str,
181 params: &[(&str, &str)],
182 ) -> Result<ApiResponse<T>, AsterDexError> {
183 let full_url = self.build_signed_url(path, params)?;
184 tracing::debug!(method = "DELETE", path = path, signed = true, "REST request");
185 let response = self
186 .http
187 .delete(&full_url)
188 .send()
189 .await
190 .map_err(AsterDexError::from_reqwest)?;
191 let result = parse_response::<T>(response).await;
192 self.log_response(&result);
193 result
194 }
195
196 pub(crate) async fn signed_put<T: DeserializeOwned>(
198 &self,
199 path: &str,
200 params: &[(&str, &str)],
201 ) -> Result<ApiResponse<T>, AsterDexError> {
202 let full_url = self.build_signed_url(path, params)?;
203 tracing::debug!(method = "PUT", path = path, signed = true, "REST request");
204 let response = self
205 .http
206 .put(&full_url)
207 .send()
208 .await
209 .map_err(AsterDexError::from_reqwest)?;
210 let result = parse_response::<T>(response).await;
211 self.log_response(&result);
212 result
213 }
214
215
216 pub async fn raw_get(
241 &self,
242 path: &str,
243 params: &[(&str, &str)],
244 ) -> Result<ApiResponse<serde_json::Value>, AsterDexError> {
245 self.get(path, params).await
246 }
247
248 pub async fn raw_post(
255 &self,
256 path: &str,
257 params: &[(&str, &str)],
258 ) -> Result<ApiResponse<serde_json::Value>, AsterDexError> {
259 self.post(path, params).await
260 }
261
262 pub async fn raw_delete(
269 &self,
270 path: &str,
271 params: &[(&str, &str)],
272 ) -> Result<ApiResponse<serde_json::Value>, AsterDexError> {
273 self.delete(path, params).await
274 }
275
276 pub async fn raw_signed_get(
286 &self,
287 path: &str,
288 params: &[(&str, &str)],
289 ) -> Result<ApiResponse<serde_json::Value>, AsterDexError> {
290 self.signed_get(path, params).await
291 }
292
293 pub async fn raw_signed_post(
303 &self,
304 path: &str,
305 params: &[(&str, &str)],
306 ) -> Result<ApiResponse<serde_json::Value>, AsterDexError> {
307 self.signed_post(path, params).await
308 }
309
310 pub async fn raw_signed_delete(
320 &self,
321 path: &str,
322 params: &[(&str, &str)],
323 ) -> Result<ApiResponse<serde_json::Value>, AsterDexError> {
324 self.signed_delete(path, params).await
325 }
326
327 fn build_signed_url(
329 &self,
330 path: &str,
331 params: &[(&str, &str)],
332 ) -> Result<String, AsterDexError> {
333 let creds = self.require_credentials()?;
334 let signed_query = creds.sign_query(params)?;
335 let base = self.base_url.as_str().trim_end_matches('/');
336 Ok(format!("{base}{path}?{signed_query}"))
337 }
338
339 fn require_credentials(&self) -> Result<&Credentials, AsterDexError> {
340 self.credentials
341 .as_ref()
342 .ok_or_else(|| AsterDexError::ConfigError {
343 message: "credentials required for signed endpoint".to_string(),
344 })
345 }
346
347 fn build_url(
348 &self,
349 path: &str,
350 params: &[(&str, &str)],
351 ) -> Result<String, AsterDexError> {
352 let base = self.base_url.as_str().trim_end_matches('/');
353 if params.is_empty() {
354 Ok(format!("{base}{path}"))
355 } else {
356 let qs = url::form_urlencoded::Serializer::new(String::new())
357 .extend_pairs(params)
358 .finish();
359 Ok(format!("{base}{path}?{qs}"))
360 }
361 }
362
363 fn log_response<T>(&self, result: &Result<ApiResponse<T>, AsterDexError>) {
364 match result {
365 Ok(r) => tracing::debug!(
366 used_weight = ?r.used_weight,
367 order_count = ?r.order_count,
368 "REST response OK"
369 ),
370 Err(e) => tracing::warn!(error_kind = ?e, "REST error"),
371 }
372 }
373}
374
375fn normalize_url(url: &str) -> Result<Url, AsterDexError> {
376 let url = if url.ends_with('/') {
377 url.to_string()
378 } else {
379 format!("{url}/")
380 };
381 Url::parse(&url).map_err(|e| AsterDexError::ConfigError {
382 message: format!("invalid base URL: {e}"),
383 })
384}
385
386fn build_http_client() -> Result<Client, AsterDexError> {
387 Client::builder()
388 .use_rustls_tls()
389 .timeout(REQUEST_TIMEOUT)
390 .connect_timeout(CONNECT_TIMEOUT)
391 .pool_idle_timeout(POOL_IDLE_TIMEOUT)
392 .pool_max_idle_per_host(POOL_MAX_IDLE_PER_HOST)
393 .tcp_keepalive(TCP_KEEPALIVE)
394 .build()
395 .map_err(|e| AsterDexError::ConfigError {
396 message: format!("failed to build HTTP client: {e}"),
397 })
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403 use mockito::Server;
404
405 #[tokio::test]
407 async fn test_public_get_200() {
408 let mut server = Server::new_async().await;
409 let mock = server
410 .mock("GET", "/fapi/v3/ping")
411 .with_status(200)
412 .with_header("X-MBX-USED-WEIGHT-1MINUTE", "10")
413 .with_header("X-MBX-ORDER-COUNT-1MINUTE", "2")
414 .with_body("{}")
415 .create_async()
416 .await;
417
418 let client = RestClient::new_public(&server.url()).unwrap();
419 let result = client
420 .get::<serde_json::Value>("/fapi/v3/ping", &[])
421 .await
422 .unwrap();
423
424 assert_eq!(result.used_weight, Some(10));
425 assert_eq!(result.order_count, Some(2));
426 mock.assert_async().await;
427 }
428
429 #[tokio::test]
431 async fn test_api_error_4xx() {
432 let mut server = Server::new_async().await;
433 let mock = server
434 .mock("GET", "/fapi/v3/depth")
435 .with_status(400)
436 .with_header("content-type", "application/json")
437 .with_body(
438 r#"{"code":-1102,"msg":"Mandatory parameter 'symbol' was not sent"}"#,
439 )
440 .create_async()
441 .await;
442
443 let client = RestClient::new_public(&server.url()).unwrap();
444 let result: Result<ApiResponse<serde_json::Value>, _> =
445 client.get("/fapi/v3/depth", &[]).await;
446
447 assert!(matches!(
448 result,
449 Err(AsterDexError::ApiError { code: -1102, .. })
450 ));
451 if let Err(AsterDexError::ApiError { msg, .. }) = &result {
452 assert_eq!(msg, "Mandatory parameter 'symbol' was not sent");
453 }
454 mock.assert_async().await;
455 }
456
457 #[tokio::test]
459 async fn test_rate_limited_429() {
460 let mut server = Server::new_async().await;
461 let mock = server
462 .mock("GET", "/fapi/v3/ping")
463 .with_status(429)
464 .with_header("Retry-After", "30")
465 .create_async()
466 .await;
467
468 let client = RestClient::new_public(&server.url()).unwrap();
469 let result: Result<ApiResponse<serde_json::Value>, _> =
470 client.get("/fapi/v3/ping", &[]).await;
471
472 assert!(
473 matches!(result, Err(AsterDexError::RateLimited { retry_after: Some(d) }) if d.as_secs() == 30)
474 );
475 mock.assert_async().await;
476 }
477
478 #[tokio::test]
480 async fn test_ip_banned_418() {
481 let mut server = Server::new_async().await;
482 let mock = server
483 .mock("GET", "/fapi/v3/ping")
484 .with_status(418)
485 .with_header("Retry-After", "7200")
486 .create_async()
487 .await;
488
489 let client = RestClient::new_public(&server.url()).unwrap();
490 let result: Result<ApiResponse<serde_json::Value>, _> =
491 client.get("/fapi/v3/ping", &[]).await;
492
493 assert!(
494 matches!(result, Err(AsterDexError::IpBanned { retry_after: Some(d) }) if d.as_secs() == 7200)
495 );
496 mock.assert_async().await;
497 }
498
499 #[tokio::test]
501 async fn test_server_error_5xx() {
502 let mut server = Server::new_async().await;
503 let mock = server
504 .mock("GET", "/fapi/v3/ping")
505 .with_status(500)
506 .with_body("Internal Server Error")
507 .create_async()
508 .await;
509
510 let client = RestClient::new_public(&server.url()).unwrap();
511 let result: Result<ApiResponse<serde_json::Value>, _> =
512 client.get("/fapi/v3/ping", &[]).await;
513
514 assert!(
515 matches!(result, Err(AsterDexError::HttpError { status: 500, ref body }) if body == "Internal Server Error")
516 );
517 mock.assert_async().await;
518 }
519}