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