ccxt_exchanges/okx/
signed_request.rs

1//! Signed request builder for OKX API.
2//!
3//! This module provides a builder pattern for creating authenticated OKX API requests,
4//! encapsulating the common signing workflow used across all authenticated endpoints.
5//!
6//! # Overview
7//!
8//! The `OkxSignedRequestBuilder` eliminates code duplication by centralizing:
9//! - Credential validation
10//! - ISO 8601 timestamp generation
11//! - Parameter signing with HMAC-SHA256
12//! - Authentication header injection
13//! - HTTP request execution
14//!
15//! # Example
16//!
17//! ```no_run
18//! # use ccxt_exchanges::okx::Okx;
19//! # use ccxt_exchanges::okx::signed_request::HttpMethod;
20//! # use ccxt_core::ExchangeConfig;
21//! # async fn example() -> ccxt_core::Result<()> {
22//! let okx = Okx::new(ExchangeConfig::default())?;
23//!
24//! // Simple GET request
25//! let data = okx.signed_request("/api/v5/account/balance")
26//!     .execute()
27//!     .await?;
28//!
29//! // POST request with parameters
30//! let data = okx.signed_request("/api/v5/trade/order")
31//!     .method(HttpMethod::Post)
32//!     .param("instId", "BTC-USDT")
33//!     .param("tdMode", "cash")
34//!     .param("side", "buy")
35//!     .execute()
36//!     .await?;
37//! # Ok(())
38//! # }
39//! ```
40
41use super::Okx;
42use ccxt_core::{Error, ParseError, Result};
43use reqwest::header::HeaderMap;
44use serde_json::Value;
45use std::collections::BTreeMap;
46
47/// HTTP request methods supported by the signed request builder.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum HttpMethod {
50    /// GET request - parameters in query string
51    #[default]
52    Get,
53    /// POST request - parameters in JSON body
54    Post,
55    /// DELETE request - parameters in JSON body
56    Delete,
57}
58
59/// Builder for creating authenticated OKX API requests.
60///
61/// This builder encapsulates the common signing workflow:
62/// 1. Credential validation
63/// 2. ISO 8601 timestamp generation
64/// 3. Parameter signing with HMAC-SHA256
65/// 4. Authentication header injection
66/// 5. HTTP request execution
67///
68/// # Example
69///
70/// ```no_run
71/// # use ccxt_exchanges::okx::Okx;
72/// # use ccxt_exchanges::okx::signed_request::HttpMethod;
73/// # use ccxt_core::ExchangeConfig;
74/// # async fn example() -> ccxt_core::Result<()> {
75/// let okx = Okx::new(ExchangeConfig::default())?;
76///
77/// let data = okx.signed_request("/api/v5/account/balance")
78///     .param("instId", "BTC-USDT")
79///     .optional_param("limit", Some(100))
80///     .execute()
81///     .await?;
82/// # Ok(())
83/// # }
84/// ```
85pub struct OkxSignedRequestBuilder<'a> {
86    /// Reference to the OKX exchange instance
87    okx: &'a Okx,
88    /// Request parameters to be signed
89    params: BTreeMap<String, String>,
90    /// Request body for POST/DELETE requests
91    body: Option<Value>,
92    /// API endpoint path
93    endpoint: String,
94    /// HTTP method for the request
95    method: HttpMethod,
96}
97
98impl<'a> OkxSignedRequestBuilder<'a> {
99    /// Creates a new signed request builder.
100    ///
101    /// # Arguments
102    ///
103    /// * `okx` - Reference to the OKX exchange instance
104    /// * `endpoint` - API endpoint path (e.g., "/api/v5/account/balance")
105    ///
106    /// # Example
107    ///
108    /// ```no_run
109    /// # use ccxt_exchanges::okx::Okx;
110    /// # use ccxt_exchanges::okx::signed_request::OkxSignedRequestBuilder;
111    /// # use ccxt_core::ExchangeConfig;
112    /// let okx = Okx::new(ExchangeConfig::default()).unwrap();
113    /// let builder = OkxSignedRequestBuilder::new(&okx, "/api/v5/account/balance");
114    /// ```
115    pub fn new(okx: &'a Okx, endpoint: impl Into<String>) -> Self {
116        Self {
117            okx,
118            params: BTreeMap::new(),
119            body: None,
120            endpoint: endpoint.into(),
121            method: HttpMethod::default(),
122        }
123    }
124
125    /// Sets the HTTP method for the request.
126    ///
127    /// Default is GET.
128    ///
129    /// # Arguments
130    ///
131    /// * `method` - The HTTP method to use
132    ///
133    /// # Example
134    ///
135    /// ```no_run
136    /// # use ccxt_exchanges::okx::Okx;
137    /// # use ccxt_exchanges::okx::signed_request::HttpMethod;
138    /// # use ccxt_core::ExchangeConfig;
139    /// # async fn example() -> ccxt_core::Result<()> {
140    /// let okx = Okx::new(ExchangeConfig::default())?;
141    /// let data = okx.signed_request("/api/v5/trade/order")
142    ///     .method(HttpMethod::Post)
143    ///     .param("instId", "BTC-USDT")
144    ///     .execute()
145    ///     .await?;
146    /// # Ok(())
147    /// # }
148    /// ```
149    pub fn method(mut self, method: HttpMethod) -> Self {
150        self.method = method;
151        self
152    }
153
154    /// Adds a required parameter.
155    ///
156    /// # Arguments
157    ///
158    /// * `key` - Parameter name
159    /// * `value` - Parameter value (will be converted to string)
160    ///
161    /// # Example
162    ///
163    /// ```no_run
164    /// # use ccxt_exchanges::okx::Okx;
165    /// # use ccxt_core::ExchangeConfig;
166    /// # async fn example() -> ccxt_core::Result<()> {
167    /// let okx = Okx::new(ExchangeConfig::default())?;
168    /// let data = okx.signed_request("/api/v5/trade/orders-history")
169    ///     .param("instId", "BTC-USDT")
170    ///     .param("limit", 100)
171    ///     .execute()
172    ///     .await?;
173    /// # Ok(())
174    /// # }
175    /// ```
176    pub fn param(mut self, key: impl Into<String>, value: impl ToString) -> Self {
177        self.params.insert(key.into(), value.to_string());
178        self
179    }
180
181    /// Adds an optional parameter (only if value is Some).
182    ///
183    /// # Arguments
184    ///
185    /// * `key` - Parameter name
186    /// * `value` - Optional parameter value
187    ///
188    /// # Example
189    ///
190    /// ```no_run
191    /// # use ccxt_exchanges::okx::Okx;
192    /// # use ccxt_core::ExchangeConfig;
193    /// # async fn example() -> ccxt_core::Result<()> {
194    /// let okx = Okx::new(ExchangeConfig::default())?;
195    /// let since: Option<i64> = Some(1234567890000);
196    /// let limit: Option<u32> = None;
197    ///
198    /// let data = okx.signed_request("/api/v5/trade/orders-history")
199    ///     .param("instId", "BTC-USDT")
200    ///     .optional_param("after", since)
201    ///     .optional_param("limit", limit)  // Won't be added since it's None
202    ///     .execute()
203    ///     .await?;
204    /// # Ok(())
205    /// # }
206    /// ```
207    pub fn optional_param<T: ToString>(mut self, key: impl Into<String>, value: Option<T>) -> Self {
208        if let Some(v) = value {
209            self.params.insert(key.into(), v.to_string());
210        }
211        self
212    }
213
214    /// Adds multiple parameters from a BTreeMap.
215    ///
216    /// # Arguments
217    ///
218    /// * `params` - Map of parameter key-value pairs
219    ///
220    /// # Example
221    ///
222    /// ```no_run
223    /// # use ccxt_exchanges::okx::Okx;
224    /// # use ccxt_core::ExchangeConfig;
225    /// # use std::collections::BTreeMap;
226    /// # async fn example() -> ccxt_core::Result<()> {
227    /// let okx = Okx::new(ExchangeConfig::default())?;
228    /// let mut params = BTreeMap::new();
229    /// params.insert("instId".to_string(), "BTC-USDT".to_string());
230    /// params.insert("tdMode".to_string(), "cash".to_string());
231    ///
232    /// let data = okx.signed_request("/api/v5/trade/order")
233    ///     .params(params)
234    ///     .execute()
235    ///     .await?;
236    /// # Ok(())
237    /// # }
238    /// ```
239    pub fn params(mut self, params: BTreeMap<String, String>) -> Self {
240        self.params.extend(params);
241        self
242    }
243
244    /// Sets the request body for POST/DELETE requests.
245    ///
246    /// # Arguments
247    ///
248    /// * `body` - JSON value to use as request body
249    ///
250    /// # Example
251    ///
252    /// ```no_run
253    /// # use ccxt_exchanges::okx::Okx;
254    /// # use ccxt_core::ExchangeConfig;
255    /// # use serde_json::json;
256    /// # async fn example() -> ccxt_core::Result<()> {
257    /// let okx = Okx::new(ExchangeConfig::default())?;
258    /// let body = json!({
259    ///     "instId": "BTC-USDT",
260    ///     "tdMode": "cash",
261    ///     "side": "buy"
262    /// });
263    ///
264    /// let data = okx.signed_request("/api/v5/trade/order")
265    ///     .method(ccxt_exchanges::okx::signed_request::HttpMethod::Post)
266    ///     .body(body)
267    ///     .execute()
268    ///     .await?;
269    /// # Ok(())
270    /// # }
271    /// ```
272    pub fn body(mut self, body: Value) -> Self {
273        self.body = Some(body);
274        self
275    }
276
277    /// Executes the signed request and returns the response.
278    ///
279    /// This method:
280    /// 1. Validates that credentials are configured
281    /// 2. Gets the current ISO 8601 timestamp
282    /// 3. Signs the request with HMAC-SHA256
283    /// 4. Adds authentication headers
284    /// 5. Executes the HTTP request
285    ///
286    /// # Returns
287    ///
288    /// Returns the raw `serde_json::Value` response for further parsing.
289    ///
290    /// # Errors
291    ///
292    /// - Returns authentication error if credentials are missing
293    /// - Returns network error if the request fails
294    /// - Returns parse error if response parsing fails
295    ///
296    /// # Example
297    ///
298    /// ```no_run
299    /// # use ccxt_exchanges::okx::Okx;
300    /// # use ccxt_core::ExchangeConfig;
301    /// # async fn example() -> ccxt_core::Result<()> {
302    /// let okx = Okx::new(ExchangeConfig::default())?;
303    /// let data = okx.signed_request("/api/v5/account/balance")
304    ///     .execute()
305    ///     .await?;
306    /// println!("Response: {:?}", data);
307    /// # Ok(())
308    /// # }
309    /// ```
310    pub async fn execute(self) -> Result<Value> {
311        // Step 1: Validate credentials
312        self.okx.check_required_credentials()?;
313
314        // Step 2: Get current ISO 8601 timestamp
315        let timestamp = chrono::Utc::now()
316            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
317            .to_string();
318
319        // Step 3: Get auth and sign request
320        let auth = self.okx.get_auth()?;
321
322        // Build the body string for signing
323        let body_str = if let Some(ref body) = self.body {
324            body.to_string()
325        } else if self.method == HttpMethod::Get {
326            String::new()
327        } else {
328            // For POST/DELETE without explicit body, use empty string
329            String::new()
330        };
331
332        // Sign the request
333        let signature = auth.sign(
334            &timestamp,
335            &method_to_string(self.method),
336            &self.endpoint,
337            &body_str,
338        );
339
340        // Step 4: Add authentication headers
341        let mut headers = HeaderMap::new();
342        auth.add_auth_headers(&mut headers, &timestamp, &signature);
343
344        // Add demo trading header if in sandbox mode
345        if self.okx.is_sandbox() {
346            if let Ok(header_value) = "1".parse() {
347                headers.insert("x-simulated-trading", header_value);
348            }
349        }
350
351        // Step 5: Execute HTTP request based on method
352        let urls = self.okx.urls();
353        let full_url = format!("{}{}", urls.rest, self.endpoint);
354
355        match self.method {
356            HttpMethod::Get => {
357                // Build query string for GET requests
358                let query_string = build_query_string(&self.params);
359                let url = if query_string.is_empty() {
360                    full_url
361                } else {
362                    format!("{}?{}", full_url, query_string)
363                };
364                self.okx.base().http_client.get(&url, Some(headers)).await
365            }
366            HttpMethod::Post => {
367                // Use explicit body if provided, otherwise use params as JSON
368                let body = if let Some(b) = self.body {
369                    b
370                } else {
371                    serde_json::to_value(&self.params).map_err(|e| {
372                        Error::from(ParseError::invalid_format(
373                            "data",
374                            format!("Failed to serialize request body: {}", e),
375                        ))
376                    })?
377                };
378                self.okx
379                    .base()
380                    .http_client
381                    .post(&full_url, Some(headers), Some(body))
382                    .await
383            }
384            HttpMethod::Delete => {
385                // Use explicit body if provided, otherwise use params as JSON
386                let body = if let Some(b) = self.body {
387                    b
388                } else {
389                    serde_json::to_value(&self.params).map_err(|e| {
390                        Error::from(ParseError::invalid_format(
391                            "data",
392                            format!("Failed to serialize request body: {}", e),
393                        ))
394                    })?
395                };
396                self.okx
397                    .base()
398                    .http_client
399                    .delete(&full_url, Some(headers), Some(body))
400                    .await
401            }
402        }
403    }
404}
405
406/// Converts HttpMethod to string representation.
407fn method_to_string(method: HttpMethod) -> String {
408    match method {
409        HttpMethod::Get => "GET".to_string(),
410        HttpMethod::Post => "POST".to_string(),
411        HttpMethod::Delete => "DELETE".to_string(),
412    }
413}
414
415/// Builds a URL-encoded query string from parameters.
416///
417/// Parameters are sorted alphabetically by key (due to BTreeMap ordering).
418fn build_query_string(params: &BTreeMap<String, String>) -> String {
419    params
420        .iter()
421        .map(|(k, v)| format!("{}={}", k, v))
422        .collect::<Vec<_>>()
423        .join("&")
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use ccxt_core::ExchangeConfig;
430
431    #[test]
432    fn test_http_method_default() {
433        assert_eq!(HttpMethod::default(), HttpMethod::Get);
434    }
435
436    #[test]
437    fn test_builder_construction() {
438        let config = ExchangeConfig::default();
439        let okx = Okx::new(config).unwrap();
440
441        let builder = OkxSignedRequestBuilder::new(&okx, "/api/v5/account/balance");
442
443        assert_eq!(builder.endpoint, "/api/v5/account/balance");
444        assert_eq!(builder.method, HttpMethod::Get);
445        assert!(builder.params.is_empty());
446        assert!(builder.body.is_none());
447    }
448
449    #[test]
450    fn test_builder_method_chaining() {
451        let config = ExchangeConfig::default();
452        let okx = Okx::new(config).unwrap();
453
454        let builder = OkxSignedRequestBuilder::new(&okx, "/api/v5/trade/order")
455            .method(HttpMethod::Post)
456            .param("instId", "BTC-USDT")
457            .param("side", "buy");
458
459        assert_eq!(builder.method, HttpMethod::Post);
460        assert_eq!(builder.params.get("instId"), Some(&"BTC-USDT".to_string()));
461        assert_eq!(builder.params.get("side"), Some(&"buy".to_string()));
462    }
463
464    #[test]
465    fn test_builder_param() {
466        let config = ExchangeConfig::default();
467        let okx = Okx::new(config).unwrap();
468
469        let builder = OkxSignedRequestBuilder::new(&okx, "/test")
470            .param("string_param", "value")
471            .param("int_param", 123)
472            .param("float_param", 45.67);
473
474        assert_eq!(
475            builder.params.get("string_param"),
476            Some(&"value".to_string())
477        );
478        assert_eq!(builder.params.get("int_param"), Some(&"123".to_string()));
479        assert_eq!(
480            builder.params.get("float_param"),
481            Some(&"45.67".to_string())
482        );
483    }
484
485    #[test]
486    fn test_builder_optional_param_some() {
487        let config = ExchangeConfig::default();
488        let okx = Okx::new(config).unwrap();
489
490        let builder = OkxSignedRequestBuilder::new(&okx, "/test")
491            .optional_param("limit", Some(100u32))
492            .optional_param("after", Some(1234567890i64));
493
494        assert_eq!(builder.params.get("limit"), Some(&"100".to_string()));
495        assert_eq!(builder.params.get("after"), Some(&"1234567890".to_string()));
496    }
497
498    #[test]
499    fn test_builder_optional_param_none() {
500        let config = ExchangeConfig::default();
501        let okx = Okx::new(config).unwrap();
502
503        let none_value: Option<u32> = None;
504        let builder =
505            OkxSignedRequestBuilder::new(&okx, "/test").optional_param("limit", none_value);
506
507        assert!(builder.params.get("limit").is_none());
508    }
509
510    #[test]
511    fn test_builder_params_bulk() {
512        let config = ExchangeConfig::default();
513        let okx = Okx::new(config).unwrap();
514
515        let mut params = BTreeMap::new();
516        params.insert("instId".to_string(), "BTC-USDT".to_string());
517        params.insert("side".to_string(), "buy".to_string());
518
519        let builder = OkxSignedRequestBuilder::new(&okx, "/test").params(params);
520
521        assert_eq!(builder.params.get("instId"), Some(&"BTC-USDT".to_string()));
522        assert_eq!(builder.params.get("side"), Some(&"buy".to_string()));
523    }
524
525    #[test]
526    fn test_builder_body() {
527        let config = ExchangeConfig::default();
528        let okx = Okx::new(config).unwrap();
529
530        let body = serde_json::json!({
531            "instId": "BTC-USDT",
532            "side": "buy"
533        });
534
535        let builder = OkxSignedRequestBuilder::new(&okx, "/test").body(body.clone());
536
537        assert_eq!(builder.body, Some(body));
538    }
539
540    #[test]
541    fn test_builder_all_http_methods() {
542        let config = ExchangeConfig::default();
543        let okx = Okx::new(config).unwrap();
544
545        // Test all HTTP methods can be set
546        let get_builder = OkxSignedRequestBuilder::new(&okx, "/test").method(HttpMethod::Get);
547        assert_eq!(get_builder.method, HttpMethod::Get);
548
549        let post_builder = OkxSignedRequestBuilder::new(&okx, "/test").method(HttpMethod::Post);
550        assert_eq!(post_builder.method, HttpMethod::Post);
551
552        let delete_builder = OkxSignedRequestBuilder::new(&okx, "/test").method(HttpMethod::Delete);
553        assert_eq!(delete_builder.method, HttpMethod::Delete);
554    }
555
556    #[test]
557    fn test_builder_parameter_ordering() {
558        let config = ExchangeConfig::default();
559        let okx = Okx::new(config).unwrap();
560
561        // Add parameters in non-alphabetical order
562        let builder = OkxSignedRequestBuilder::new(&okx, "/test")
563            .param("zebra", "z")
564            .param("apple", "a")
565            .param("mango", "m");
566
567        // BTreeMap should maintain alphabetical order
568        let keys: Vec<_> = builder.params.keys().collect();
569        assert_eq!(keys, vec!["apple", "mango", "zebra"]);
570    }
571
572    #[test]
573    fn test_method_to_string() {
574        assert_eq!(method_to_string(HttpMethod::Get), "GET");
575        assert_eq!(method_to_string(HttpMethod::Post), "POST");
576        assert_eq!(method_to_string(HttpMethod::Delete), "DELETE");
577    }
578
579    #[test]
580    fn test_build_query_string() {
581        let mut params = BTreeMap::new();
582        params.insert("instId".to_string(), "BTC-USDT".to_string());
583        params.insert("side".to_string(), "buy".to_string());
584        params.insert("amount".to_string(), "1.5".to_string());
585
586        let query = build_query_string(&params);
587
588        // BTreeMap maintains alphabetical order
589        assert_eq!(query, "amount=1.5&instId=BTC-USDT&side=buy");
590    }
591
592    #[test]
593    fn test_build_query_string_empty() {
594        let params = BTreeMap::new();
595        let query = build_query_string(&params);
596        assert!(query.is_empty());
597    }
598}
599
600#[cfg(test)]
601mod property_tests {
602    use super::*;
603    use ccxt_core::ExchangeConfig;
604    use proptest::prelude::*;
605
606    // Strategy to generate valid parameter keys (alphanumeric, non-empty)
607    fn param_key_strategy() -> impl Strategy<Value = String> {
608        "[a-zA-Z][a-zA-Z0-9]{0,19}".prop_map(|s| s)
609    }
610
611    // Strategy to generate valid parameter values
612    fn param_value_strategy() -> impl Strategy<Value = String> {
613        "[a-zA-Z0-9._-]{1,50}".prop_map(|s| s)
614    }
615
616    // Strategy to generate a BTreeMap of parameters
617    fn params_strategy() -> impl Strategy<Value = BTreeMap<String, String>> {
618        proptest::collection::btree_map(param_key_strategy(), param_value_strategy(), 0..10)
619    }
620
621    proptest! {
622        #![proptest_config(ProptestConfig::with_cases(100))]
623
624        /// **Feature: exchange-signed-request-refactor, Property 1: Fluent API Method Chaining**
625        ///
626        /// *For any* sequence of builder method calls (param, optional_param, params, method),
627        /// each method SHALL return Self allowing further method chaining, and the final execute()
628        /// call SHALL consume the builder.
629        ///
630        /// **Validates: Requirements 1.1**
631        #[test]
632        fn prop_fluent_api_method_chaining(
633            params in params_strategy(),
634            other_key in param_key_strategy(),
635            other_value in param_value_strategy()
636        ) {
637            let config = ExchangeConfig::default();
638            let okx = Okx::new(config).unwrap();
639
640            // Build with method chaining
641            let builder = OkxSignedRequestBuilder::new(&okx, "/test")
642                .method(HttpMethod::Post)
643                .params(params.clone())
644                .param(&other_key, &other_value)
645                .optional_param("optional", Some("value"));
646
647            // Verify all parameters are present
648            for (key, value) in &params {
649                prop_assert_eq!(builder.params.get(key), Some(value));
650            }
651            prop_assert_eq!(builder.params.get(&other_key), Some(&other_value));
652            prop_assert_eq!(builder.params.get("optional"), Some(&"value".to_string()));
653            prop_assert_eq!(builder.method, HttpMethod::Post);
654        }
655
656        /// **Feature: exchange-signed-request-refactor, Property 5: Parameter Methods Correctly Populate Params**
657        ///
658        /// *For any* combination of param(), optional_param(), and params() calls:
659        /// - param(key, value) SHALL always add the key-value pair
660        /// - optional_param(key, Some(value)) SHALL add the key-value pair
661        /// - optional_param(key, None) SHALL NOT add any key-value pair
662        /// - params(map) SHALL add all key-value pairs from the map
663        ///
664        /// **Validates: Requirements 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8**
665        #[test]
666        fn prop_optional_param_conditional_addition(
667            key in param_key_strategy(),
668            value in proptest::option::of(param_value_strategy()),
669            other_key in param_key_strategy(),
670            other_value in param_value_strategy()
671        ) {
672            let config = ExchangeConfig::default();
673            let okx = Okx::new(config).unwrap();
674
675            // Build with optional parameter
676            let builder = OkxSignedRequestBuilder::new(&okx, "/test")
677                .param(&other_key, &other_value)
678                .optional_param(&key, value.clone());
679
680            // Property: If value is Some, parameter should be present with correct value
681            // If value is None, parameter should NOT be present
682            match value {
683                Some(v) => {
684                    prop_assert!(
685                        builder.params.contains_key(&key),
686                        "Parameter {} should be present when value is Some",
687                        key
688                    );
689                    prop_assert_eq!(
690                        builder.params.get(&key).unwrap(),
691                        &v,
692                        "Parameter {} should have correct value",
693                        key
694                    );
695                }
696                None => {
697                    // Only check if key is different from other_key
698                    if key != other_key {
699                        prop_assert!(
700                            !builder.params.contains_key(&key),
701                            "Parameter {} should NOT be present when value is None",
702                            key
703                        );
704                    }
705                }
706            }
707
708            // Property: Other parameters should always be present
709            prop_assert!(
710                builder.params.contains_key(&other_key),
711                "Other parameter {} should always be present",
712                other_key
713            );
714            prop_assert_eq!(
715                builder.params.get(&other_key).unwrap(),
716                &other_value,
717                "Other parameter {} should have correct value",
718                other_key
719            );
720        }
721    }
722}