Skip to main content

bybit_api/
client.rs

1//! HTTP client for Bybit REST API.
2
3use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
4use serde::de::DeserializeOwned;
5use serde::Serialize;
6use tracing::{debug, warn};
7
8use crate::auth::{generate_signature, get_timestamp};
9use crate::config::ClientConfig;
10use crate::constants::*;
11use crate::error::{BybitError, Result};
12
13/// JSON keys whose values must be masked before being written to debug logs.
14///
15/// Match is case-insensitive on the unquoted key text. We deliberately do not
16/// try to be exhaustive — anything that looks credential-shaped goes here.
17const SENSITIVE_JSON_KEYS: &[&str] = &[
18    "secret",
19    "password",
20    "apiSecret",
21    "api_secret",
22    "apiKey",
23    "api_key",
24    "privateKey",
25    "private_key",
26    "token",
27    "sign",
28];
29
30/// Mask credential values in a JSON body string before logging.
31///
32/// This is a defensive last line of defense — typed credential fields should
33/// already use [`crate::models::RedactedString`]. The mask is purely textual
34/// (substring of a JSON dump), so it works on both serialized request bodies
35/// and raw HTTP response text.
36fn mask_sensitive(json_body: &str) -> String {
37    let mut out = json_body.to_owned();
38    for key in SENSITIVE_JSON_KEYS {
39        // Match `"<key>":"<anything-not-double-quote>"` (the most common JSON shape).
40        // Use a simple linear scan: find `"<key>":"`, then replace up to the next `"`.
41        let needle = format!("\"{}\":\"", key);
42        let needle_lower = needle.to_lowercase();
43        let mut cursor = 0usize;
44        loop {
45            // Case-insensitive find: scan windows of out from cursor.
46            let lower_remaining = out[cursor..].to_lowercase();
47            let Some(rel) = lower_remaining.find(&needle_lower) else {
48                break;
49            };
50            let start = cursor + rel + needle.len();
51            // Find the closing quote (no escape support — Bybit secrets do not contain `"`).
52            let Some(end_rel) = out[start..].find('"') else {
53                break;
54            };
55            let end = start + end_rel;
56            out.replace_range(start..end, "***REDACTED***");
57            cursor = start + "***REDACTED***".len();
58        }
59    }
60    out
61}
62
63/// Build a form-urlencoded query string used both for HMAC signing and the
64/// request URL. The signed bytes and the bytes Bybit reconstructs from the
65/// wire URL must match exactly, so the same encoder is the only safe source.
66fn encode_query(params: &[(&str, &str)]) -> String {
67    url::form_urlencoded::Serializer::new(String::new())
68        .extend_pairs(params.iter().copied())
69        .finish()
70}
71
72#[cfg(test)]
73mod mask_tests {
74    use super::mask_sensitive;
75
76    #[test]
77    fn masks_secret_field() {
78        let input = r#"{"apiKey":"k1","secret":"superSecretValue","other":"plain"}"#;
79        let out = mask_sensitive(input);
80        assert!(out.contains("***REDACTED***"));
81        assert!(!out.contains("superSecretValue"));
82        assert!(!out.contains("k1"));
83        assert!(out.contains("plain"));
84    }
85
86    #[test]
87    fn masks_password_case_insensitively() {
88        let input = r#"{"Password":"p@ss","apisecret":"x"}"#;
89        let out = mask_sensitive(input);
90        assert!(!out.contains("p@ss"));
91        assert!(!out.contains("\"x\""));
92    }
93
94    #[test]
95    fn leaves_unrelated_keys_intact() {
96        let input = r#"{"category":"linear","symbol":"BTCUSDT"}"#;
97        assert_eq!(mask_sensitive(input), input);
98    }
99}
100
101#[cfg(test)]
102mod query_tests {
103    use super::encode_query;
104
105    #[test]
106    fn empty_params_produce_empty_string() {
107        assert_eq!(encode_query(&[]), "");
108    }
109
110    #[test]
111    fn percent_encodes_base64url_cursor() {
112        // Bybit pagination cursors are base64url and routinely contain
113        // `+`, `/`, `=`. If the signed string differs from the wire query
114        // string by even one byte, Bybit returns `10004 sign error`.
115        let qs = encode_query(&[("cursor", "abc+/=def"), ("limit", "50")]);
116        assert_eq!(qs, "cursor=abc%2B%2F%3Ddef&limit=50");
117    }
118
119    #[test]
120    fn preserves_param_order() {
121        let qs = encode_query(&[("z", "1"), ("a", "2"), ("m", "3")]);
122        assert_eq!(qs, "z=1&a=2&m=3");
123    }
124
125    #[test]
126    fn encodes_ampersand_in_value() {
127        let qs = encode_query(&[("symbol", "BTC&ETH")]);
128        assert_eq!(qs, "symbol=BTC%26ETH");
129    }
130}
131
132/// API response wrapper from Bybit.
133#[derive(Debug, serde::Deserialize)]
134#[serde(rename_all = "camelCase")]
135pub struct ApiResponse<T> {
136    /// Return code (0 = success)
137    pub ret_code: i32,
138    /// Return message
139    pub ret_msg: String,
140    /// Response data
141    pub result: T,
142    /// Extended info
143    #[serde(default)]
144    #[allow(dead_code)]
145    pub ret_ext_info: serde_json::Value,
146    /// Server time
147    #[allow(dead_code)]
148    pub time: u64,
149}
150
151/// Bybit HTTP API client.
152#[derive(Debug, Clone)]
153pub struct BybitClient {
154    config: ClientConfig,
155    http: reqwest::Client,
156}
157
158impl BybitClient {
159    /// Create a new client with the given configuration.
160    pub fn new(config: ClientConfig) -> Result<Self> {
161        let http = reqwest::Client::builder()
162            .timeout(config.timeout)
163            .build()
164            .map_err(BybitError::Http)?;
165
166        Ok(Self { config, http })
167    }
168
169    /// Create a new client with API credentials using default settings.
170    pub fn with_credentials(
171        api_key: impl Into<String>,
172        api_secret: impl Into<String>,
173    ) -> Result<Self> {
174        let config = ClientConfig::builder(api_key, api_secret).build();
175        Self::new(config)
176    }
177
178    /// Create a new client for testnet.
179    pub fn testnet(api_key: impl Into<String>, api_secret: impl Into<String>) -> Result<Self> {
180        let config = ClientConfig::builder(api_key, api_secret)
181            .base_url(TESTNET)
182            .build();
183        Self::new(config)
184    }
185
186    /// Create a new client for demo environment.
187    pub fn demo(api_key: impl Into<String>, api_secret: impl Into<String>) -> Result<Self> {
188        let config = ClientConfig::builder(api_key, api_secret)
189            .base_url(DEMO)
190            .build();
191        Self::new(config)
192    }
193
194    /// Get the client configuration.
195    pub fn config(&self) -> &ClientConfig {
196        &self.config
197    }
198
199    /// Send a public GET request (no authentication).
200    pub async fn get_public<T: DeserializeOwned>(
201        &self,
202        endpoint: &str,
203        params: &[(&str, &str)],
204    ) -> Result<T> {
205        let url = format!("{}{}", self.config.base_url, endpoint);
206
207        let response = tokio::time::timeout(
208            self.config.timeout,
209            self.http.get(&url).query(params).send(),
210        )
211        .await
212        .map_err(|_| BybitError::Timeout)?
213        .map_err(BybitError::Http)?;
214
215        self.parse_response(response).await
216    }
217
218    /// Send an authenticated GET request.
219    pub async fn get<T: DeserializeOwned>(
220        &self,
221        endpoint: &str,
222        params: &[(&str, &str)],
223    ) -> Result<T> {
224        let timestamp = get_timestamp();
225
226        // The HMAC payload MUST be byte-identical to the query string Bybit
227        // reconstructs from the wire URL. We pre-encode here and append the
228        // result to the URL ourselves (rather than letting reqwest re-encode
229        // via `.query()`), so values containing reserved chars — most notably
230        // `+`, `/`, `=` in base64url pagination cursors — produce a matching
231        // sign instead of a `10004 sign error`.
232        let query_string = encode_query(params);
233
234        let signature = generate_signature(
235            &self.config.api_secret,
236            timestamp,
237            &self.config.api_key,
238            self.config.recv_window,
239            &query_string,
240        );
241
242        let url = if query_string.is_empty() {
243            format!("{}{}", self.config.base_url, endpoint)
244        } else {
245            format!("{}{}?{}", self.config.base_url, endpoint, query_string)
246        };
247
248        let headers = self.build_auth_headers(timestamp, &signature);
249
250        let response = tokio::time::timeout(
251            self.config.timeout,
252            self.http.get(&url).headers(headers).send(),
253        )
254        .await
255        .map_err(|_| BybitError::Timeout)?
256        .map_err(BybitError::Http)?;
257
258        self.parse_response(response).await
259    }
260
261    /// Send an authenticated POST request.
262    pub async fn post<T: DeserializeOwned, B: Serialize>(
263        &self,
264        endpoint: &str,
265        body: &B,
266    ) -> Result<T> {
267        let url = format!("{}{}", self.config.base_url, endpoint);
268        let timestamp = get_timestamp();
269
270        let body_str = serde_json::to_string(body).map_err(|e| BybitError::Parse(e.to_string()))?;
271
272        let signature = generate_signature(
273            &self.config.api_secret,
274            timestamp,
275            &self.config.api_key,
276            self.config.recv_window,
277            &body_str,
278        );
279
280        let headers = self.build_auth_headers(timestamp, &signature);
281
282        if self.config.debug {
283            debug!("POST {} body: {}", url, mask_sensitive(&body_str));
284        }
285
286        let response = tokio::time::timeout(
287            self.config.timeout,
288            self.http
289                .post(&url)
290                .headers(headers)
291                .header(CONTENT_TYPE, "application/json")
292                .body(body_str)
293                .send(),
294        )
295        .await
296        .map_err(|_| BybitError::Timeout)?
297        .map_err(BybitError::Http)?;
298
299        self.parse_response(response).await
300    }
301
302    /// Build authentication headers.
303    fn build_auth_headers(&self, timestamp: u64, signature: &str) -> HeaderMap {
304        let mut headers = HeaderMap::new();
305
306        headers.insert(
307            HEADER_API_KEY,
308            HeaderValue::from_str(&self.config.api_key)
309                .unwrap_or_else(|_| HeaderValue::from_static("")),
310        );
311        headers.insert(
312            HEADER_TIMESTAMP,
313            HeaderValue::from_str(&timestamp.to_string())
314                .unwrap_or_else(|_| HeaderValue::from_static("0")),
315        );
316        headers.insert(
317            HEADER_SIGN,
318            HeaderValue::from_str(signature).unwrap_or_else(|_| HeaderValue::from_static("")),
319        );
320        headers.insert(HEADER_SIGN_TYPE, HeaderValue::from_static("2"));
321        headers.insert(
322            HEADER_RECV_WINDOW,
323            HeaderValue::from_str(&self.config.recv_window.to_string())
324                .unwrap_or_else(|_| HeaderValue::from_static("5000")),
325        );
326
327        headers
328    }
329
330    /// Parse API response and handle errors.
331    async fn parse_response<T: DeserializeOwned>(&self, response: reqwest::Response) -> Result<T> {
332        let status = response.status();
333        let text = response.text().await.map_err(BybitError::Http)?;
334
335        if self.config.debug {
336            debug!(
337                "Response status: {}, body: {}",
338                status,
339                mask_sensitive(&text)
340            );
341        }
342
343        if !status.is_success() {
344            // Try to parse as API error
345            if let Ok(api_resp) = serde_json::from_str::<ApiResponse<serde_json::Value>>(&text) {
346                return Err(BybitError::Api {
347                    code: api_resp.ret_code,
348                    msg: api_resp.ret_msg,
349                });
350            }
351            return Err(BybitError::Parse(format!(
352                "HTTP {} - {}",
353                status.as_u16(),
354                text
355            )));
356        }
357
358        // Parse successful response
359        let api_resp: ApiResponse<T> = serde_json::from_str(&text).map_err(|e| {
360            warn!("Failed to parse response: {}, body: {}", e, text);
361            BybitError::Parse(format!(
362                "JSON parse error: {} - body: {}",
363                e,
364                &text[..text.len().min(200)]
365            ))
366        })?;
367
368        // Check for API-level errors
369        if api_resp.ret_code != 0 {
370            return Err(BybitError::Api {
371                code: api_resp.ret_code,
372                msg: api_resp.ret_msg,
373            });
374        }
375
376        Ok(api_resp.result)
377    }
378}