1use reqwest::Client;
4use serde::Serialize;
5use serde::de::DeserializeOwned;
6use std::time::Duration;
7use url::Url;
8
9use crate::auth::Credentials;
10use crate::rest::error::AsterDexError;
11use crate::rest::request::parse_response;
12use crate::rest::response::ApiResponse;
13
14const DEFAULT_BASE_URL: &str = "https://fapi.asterdex.com";
15const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
18const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
19const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
20const TCP_KEEPALIVE: Duration = Duration::from_secs(60);
21const POOL_MAX_IDLE_PER_HOST: usize = 32;
22
23pub struct RestClient {
28 pub(crate) base_url: Url,
29 pub(crate) credentials: Option<Credentials>,
30 pub(crate) http: Client,
31}
32
33impl RestClient {
34 pub fn from_env() -> Result<Self, AsterDexError> {
42 let creds = Credentials::from_env()?;
43 let base_url_str = std::env::var("ASTERDEX_BASE_URL")
44 .unwrap_or_else(|_| DEFAULT_BASE_URL.to_string());
45 Self::new_with_credentials(&base_url_str, creds)
46 }
47
48 pub fn new(base_url: &str, credentials: Credentials) -> Result<Self, AsterDexError> {
53 Self::new_with_credentials(base_url, credentials)
54 }
55
56 pub fn new_public(base_url: &str) -> Result<Self, AsterDexError> {
64 let url = normalize_url(base_url)?;
65 let http = build_http_client()?;
66 Ok(Self {
67 base_url: url,
68 credentials: None,
69 http,
70 })
71 }
72
73 fn new_with_credentials(
74 base_url: &str,
75 creds: Credentials,
76 ) -> Result<Self, AsterDexError> {
77 let url = normalize_url(base_url)?;
78 let http = build_http_client()?;
79 Ok(Self {
80 base_url: url,
81 credentials: Some(creds),
82 http,
83 })
84 }
85
86 pub(crate) async fn get<T: DeserializeOwned>(
88 &self,
89 path: &str,
90 params: &[(&str, &str)],
91 ) -> Result<ApiResponse<T>, AsterDexError> {
92 let url = self.build_url(path, params)?;
93 tracing::debug!(method = "GET", path = path, signed = false, "REST request");
94 let response = self
95 .http
96 .get(url)
97 .send()
98 .await
99 .map_err(AsterDexError::from_reqwest)?;
100 let result = parse_response::<T>(response).await;
101 self.log_response(&result);
102 result
103 }
104
105 pub(crate) async fn post<T: DeserializeOwned>(
107 &self,
108 path: &str,
109 params: &[(&str, &str)],
110 ) -> Result<ApiResponse<T>, AsterDexError> {
111 let url = self.build_url(path, params)?;
112 tracing::debug!(method = "POST", path = path, signed = false, "REST request");
113 let response = self
114 .http
115 .post(url)
116 .send()
117 .await
118 .map_err(AsterDexError::from_reqwest)?;
119 parse_response::<T>(response).await
120 }
121
122 pub(crate) async fn delete<T: DeserializeOwned>(
124 &self,
125 path: &str,
126 params: &[(&str, &str)],
127 ) -> Result<ApiResponse<T>, AsterDexError> {
128 let url = self.build_url(path, params)?;
129 tracing::debug!(method = "DELETE", path = path, signed = false, "REST request");
130 let response = self
131 .http
132 .delete(url)
133 .send()
134 .await
135 .map_err(AsterDexError::from_reqwest)?;
136 parse_response::<T>(response).await
137 }
138
139 pub(crate) async fn signed_get<T: DeserializeOwned>(
141 &self,
142 path: &str,
143 params: &[(&str, &str)],
144 ) -> Result<ApiResponse<T>, AsterDexError> {
145 let full_url = self.build_signed_url(path, params)?;
146 tracing::debug!(method = "GET", path = path, signed = true, "REST request");
147 let response = self
148 .http
149 .get(&full_url)
150 .send()
151 .await
152 .map_err(AsterDexError::from_reqwest)?;
153 let result = parse_response::<T>(response).await;
154 self.log_response(&result);
155 result
156 }
157
158 pub(crate) async fn signed_post<T: DeserializeOwned>(
160 &self,
161 path: &str,
162 params: &[(&str, &str)],
163 ) -> Result<ApiResponse<T>, AsterDexError> {
164 let full_url = self.build_signed_url(path, params)?;
165 tracing::debug!(method = "POST", path = path, signed = true, "REST request");
166 let response = self
167 .http
168 .post(&full_url)
169 .header("Content-Type", "application/x-www-form-urlencoded")
170 .send()
171 .await
172 .map_err(AsterDexError::from_reqwest)?;
173 let result = parse_response::<T>(response).await;
174 self.log_response(&result);
175 result
176 }
177
178 pub(crate) async fn signed_delete<T: DeserializeOwned>(
180 &self,
181 path: &str,
182 params: &[(&str, &str)],
183 ) -> Result<ApiResponse<T>, AsterDexError> {
184 let full_url = self.build_signed_url(path, params)?;
185 tracing::debug!(method = "DELETE", path = path, signed = true, "REST request");
186 let response = self
187 .http
188 .delete(&full_url)
189 .send()
190 .await
191 .map_err(AsterDexError::from_reqwest)?;
192 let result = parse_response::<T>(response).await;
193 self.log_response(&result);
194 result
195 }
196
197 pub(crate) async fn signed_put<T: DeserializeOwned>(
199 &self,
200 path: &str,
201 params: &[(&str, &str)],
202 ) -> Result<ApiResponse<T>, AsterDexError> {
203 let full_url = self.build_signed_url(path, params)?;
204 tracing::debug!(method = "PUT", path = path, signed = true, "REST request");
205 let response = self
206 .http
207 .put(&full_url)
208 .send()
209 .await
210 .map_err(AsterDexError::from_reqwest)?;
211 let result = parse_response::<T>(response).await;
212 self.log_response(&result);
213 result
214 }
215
216 pub(crate) async fn signed_post_json<B: Serialize + ?Sized, T: DeserializeOwned>(
221 &self,
222 path: &str,
223 query_params: &[(&str, &str)],
224 body: &B,
225 ) -> Result<ApiResponse<T>, AsterDexError> {
226 let full_url = self.build_signed_url(path, query_params)?;
227 tracing::debug!(method = "POST", path = path, signed = true, "REST request (JSON body)");
228 let response = self
229 .http
230 .post(&full_url)
231 .json(body)
232 .send()
233 .await
234 .map_err(AsterDexError::from_reqwest)?;
235 let result = parse_response::<T>(response).await;
236 self.log_response(&result);
237 result
238 }
239
240 pub async fn raw_get(
265 &self,
266 path: &str,
267 params: &[(&str, &str)],
268 ) -> Result<ApiResponse<serde_json::Value>, AsterDexError> {
269 self.get(path, params).await
270 }
271
272 pub async fn raw_post(
279 &self,
280 path: &str,
281 params: &[(&str, &str)],
282 ) -> Result<ApiResponse<serde_json::Value>, AsterDexError> {
283 self.post(path, params).await
284 }
285
286 pub async fn raw_delete(
293 &self,
294 path: &str,
295 params: &[(&str, &str)],
296 ) -> Result<ApiResponse<serde_json::Value>, AsterDexError> {
297 self.delete(path, params).await
298 }
299
300 pub async fn raw_signed_get(
310 &self,
311 path: &str,
312 params: &[(&str, &str)],
313 ) -> Result<ApiResponse<serde_json::Value>, AsterDexError> {
314 self.signed_get(path, params).await
315 }
316
317 pub async fn raw_signed_post(
327 &self,
328 path: &str,
329 params: &[(&str, &str)],
330 ) -> Result<ApiResponse<serde_json::Value>, AsterDexError> {
331 self.signed_post(path, params).await
332 }
333
334 pub async fn raw_signed_delete(
344 &self,
345 path: &str,
346 params: &[(&str, &str)],
347 ) -> Result<ApiResponse<serde_json::Value>, AsterDexError> {
348 self.signed_delete(path, params).await
349 }
350
351 fn build_signed_url(
353 &self,
354 path: &str,
355 params: &[(&str, &str)],
356 ) -> Result<String, AsterDexError> {
357 let creds = self.require_credentials()?;
358 let signed_query = creds.sign_query(params)?;
359 let base = self.base_url.as_str().trim_end_matches('/');
360 Ok(format!("{base}{path}?{signed_query}"))
361 }
362
363 fn require_credentials(&self) -> Result<&Credentials, AsterDexError> {
364 self.credentials
365 .as_ref()
366 .ok_or_else(|| AsterDexError::ConfigError {
367 message: "credentials required for signed endpoint".to_string(),
368 })
369 }
370
371 fn build_url(
372 &self,
373 path: &str,
374 params: &[(&str, &str)],
375 ) -> Result<String, AsterDexError> {
376 let base = self.base_url.as_str().trim_end_matches('/');
377 if params.is_empty() {
378 Ok(format!("{base}{path}"))
379 } else {
380 let qs = url::form_urlencoded::Serializer::new(String::new())
381 .extend_pairs(params)
382 .finish();
383 Ok(format!("{base}{path}?{qs}"))
384 }
385 }
386
387 fn log_response<T>(&self, result: &Result<ApiResponse<T>, AsterDexError>) {
388 match result {
389 Ok(r) => tracing::debug!(
390 used_weight = ?r.used_weight,
391 order_count = ?r.order_count,
392 "REST response OK"
393 ),
394 Err(e) => tracing::warn!(error_kind = ?e, "REST error"),
395 }
396 }
397}
398
399fn normalize_url(url: &str) -> Result<Url, AsterDexError> {
400 let url = if url.ends_with('/') {
401 url.to_string()
402 } else {
403 format!("{url}/")
404 };
405 Url::parse(&url).map_err(|e| AsterDexError::ConfigError {
406 message: format!("invalid base URL: {e}"),
407 })
408}
409
410fn build_http_client() -> Result<Client, AsterDexError> {
411 Client::builder()
412 .use_rustls_tls()
413 .timeout(REQUEST_TIMEOUT)
414 .connect_timeout(CONNECT_TIMEOUT)
415 .pool_idle_timeout(POOL_IDLE_TIMEOUT)
416 .pool_max_idle_per_host(POOL_MAX_IDLE_PER_HOST)
417 .tcp_keepalive(TCP_KEEPALIVE)
418 .build()
419 .map_err(|e| AsterDexError::ConfigError {
420 message: format!("failed to build HTTP client: {e}"),
421 })
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427 use mockito::Server;
428
429 #[tokio::test]
431 async fn test_public_get_200() {
432 let mut server = Server::new_async().await;
433 let mock = server
434 .mock("GET", "/fapi/v3/ping")
435 .with_status(200)
436 .with_header("X-MBX-USED-WEIGHT-1MINUTE", "10")
437 .with_header("X-MBX-ORDER-COUNT-1MINUTE", "2")
438 .with_body("{}")
439 .create_async()
440 .await;
441
442 let client = RestClient::new_public(&server.url()).unwrap();
443 let result = client
444 .get::<serde_json::Value>("/fapi/v3/ping", &[])
445 .await
446 .unwrap();
447
448 assert_eq!(result.used_weight, Some(10));
449 assert_eq!(result.order_count, Some(2));
450 mock.assert_async().await;
451 }
452
453 #[tokio::test]
455 async fn test_api_error_4xx() {
456 let mut server = Server::new_async().await;
457 let mock = server
458 .mock("GET", "/fapi/v3/depth")
459 .with_status(400)
460 .with_header("content-type", "application/json")
461 .with_body(
462 r#"{"code":-1102,"msg":"Mandatory parameter 'symbol' was not sent"}"#,
463 )
464 .create_async()
465 .await;
466
467 let client = RestClient::new_public(&server.url()).unwrap();
468 let result: Result<ApiResponse<serde_json::Value>, _> =
469 client.get("/fapi/v3/depth", &[]).await;
470
471 assert!(matches!(
472 result,
473 Err(AsterDexError::ApiError { code: -1102, .. })
474 ));
475 if let Err(AsterDexError::ApiError { msg, .. }) = &result {
476 assert_eq!(msg, "Mandatory parameter 'symbol' was not sent");
477 }
478 mock.assert_async().await;
479 }
480
481 #[tokio::test]
483 async fn test_rate_limited_429() {
484 let mut server = Server::new_async().await;
485 let mock = server
486 .mock("GET", "/fapi/v3/ping")
487 .with_status(429)
488 .with_header("Retry-After", "30")
489 .create_async()
490 .await;
491
492 let client = RestClient::new_public(&server.url()).unwrap();
493 let result: Result<ApiResponse<serde_json::Value>, _> =
494 client.get("/fapi/v3/ping", &[]).await;
495
496 assert!(
497 matches!(result, Err(AsterDexError::RateLimited { retry_after: Some(d) }) if d.as_secs() == 30)
498 );
499 mock.assert_async().await;
500 }
501
502 #[tokio::test]
504 async fn test_ip_banned_418() {
505 let mut server = Server::new_async().await;
506 let mock = server
507 .mock("GET", "/fapi/v3/ping")
508 .with_status(418)
509 .with_header("Retry-After", "7200")
510 .create_async()
511 .await;
512
513 let client = RestClient::new_public(&server.url()).unwrap();
514 let result: Result<ApiResponse<serde_json::Value>, _> =
515 client.get("/fapi/v3/ping", &[]).await;
516
517 assert!(
518 matches!(result, Err(AsterDexError::IpBanned { retry_after: Some(d) }) if d.as_secs() == 7200)
519 );
520 mock.assert_async().await;
521 }
522
523 #[tokio::test]
525 async fn test_server_error_5xx() {
526 let mut server = Server::new_async().await;
527 let mock = server
528 .mock("GET", "/fapi/v3/ping")
529 .with_status(500)
530 .with_body("Internal Server Error")
531 .create_async()
532 .await;
533
534 let client = RestClient::new_public(&server.url()).unwrap();
535 let result: Result<ApiResponse<serde_json::Value>, _> =
536 client.get("/fapi/v3/ping", &[]).await;
537
538 assert!(
539 matches!(result, Err(AsterDexError::HttpError { status: 500, ref body }) if body == "Internal Server Error")
540 );
541 mock.assert_async().await;
542 }
543}