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 ×tamp,
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, ×tamp, &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(¶ms);
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(¶ms);
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 ¶ms {
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}