Skip to main content

asterdex_sdk/rest/
client.rs

1// US-003: RestClient — core HTTP client
2
3use 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";
14// Conservative defaults tuned for exchange REST APIs.
15// Override by passing a custom `reqwest::Client` if needed in future.
16const 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
22/// Async REST client for AsterDex Futures V3 API.
23///
24/// Supports both public (unsigned) and authenticated (signed) endpoints.
25/// Use `new_public` for public-only access, `from_env` or `new` for authenticated access.
26pub struct RestClient {
27    pub(crate) base_url: Url,
28    pub(crate) credentials: Option<Credentials>,
29    pub(crate) http: Client,
30}
31
32impl RestClient {
33    /// Construct authenticated client from environment variables.
34    ///
35    /// Reads: `ASTERDEX_USER`, `ASTERDEX_SIGNER_ADDRESS`, `ASTERDEX_SIGNER_PRIVATE_KEY`.
36    /// Optional: `ASTERDEX_BASE_URL` (defaults to `https://fapi.asterdex.com`).
37    ///
38    /// # Errors
39    /// - `AsterDexError::ConfigError` if any required env var is missing or invalid
40    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    /// Construct authenticated client with explicit base URL and credentials.
48    ///
49    /// # Errors
50    /// - `AsterDexError::ConfigError` if base URL is invalid
51    pub fn new(base_url: &str, credentials: Credentials) -> Result<Self, AsterDexError> {
52        Self::new_with_credentials(base_url, credentials)
53    }
54
55    /// Construct unauthenticated (public) client.
56    ///
57    /// Only public endpoints (unsigned GET) will work. Signed endpoints will return
58    /// `AsterDexError::ConfigError`.
59    ///
60    /// # Errors
61    /// - `AsterDexError::ConfigError` if base URL is invalid
62    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    // --- Internal: unsigned GET ---
86    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    // --- Internal: unsigned POST ---
105    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    // --- Internal: unsigned DELETE ---
122    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    // --- Internal: SIGNED GET ---
139    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    // --- Internal: SIGNED POST (query string only, empty body) ---
158    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    // --- Internal: SIGNED DELETE ---
178    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    // --- Internal: SIGNED PUT (for listenKey renewal) ---
197    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    // -------------------------------------------------------------------------
217    // US-016: Raw JSON passthrough methods (ADR-011)
218    // These delegate to the internal typed methods with T = serde_json::Value.
219    // Error mapping (429 → RateLimited, etc.) applies identically to typed methods.
220    // -------------------------------------------------------------------------
221
222    /// Make an unsigned GET request and return the raw JSON response.
223    ///
224    /// Use when the typed response struct does not cover all fields, or for debugging.
225    /// Error mapping (429 → `RateLimited`, etc.) applies identically to typed methods.
226    ///
227    /// # Errors
228    /// - `AsterDexError::ApiError` — if the server returns a 4xx error with `{code, msg}`
229    /// - `AsterDexError::RateLimited` — if the server returns 429
230    /// - `AsterDexError::NetworkError` — if a network-level error occurs
231    ///
232    /// # Examples
233    /// ```no_run
234    /// # async fn example(client: &asterdex_sdk::RestClient) -> Result<(), asterdex_sdk::AsterDexError> {
235    /// let resp = client.raw_get("/fapi/v3/exchangeInfo", &[]).await?;
236    /// println!("{}", resp.data);
237    /// # Ok(())
238    /// # }
239    /// ```
240    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    /// Make an unsigned POST request and return the raw JSON response.
249    ///
250    /// # Errors
251    /// - `AsterDexError::ApiError` — if the server returns a 4xx error with `{code, msg}`
252    /// - `AsterDexError::RateLimited` — if the server returns 429
253    /// - `AsterDexError::NetworkError` — if a network-level error occurs
254    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    /// Make an unsigned DELETE request and return the raw JSON response.
263    ///
264    /// # Errors
265    /// - `AsterDexError::ApiError` — if the server returns a 4xx error with `{code, msg}`
266    /// - `AsterDexError::RateLimited` — if the server returns 429
267    /// - `AsterDexError::NetworkError` — if a network-level error occurs
268    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    /// Make a signed GET request and return the raw JSON response (EIP-712 authenticated).
277    ///
278    /// Requires credentials to be set (use `RestClient::from_env()`).
279    ///
280    /// # Errors
281    /// - `AsterDexError::ConfigError` — if credentials are not set
282    /// - `AsterDexError::ApiError` — if the server returns a 4xx error with `{code, msg}`
283    /// - `AsterDexError::RateLimited` — if the server returns 429
284    /// - `AsterDexError::NetworkError` — if a network-level error occurs
285    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    /// Make a signed POST request and return the raw JSON response.
294    ///
295    /// Requires credentials to be set (use `RestClient::from_env()`).
296    ///
297    /// # Errors
298    /// - `AsterDexError::ConfigError` — if credentials are not set
299    /// - `AsterDexError::ApiError` — if the server returns a 4xx error with `{code, msg}`
300    /// - `AsterDexError::RateLimited` — if the server returns 429
301    /// - `AsterDexError::NetworkError` — if a network-level error occurs
302    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    /// Make a signed DELETE request and return the raw JSON response.
311    ///
312    /// Requires credentials to be set (use `RestClient::from_env()`).
313    ///
314    /// # Errors
315    /// - `AsterDexError::ConfigError` — if credentials are not set
316    /// - `AsterDexError::ApiError` — if the server returns a 4xx error with `{code, msg}`
317    /// - `AsterDexError::RateLimited` — if the server returns 429
318    /// - `AsterDexError::NetworkError` — if a network-level error occurs
319    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    /// Build a signed URL: sign params with EIP-712 and append `&signature=...`.
328    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    // US-003: Test public GET 200 — verify Ok(ApiResponse) with rate limit headers
406    #[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    // US-003: Test 4xx API error maps to AsterDexError::ApiError
430    #[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    // US-003: Test HTTP 429 maps to AsterDexError::RateLimited
458    #[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    // US-003: Test HTTP 418 maps to AsterDexError::IpBanned
479    #[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    // US-003: Test 5xx maps to AsterDexError::HttpError
500    #[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}