Skip to main content

asterdex_sdk/rest/
client.rs

1// US-003: RestClient — core HTTP client
2
3use 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";
15// Conservative defaults tuned for exchange REST APIs.
16// Override by passing a custom `reqwest::Client` if needed in future.
17const 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
23/// Async REST client for AsterDex Futures V3 API.
24///
25/// Supports both public (unsigned) and authenticated (signed) endpoints.
26/// Use `new_public` for public-only access, `from_env` or `new` for authenticated access.
27pub struct RestClient {
28    pub(crate) base_url: Url,
29    pub(crate) credentials: Option<Credentials>,
30    pub(crate) http: Client,
31}
32
33impl RestClient {
34    /// Construct authenticated client from environment variables.
35    ///
36    /// Reads: `ASTERDEX_USER`, `ASTERDEX_SIGNER_ADDRESS`, `ASTERDEX_SIGNER_PRIVATE_KEY`.
37    /// Optional: `ASTERDEX_BASE_URL` (defaults to `https://fapi.asterdex.com`).
38    ///
39    /// # Errors
40    /// - `AsterDexError::ConfigError` if any required env var is missing or invalid
41    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    /// Construct authenticated client with explicit base URL and credentials.
49    ///
50    /// # Errors
51    /// - `AsterDexError::ConfigError` if base URL is invalid
52    pub fn new(base_url: &str, credentials: Credentials) -> Result<Self, AsterDexError> {
53        Self::new_with_credentials(base_url, credentials)
54    }
55
56    /// Construct unauthenticated (public) client.
57    ///
58    /// Only public endpoints (unsigned GET) will work. Signed endpoints will return
59    /// `AsterDexError::ConfigError`.
60    ///
61    /// # Errors
62    /// - `AsterDexError::ConfigError` if base URL is invalid
63    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    // --- Internal: unsigned GET ---
87    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    // --- Internal: unsigned POST ---
106    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    // --- Internal: unsigned DELETE ---
123    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    // --- Internal: SIGNED GET ---
140    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    // --- Internal: SIGNED POST (query string only, empty body) ---
159    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    // --- Internal: SIGNED DELETE ---
179    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    // --- Internal: SIGNED PUT (for listenKey renewal) ---
198    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    // --- Internal: SIGNED POST with JSON body (for batchOrders) ---
217    ///
218    /// Generic over `Serialize` to avoid the `serde_json::to_value` → `Value` → bytes
219    /// round trip; reqwest serializes the body directly from `&B`.
220    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    // -------------------------------------------------------------------------
241    // US-016: Raw JSON passthrough methods (ADR-011)
242    // These delegate to the internal typed methods with T = serde_json::Value.
243    // Error mapping (429 → RateLimited, etc.) applies identically to typed methods.
244    // -------------------------------------------------------------------------
245
246    /// Make an unsigned GET request and return the raw JSON response.
247    ///
248    /// Use when the typed response struct does not cover all fields, or for debugging.
249    /// Error mapping (429 → `RateLimited`, etc.) applies identically to typed methods.
250    ///
251    /// # Errors
252    /// - `AsterDexError::ApiError` — if the server returns a 4xx error with `{code, msg}`
253    /// - `AsterDexError::RateLimited` — if the server returns 429
254    /// - `AsterDexError::NetworkError` — if a network-level error occurs
255    ///
256    /// # Examples
257    /// ```no_run
258    /// # async fn example(client: &asterdex_sdk::RestClient) -> Result<(), asterdex_sdk::AsterDexError> {
259    /// let resp = client.raw_get("/fapi/v3/exchangeInfo", &[]).await?;
260    /// println!("{}", resp.data);
261    /// # Ok(())
262    /// # }
263    /// ```
264    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    /// Make an unsigned POST request and return the raw JSON response.
273    ///
274    /// # Errors
275    /// - `AsterDexError::ApiError` — if the server returns a 4xx error with `{code, msg}`
276    /// - `AsterDexError::RateLimited` — if the server returns 429
277    /// - `AsterDexError::NetworkError` — if a network-level error occurs
278    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    /// Make an unsigned DELETE request and return the raw JSON response.
287    ///
288    /// # Errors
289    /// - `AsterDexError::ApiError` — if the server returns a 4xx error with `{code, msg}`
290    /// - `AsterDexError::RateLimited` — if the server returns 429
291    /// - `AsterDexError::NetworkError` — if a network-level error occurs
292    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    /// Make a signed GET request and return the raw JSON response (EIP-712 authenticated).
301    ///
302    /// Requires credentials to be set (use `RestClient::from_env()`).
303    ///
304    /// # Errors
305    /// - `AsterDexError::ConfigError` — if credentials are not set
306    /// - `AsterDexError::ApiError` — if the server returns a 4xx error with `{code, msg}`
307    /// - `AsterDexError::RateLimited` — if the server returns 429
308    /// - `AsterDexError::NetworkError` — if a network-level error occurs
309    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    /// Make a signed POST request and return the raw JSON response.
318    ///
319    /// Requires credentials to be set (use `RestClient::from_env()`).
320    ///
321    /// # Errors
322    /// - `AsterDexError::ConfigError` — if credentials are not set
323    /// - `AsterDexError::ApiError` — if the server returns a 4xx error with `{code, msg}`
324    /// - `AsterDexError::RateLimited` — if the server returns 429
325    /// - `AsterDexError::NetworkError` — if a network-level error occurs
326    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    /// Make a signed DELETE request and return the raw JSON response.
335    ///
336    /// Requires credentials to be set (use `RestClient::from_env()`).
337    ///
338    /// # Errors
339    /// - `AsterDexError::ConfigError` — if credentials are not set
340    /// - `AsterDexError::ApiError` — if the server returns a 4xx error with `{code, msg}`
341    /// - `AsterDexError::RateLimited` — if the server returns 429
342    /// - `AsterDexError::NetworkError` — if a network-level error occurs
343    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    /// Build a signed URL: sign params with EIP-712 and append `&signature=...`.
352    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    // US-003: Test public GET 200 — verify Ok(ApiResponse) with rate limit headers
430    #[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    // US-003: Test 4xx API error maps to AsterDexError::ApiError
454    #[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    // US-003: Test HTTP 429 maps to AsterDexError::RateLimited
482    #[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    // US-003: Test HTTP 418 maps to AsterDexError::IpBanned
503    #[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    // US-003: Test 5xx maps to AsterDexError::HttpError
524    #[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}