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).unwrap_or_default()
349 } else {
350 String::new()
351 };
352
353 // Sign the request (Bybit uses hex encoding)
354 let signature = auth.sign(×tamp, recv_window, &sign_params);
355
356 // Step 4: Add authentication headers
357 let mut headers = HeaderMap::new();
358 auth.add_auth_headers(&mut headers, ×tamp, &signature, recv_window);
359 headers.insert("Content-Type", HeaderValue::from_static("application/json"));
360
361 // Step 5: Execute HTTP request based on method
362 let urls = self.bybit.urls();
363 let full_url = format!("{}{}", urls.rest, self.endpoint);
364
365 match self.method {
366 HttpMethod::Get => {
367 // Build query string for GET requests
368 let query_string = build_query_string(&self.params);
369 let url = if query_string.is_empty() {
370 full_url
371 } else {
372 format!("{}?{}", full_url, query_string)
373 };
374 self.bybit.base().http_client.get(&url, Some(headers)).await
375 }
376 HttpMethod::Post => {
377 // Use explicit body if provided, otherwise use params as JSON
378 let body = if let Some(b) = self.body {
379 b
380 } else {
381 serde_json::to_value(&self.params).map_err(|e| {
382 Error::from(ParseError::invalid_format(
383 "data",
384 format!("Failed to serialize request body: {}", e),
385 ))
386 })?
387 };
388 self.bybit
389 .base()
390 .http_client
391 .post(&full_url, Some(headers), Some(body))
392 .await
393 }
394 HttpMethod::Delete => {
395 // Use explicit body if provided, otherwise use params as JSON
396 let body = if let Some(b) = self.body {
397 b
398 } else {
399 serde_json::to_value(&self.params).map_err(|e| {
400 Error::from(ParseError::invalid_format(
401 "data",
402 format!("Failed to serialize request body: {}", e),
403 ))
404 })?
405 };
406 self.bybit
407 .base()
408 .http_client
409 .delete(&full_url, Some(headers), Some(body))
410 .await
411 }
412 }
413 }
414}
415
416/// Converts HttpMethod to string representation.
417#[allow(dead_code)]
418fn method_to_string(method: HttpMethod) -> String {
419 match method {
420 HttpMethod::Get => "GET".to_string(),
421 HttpMethod::Post => "POST".to_string(),
422 HttpMethod::Delete => "DELETE".to_string(),
423 }
424}
425
426/// Builds a URL-encoded query string from parameters.
427///
428/// Parameters are sorted alphabetically by key (due to BTreeMap ordering).
429fn build_query_string(params: &BTreeMap<String, String>) -> String {
430 params
431 .iter()
432 .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
433 .collect::<Vec<_>>()
434 .join("&")
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use ccxt_core::ExchangeConfig;
441
442 #[test]
443 fn test_http_method_default() {
444 assert_eq!(HttpMethod::default(), HttpMethod::Get);
445 }
446
447 #[test]
448 fn test_builder_construction() {
449 let config = ExchangeConfig::default();
450 let bybit = Bybit::new(config).unwrap();
451
452 let builder = BybitSignedRequestBuilder::new(&bybit, "/v5/account/wallet-balance");
453
454 assert_eq!(builder.endpoint, "/v5/account/wallet-balance");
455 assert_eq!(builder.method, HttpMethod::Get);
456 assert!(builder.params.is_empty());
457 assert!(builder.body.is_none());
458 }
459
460 #[test]
461 fn test_builder_method_chaining() {
462 let config = ExchangeConfig::default();
463 let bybit = Bybit::new(config).unwrap();
464
465 let builder = BybitSignedRequestBuilder::new(&bybit, "/v5/order/create")
466 .method(HttpMethod::Post)
467 .param("category", "spot")
468 .param("symbol", "BTCUSDT");
469
470 assert_eq!(builder.method, HttpMethod::Post);
471 assert_eq!(builder.params.get("category"), Some(&"spot".to_string()));
472 assert_eq!(builder.params.get("symbol"), Some(&"BTCUSDT".to_string()));
473 }
474
475 #[test]
476 fn test_builder_param() {
477 let config = ExchangeConfig::default();
478 let bybit = Bybit::new(config).unwrap();
479
480 let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
481 .param("string_param", "value")
482 .param("int_param", 123)
483 .param("float_param", 45.67);
484
485 assert_eq!(
486 builder.params.get("string_param"),
487 Some(&"value".to_string())
488 );
489 assert_eq!(builder.params.get("int_param"), Some(&"123".to_string()));
490 assert_eq!(
491 builder.params.get("float_param"),
492 Some(&"45.67".to_string())
493 );
494 }
495
496 #[test]
497 fn test_builder_optional_param_some() {
498 let config = ExchangeConfig::default();
499 let bybit = Bybit::new(config).unwrap();
500
501 let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
502 .optional_param("limit", Some(100u32))
503 .optional_param("startTime", Some(1234567890i64));
504
505 assert_eq!(builder.params.get("limit"), Some(&"100".to_string()));
506 assert_eq!(
507 builder.params.get("startTime"),
508 Some(&"1234567890".to_string())
509 );
510 }
511
512 #[test]
513 fn test_builder_optional_param_none() {
514 let config = ExchangeConfig::default();
515 let bybit = Bybit::new(config).unwrap();
516
517 let none_value: Option<u32> = None;
518 let builder =
519 BybitSignedRequestBuilder::new(&bybit, "/test").optional_param("limit", none_value);
520
521 assert!(builder.params.get("limit").is_none());
522 }
523
524 #[test]
525 fn test_builder_params_bulk() {
526 let config = ExchangeConfig::default();
527 let bybit = Bybit::new(config).unwrap();
528
529 let mut params = BTreeMap::new();
530 params.insert("category".to_string(), "spot".to_string());
531 params.insert("symbol".to_string(), "BTCUSDT".to_string());
532
533 let builder = BybitSignedRequestBuilder::new(&bybit, "/test").params(params);
534
535 assert_eq!(builder.params.get("category"), Some(&"spot".to_string()));
536 assert_eq!(builder.params.get("symbol"), Some(&"BTCUSDT".to_string()));
537 }
538
539 #[test]
540 fn test_builder_body() {
541 let config = ExchangeConfig::default();
542 let bybit = Bybit::new(config).unwrap();
543
544 let body = serde_json::json!({
545 "category": "spot",
546 "symbol": "BTCUSDT"
547 });
548
549 let builder = BybitSignedRequestBuilder::new(&bybit, "/test").body(body.clone());
550
551 assert_eq!(builder.body, Some(body));
552 }
553
554 #[test]
555 fn test_builder_all_http_methods() {
556 let config = ExchangeConfig::default();
557 let bybit = Bybit::new(config).unwrap();
558
559 // Test all HTTP methods can be set
560 let get_builder = BybitSignedRequestBuilder::new(&bybit, "/test").method(HttpMethod::Get);
561 assert_eq!(get_builder.method, HttpMethod::Get);
562
563 let post_builder = BybitSignedRequestBuilder::new(&bybit, "/test").method(HttpMethod::Post);
564 assert_eq!(post_builder.method, HttpMethod::Post);
565
566 let delete_builder =
567 BybitSignedRequestBuilder::new(&bybit, "/test").method(HttpMethod::Delete);
568 assert_eq!(delete_builder.method, HttpMethod::Delete);
569 }
570
571 #[test]
572 fn test_builder_parameter_ordering() {
573 let config = ExchangeConfig::default();
574 let bybit = Bybit::new(config).unwrap();
575
576 // Add parameters in non-alphabetical order
577 let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
578 .param("zebra", "z")
579 .param("apple", "a")
580 .param("mango", "m");
581
582 // BTreeMap should maintain alphabetical order
583 let keys: Vec<_> = builder.params.keys().collect();
584 assert_eq!(keys, vec!["apple", "mango", "zebra"]);
585 }
586
587 #[test]
588 fn test_method_to_string() {
589 assert_eq!(method_to_string(HttpMethod::Get), "GET");
590 assert_eq!(method_to_string(HttpMethod::Post), "POST");
591 assert_eq!(method_to_string(HttpMethod::Delete), "DELETE");
592 }
593
594 #[test]
595 fn test_build_query_string() {
596 let mut params = BTreeMap::new();
597 params.insert("category".to_string(), "spot".to_string());
598 params.insert("symbol".to_string(), "BTCUSDT".to_string());
599 params.insert("limit".to_string(), "50".to_string());
600
601 let query = build_query_string(¶ms);
602
603 // BTreeMap maintains alphabetical order
604 assert_eq!(query, "category=spot&limit=50&symbol=BTCUSDT");
605 }
606
607 #[test]
608 fn test_build_query_string_empty() {
609 let params = BTreeMap::new();
610 let query = build_query_string(¶ms);
611 assert!(query.is_empty());
612 }
613
614 #[test]
615 fn test_build_query_string_with_special_chars() {
616 let mut params = BTreeMap::new();
617 params.insert("symbol".to_string(), "BTC/USDT".to_string());
618
619 let query = build_query_string(¶ms);
620
621 // Should URL-encode the slash
622 assert_eq!(query, "symbol=BTC%2FUSDT");
623 }
624}
625
626#[cfg(test)]
627mod property_tests {
628 use super::*;
629 use ccxt_core::ExchangeConfig;
630 use proptest::prelude::*;
631
632 // Strategy to generate valid parameter keys (alphanumeric, non-empty)
633 fn param_key_strategy() -> impl Strategy<Value = String> {
634 "[a-zA-Z][a-zA-Z0-9]{0,19}".prop_map(|s| s)
635 }
636
637 // Strategy to generate valid parameter values
638 fn param_value_strategy() -> impl Strategy<Value = String> {
639 "[a-zA-Z0-9._-]{1,50}".prop_map(|s| s)
640 }
641
642 // Strategy to generate a BTreeMap of parameters
643 fn params_strategy() -> impl Strategy<Value = BTreeMap<String, String>> {
644 proptest::collection::btree_map(param_key_strategy(), param_value_strategy(), 0..10)
645 }
646
647 proptest! {
648 #![proptest_config(ProptestConfig::with_cases(100))]
649
650 /// **Feature: exchange-signed-request-refactor, Property 1: Fluent API Method Chaining**
651 ///
652 /// *For any* sequence of builder method calls (param, optional_param, params, method),
653 /// each method SHALL return Self allowing further method chaining, and the final execute()
654 /// call SHALL consume the builder.
655 ///
656 /// **Validates: Requirements 3.1**
657 #[test]
658 fn prop_fluent_api_method_chaining(
659 params in params_strategy(),
660 other_key in param_key_strategy(),
661 other_value in param_value_strategy()
662 ) {
663 let config = ExchangeConfig::default();
664 let bybit = Bybit::new(config).unwrap();
665
666 // Build with method chaining
667 let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
668 .method(HttpMethod::Post)
669 .params(params.clone())
670 .param(&other_key, &other_value)
671 .optional_param("optional", Some("value"));
672
673 // Verify all parameters are present
674 for (key, value) in ¶ms {
675 prop_assert_eq!(builder.params.get(key), Some(value));
676 }
677 prop_assert_eq!(builder.params.get(&other_key), Some(&other_value));
678 prop_assert_eq!(builder.params.get("optional"), Some(&"value".to_string()));
679 prop_assert_eq!(builder.method, HttpMethod::Post);
680 }
681
682 /// **Feature: exchange-signed-request-refactor, Property 5: Parameter Methods Correctly Populate Params**
683 ///
684 /// *For any* combination of param(), optional_param(), and params() calls:
685 /// - param(key, value) SHALL always add the key-value pair
686 /// - optional_param(key, Some(value)) SHALL add the key-value pair
687 /// - optional_param(key, None) SHALL NOT add any key-value pair
688 /// - params(map) SHALL add all key-value pairs from the map
689 ///
690 /// **Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8**
691 #[test]
692 fn prop_optional_param_conditional_addition(
693 key in param_key_strategy(),
694 value in proptest::option::of(param_value_strategy()),
695 other_key in param_key_strategy(),
696 other_value in param_value_strategy()
697 ) {
698 let config = ExchangeConfig::default();
699 let bybit = Bybit::new(config).unwrap();
700
701 // Build with optional parameter
702 let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
703 .param(&other_key, &other_value)
704 .optional_param(&key, value.clone());
705
706 // Property: If value is Some, parameter should be present with correct value
707 // If value is None, parameter should NOT be present
708 match value {
709 Some(v) => {
710 prop_assert!(
711 builder.params.contains_key(&key),
712 "Parameter {} should be present when value is Some",
713 key
714 );
715 prop_assert_eq!(
716 builder.params.get(&key).unwrap(),
717 &v,
718 "Parameter {} should have correct value",
719 key
720 );
721 }
722 None => {
723 // Only check if key is different from other_key
724 if key != other_key {
725 prop_assert!(
726 !builder.params.contains_key(&key),
727 "Parameter {} should NOT be present when value is None",
728 key
729 );
730 }
731 }
732 }
733
734 // Property: Other parameters should always be present
735 prop_assert!(
736 builder.params.contains_key(&other_key),
737 "Other parameter {} should always be present",
738 other_key
739 );
740 prop_assert_eq!(
741 builder.params.get(&other_key).unwrap(),
742 &other_value,
743 "Other parameter {} should have correct value",
744 other_key
745 );
746 }
747
748 /// **Feature: exchange-signed-request-refactor, Property 6: HTTP Method Determines Parameter Location**
749 ///
750 /// *For any* signed request:
751 /// - GET requests SHALL have parameters in the URL query string
752 /// - POST requests SHALL have parameters in the JSON request body
753 /// - The signing input SHALL use the appropriate format based on HTTP method
754 ///
755 /// **Validates: Requirements 3.8**
756 #[test]
757 fn prop_http_method_determines_param_location(
758 params in params_strategy(),
759 method in prop_oneof![
760 Just(HttpMethod::Get),
761 Just(HttpMethod::Post),
762 Just(HttpMethod::Delete)
763 ]
764 ) {
765 let config = ExchangeConfig::default();
766 let bybit = Bybit::new(config).unwrap();
767
768 let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
769 .method(method)
770 .params(params.clone());
771
772 // Verify method is set correctly
773 prop_assert_eq!(builder.method, method);
774
775 // Verify all params are stored
776 for (key, value) in ¶ms {
777 prop_assert_eq!(builder.params.get(key), Some(value));
778 }
779 }
780 }
781}