ccxt_exchanges/binance/signed_request.rs
1//! Signed request builder for Binance API.
2//!
3//! This module provides a builder pattern for creating authenticated Binance API requests,
4//! encapsulating the common signing workflow used across all authenticated endpoints.
5//!
6//! # Overview
7//!
8//! The `SignedRequestBuilder` eliminates code duplication by centralizing:
9//! - Credential validation
10//! - 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::binance::Binance;
19//! # use ccxt_exchanges::binance::signed_request::HttpMethod;
20//! # use ccxt_core::ExchangeConfig;
21//! # async fn example() -> ccxt_core::Result<()> {
22//! let binance = Binance::new(ExchangeConfig::default())?;
23//!
24//! // Simple GET request
25//! let data = binance.signed_request("/api/v3/account")
26//! .execute()
27//! .await?;
28//!
29//! // POST request with parameters
30//! let data = binance.signed_request("/api/v3/order")
31//! .method(HttpMethod::Post)
32//! .param("symbol", "BTCUSDT")
33//! .param("side", "BUY")
34//! .param("type", "MARKET")
35//! .param("quantity", "0.001")
36//! .execute()
37//! .await?;
38//! # Ok(())
39//! # }
40//! ```
41
42use super::Binance;
43use ccxt_core::{Error, ParseError, Result};
44use reqwest::header::HeaderMap;
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 /// PUT request - parameters in JSON body
57 Put,
58 /// DELETE request - parameters in JSON body
59 Delete,
60}
61
62/// Builder for creating authenticated Binance API requests.
63///
64/// This builder encapsulates the common signing workflow:
65/// 1. Credential validation
66/// 2. Timestamp generation
67/// 3. Parameter signing with HMAC-SHA256
68/// 4. Authentication header injection
69/// 5. HTTP request execution
70///
71/// # Example
72///
73/// ```no_run
74/// # use ccxt_exchanges::binance::Binance;
75/// # use ccxt_exchanges::binance::signed_request::HttpMethod;
76/// # use ccxt_core::ExchangeConfig;
77/// # async fn example() -> ccxt_core::Result<()> {
78/// let binance = Binance::new(ExchangeConfig::default())?;
79///
80/// let data = binance.signed_request("/api/v3/account")
81/// .param("symbol", "BTCUSDT")
82/// .optional_param("limit", Some(100))
83/// .execute()
84/// .await?;
85/// # Ok(())
86/// # }
87/// ```
88pub struct SignedRequestBuilder<'a> {
89 /// Reference to the Binance exchange instance
90 binance: &'a Binance,
91 /// Request parameters to be signed
92 params: BTreeMap<String, String>,
93 /// API endpoint URL
94 endpoint: String,
95 /// HTTP method for the request
96 method: HttpMethod,
97}
98
99impl<'a> SignedRequestBuilder<'a> {
100 /// Creates a new signed request builder.
101 ///
102 /// # Arguments
103 ///
104 /// * `binance` - Reference to the Binance exchange instance
105 /// * `endpoint` - Full API endpoint URL
106 ///
107 /// # Example
108 ///
109 /// ```no_run
110 /// # use ccxt_exchanges::binance::Binance;
111 /// # use ccxt_exchanges::binance::signed_request::SignedRequestBuilder;
112 /// # use ccxt_core::ExchangeConfig;
113 /// let binance = Binance::new(ExchangeConfig::default()).unwrap();
114 /// let builder = SignedRequestBuilder::new(&binance, "https://api.binance.com/api/v3/account");
115 /// ```
116 pub fn new(binance: &'a Binance, endpoint: impl Into<String>) -> Self {
117 Self {
118 binance,
119 params: BTreeMap::new(),
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::binance::Binance;
137 /// # use ccxt_exchanges::binance::signed_request::HttpMethod;
138 /// # use ccxt_core::ExchangeConfig;
139 /// # async fn example() -> ccxt_core::Result<()> {
140 /// let binance = Binance::new(ExchangeConfig::default())?;
141 /// let data = binance.signed_request("/api/v3/order")
142 /// .method(HttpMethod::Post)
143 /// .param("symbol", "BTCUSDT")
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::binance::Binance;
165 /// # use ccxt_core::ExchangeConfig;
166 /// # async fn example() -> ccxt_core::Result<()> {
167 /// let binance = Binance::new(ExchangeConfig::default())?;
168 /// let data = binance.signed_request("/api/v3/myTrades")
169 /// .param("symbol", "BTCUSDT")
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::binance::Binance;
192 /// # use ccxt_core::ExchangeConfig;
193 /// # async fn example() -> ccxt_core::Result<()> {
194 /// let binance = Binance::new(ExchangeConfig::default())?;
195 /// let since: Option<i64> = Some(1234567890);
196 /// let limit: Option<u32> = None;
197 ///
198 /// let data = binance.signed_request("/api/v3/myTrades")
199 /// .param("symbol", "BTCUSDT")
200 /// .optional_param("startTime", 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::binance::Binance;
224 /// # use ccxt_core::ExchangeConfig;
225 /// # use std::collections::BTreeMap;
226 /// # async fn example() -> ccxt_core::Result<()> {
227 /// let binance = Binance::new(ExchangeConfig::default())?;
228 /// let mut params = BTreeMap::new();
229 /// params.insert("symbol".to_string(), "BTCUSDT".to_string());
230 /// params.insert("side".to_string(), "BUY".to_string());
231 ///
232 /// let data = binance.signed_request("/api/v3/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 /// Merges parameters from a JSON Value object.
245 ///
246 /// Only string, number, and boolean values are supported.
247 /// Nested objects and arrays are ignored.
248 ///
249 /// # Arguments
250 ///
251 /// * `params` - Optional JSON Value containing parameters
252 ///
253 /// # Example
254 ///
255 /// ```no_run
256 /// # use ccxt_exchanges::binance::Binance;
257 /// # use ccxt_core::ExchangeConfig;
258 /// # use serde_json::json;
259 /// # async fn example() -> ccxt_core::Result<()> {
260 /// let binance = Binance::new(ExchangeConfig::default())?;
261 /// let extra_params = Some(json!({
262 /// "orderId": "12345",
263 /// "fromId": 67890
264 /// }));
265 ///
266 /// let data = binance.signed_request("/api/v3/myTrades")
267 /// .param("symbol", "BTCUSDT")
268 /// .merge_json_params(extra_params)
269 /// .execute()
270 /// .await?;
271 /// # Ok(())
272 /// # }
273 /// ```
274 pub fn merge_json_params(mut self, params: Option<Value>) -> Self {
275 if let Some(Value::Object(map)) = params {
276 for (key, value) in map {
277 let string_value = match value {
278 Value::String(s) => s,
279 Value::Number(n) => n.to_string(),
280 Value::Bool(b) => b.to_string(),
281 _ => continue, // Skip arrays, objects, and null
282 };
283 self.params.insert(key, string_value);
284 }
285 }
286 self
287 }
288
289 /// Executes the signed request and returns the response.
290 ///
291 /// This method:
292 /// 1. Validates that credentials are configured
293 /// 2. Gets the signing timestamp (using cached offset if available)
294 /// 3. Signs the parameters with HMAC-SHA256
295 /// 4. Adds authentication headers
296 /// 5. Executes the HTTP request
297 ///
298 /// # Returns
299 ///
300 /// Returns the raw `serde_json::Value` response for further parsing.
301 ///
302 /// # Errors
303 ///
304 /// - Returns authentication error if credentials are missing
305 /// - Returns network error if the request fails
306 /// - Returns parse error if response parsing fails
307 ///
308 /// # Example
309 ///
310 /// ```no_run
311 /// # use ccxt_exchanges::binance::Binance;
312 /// # use ccxt_core::ExchangeConfig;
313 /// # async fn example() -> ccxt_core::Result<()> {
314 /// let binance = Binance::new(ExchangeConfig::default())?;
315 /// let data = binance.signed_request("/api/v3/account")
316 /// .execute()
317 /// .await?;
318 /// println!("Response: {:?}", data);
319 /// # Ok(())
320 /// # }
321 /// ```
322 pub async fn execute(self) -> Result<Value> {
323 // Step 1: Validate credentials
324 self.binance.check_required_credentials()?;
325
326 // Step 2: Get signing timestamp
327 let timestamp = self.binance.get_signing_timestamp().await?;
328
329 // Step 3: Get auth and sign parameters
330 let auth = self.binance.get_auth()?;
331 let signed_params = auth.sign_with_timestamp(
332 &self.params,
333 timestamp,
334 Some(self.binance.options().recv_window),
335 )?;
336
337 // Step 4: Add authentication headers
338 let mut headers = HeaderMap::new();
339 auth.add_auth_headers_reqwest(&mut headers);
340
341 // Step 5: Execute HTTP request based on method
342 match self.method {
343 HttpMethod::Get => {
344 // Build query string for GET requests
345 let query_string = build_query_string(&signed_params);
346 let url = if query_string.is_empty() {
347 self.endpoint
348 } else {
349 format!("{}?{}", self.endpoint, query_string)
350 };
351 self.binance
352 .base()
353 .http_client
354 .get(&url, Some(headers))
355 .await
356 }
357 HttpMethod::Post => {
358 // Serialize parameters as JSON body for POST requests
359 let body = serde_json::to_value(&signed_params).map_err(|e| {
360 Error::from(ParseError::invalid_format(
361 "data",
362 format!("Failed to serialize request body: {}", e),
363 ))
364 })?;
365 self.binance
366 .base()
367 .http_client
368 .post(&self.endpoint, Some(headers), Some(body))
369 .await
370 }
371 HttpMethod::Put => {
372 // Serialize parameters as JSON body for PUT requests
373 let body = serde_json::to_value(&signed_params).map_err(|e| {
374 Error::from(ParseError::invalid_format(
375 "data",
376 format!("Failed to serialize request body: {}", e),
377 ))
378 })?;
379 self.binance
380 .base()
381 .http_client
382 .put(&self.endpoint, Some(headers), Some(body))
383 .await
384 }
385 HttpMethod::Delete => {
386 // Serialize parameters as JSON body for DELETE requests
387 let body = serde_json::to_value(&signed_params).map_err(|e| {
388 Error::from(ParseError::invalid_format(
389 "data",
390 format!("Failed to serialize request body: {}", e),
391 ))
392 })?;
393 self.binance
394 .base()
395 .http_client
396 .delete(&self.endpoint, Some(headers), Some(body))
397 .await
398 }
399 }
400 }
401}
402
403/// Builds a URL-encoded query string from parameters.
404///
405/// Parameters are sorted alphabetically by key (due to BTreeMap ordering).
406fn build_query_string(params: &BTreeMap<String, String>) -> String {
407 params
408 .iter()
409 .map(|(k, v)| format!("{}={}", k, v))
410 .collect::<Vec<_>>()
411 .join("&")
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use ccxt_core::ExchangeConfig;
418
419 #[test]
420 fn test_http_method_default() {
421 assert_eq!(HttpMethod::default(), HttpMethod::Get);
422 }
423
424 #[test]
425 fn test_builder_construction() {
426 let config = ExchangeConfig::default();
427 let binance = Binance::new(config).unwrap();
428
429 let builder = SignedRequestBuilder::new(&binance, "https://api.binance.com/api/v3/account");
430
431 assert_eq!(builder.endpoint, "https://api.binance.com/api/v3/account");
432 assert_eq!(builder.method, HttpMethod::Get);
433 assert!(builder.params.is_empty());
434 }
435
436 #[test]
437 fn test_builder_method_chaining() {
438 let config = ExchangeConfig::default();
439 let binance = Binance::new(config).unwrap();
440
441 let builder = SignedRequestBuilder::new(&binance, "/api/v3/order")
442 .method(HttpMethod::Post)
443 .param("symbol", "BTCUSDT")
444 .param("side", "BUY");
445
446 assert_eq!(builder.method, HttpMethod::Post);
447 assert_eq!(builder.params.get("symbol"), Some(&"BTCUSDT".to_string()));
448 assert_eq!(builder.params.get("side"), Some(&"BUY".to_string()));
449 }
450
451 #[test]
452 fn test_builder_param() {
453 let config = ExchangeConfig::default();
454 let binance = Binance::new(config).unwrap();
455
456 let builder = SignedRequestBuilder::new(&binance, "/test")
457 .param("string_param", "value")
458 .param("int_param", 123)
459 .param("float_param", 45.67);
460
461 assert_eq!(
462 builder.params.get("string_param"),
463 Some(&"value".to_string())
464 );
465 assert_eq!(builder.params.get("int_param"), Some(&"123".to_string()));
466 assert_eq!(
467 builder.params.get("float_param"),
468 Some(&"45.67".to_string())
469 );
470 }
471
472 #[test]
473 fn test_builder_optional_param_some() {
474 let config = ExchangeConfig::default();
475 let binance = Binance::new(config).unwrap();
476
477 let builder = SignedRequestBuilder::new(&binance, "/test")
478 .optional_param("limit", Some(100u32))
479 .optional_param("since", Some(1234567890i64));
480
481 assert_eq!(builder.params.get("limit"), Some(&"100".to_string()));
482 assert_eq!(builder.params.get("since"), Some(&"1234567890".to_string()));
483 }
484
485 #[test]
486 fn test_builder_optional_param_none() {
487 let config = ExchangeConfig::default();
488 let binance = Binance::new(config).unwrap();
489
490 let none_value: Option<u32> = None;
491 let builder =
492 SignedRequestBuilder::new(&binance, "/test").optional_param("limit", none_value);
493
494 assert!(builder.params.get("limit").is_none());
495 }
496
497 #[test]
498 fn test_builder_params_bulk() {
499 let config = ExchangeConfig::default();
500 let binance = Binance::new(config).unwrap();
501
502 let mut params = BTreeMap::new();
503 params.insert("symbol".to_string(), "BTCUSDT".to_string());
504 params.insert("side".to_string(), "BUY".to_string());
505
506 let builder = SignedRequestBuilder::new(&binance, "/test").params(params);
507
508 assert_eq!(builder.params.get("symbol"), Some(&"BTCUSDT".to_string()));
509 assert_eq!(builder.params.get("side"), Some(&"BUY".to_string()));
510 }
511
512 #[test]
513 fn test_builder_merge_json_params() {
514 let config = ExchangeConfig::default();
515 let binance = Binance::new(config).unwrap();
516
517 let json_params = Some(serde_json::json!({
518 "orderId": "12345",
519 "fromId": 67890,
520 "active": true,
521 "nested": {"ignored": "value"},
522 "array": [1, 2, 3]
523 }));
524
525 let builder = SignedRequestBuilder::new(&binance, "/test").merge_json_params(json_params);
526
527 assert_eq!(builder.params.get("orderId"), Some(&"12345".to_string()));
528 assert_eq!(builder.params.get("fromId"), Some(&"67890".to_string()));
529 assert_eq!(builder.params.get("active"), Some(&"true".to_string()));
530 // Nested objects and arrays should be ignored
531 assert!(builder.params.get("nested").is_none());
532 assert!(builder.params.get("array").is_none());
533 }
534
535 #[test]
536 fn test_builder_merge_json_params_none() {
537 let config = ExchangeConfig::default();
538 let binance = Binance::new(config).unwrap();
539
540 let builder = SignedRequestBuilder::new(&binance, "/test")
541 .param("existing", "value")
542 .merge_json_params(None);
543
544 assert_eq!(builder.params.get("existing"), Some(&"value".to_string()));
545 assert_eq!(builder.params.len(), 1);
546 }
547
548 #[test]
549 fn test_build_query_string() {
550 let mut params = BTreeMap::new();
551 params.insert("symbol".to_string(), "BTCUSDT".to_string());
552 params.insert("side".to_string(), "BUY".to_string());
553 params.insert("amount".to_string(), "1.5".to_string());
554
555 let query = build_query_string(¶ms);
556
557 // BTreeMap maintains alphabetical order
558 assert_eq!(query, "amount=1.5&side=BUY&symbol=BTCUSDT");
559 }
560
561 #[test]
562 fn test_build_query_string_empty() {
563 let params = BTreeMap::new();
564 let query = build_query_string(¶ms);
565 assert!(query.is_empty());
566 }
567
568 #[test]
569 fn test_builder_all_http_methods() {
570 let config = ExchangeConfig::default();
571 let binance = Binance::new(config).unwrap();
572
573 // Test all HTTP methods can be set
574 let get_builder = SignedRequestBuilder::new(&binance, "/test").method(HttpMethod::Get);
575 assert_eq!(get_builder.method, HttpMethod::Get);
576
577 let post_builder = SignedRequestBuilder::new(&binance, "/test").method(HttpMethod::Post);
578 assert_eq!(post_builder.method, HttpMethod::Post);
579
580 let put_builder = SignedRequestBuilder::new(&binance, "/test").method(HttpMethod::Put);
581 assert_eq!(put_builder.method, HttpMethod::Put);
582
583 let delete_builder =
584 SignedRequestBuilder::new(&binance, "/test").method(HttpMethod::Delete);
585 assert_eq!(delete_builder.method, HttpMethod::Delete);
586 }
587
588 #[test]
589 fn test_builder_parameter_ordering() {
590 let config = ExchangeConfig::default();
591 let binance = Binance::new(config).unwrap();
592
593 // Add parameters in non-alphabetical order
594 let builder = SignedRequestBuilder::new(&binance, "/test")
595 .param("zebra", "z")
596 .param("apple", "a")
597 .param("mango", "m");
598
599 // BTreeMap should maintain alphabetical order
600 let keys: Vec<_> = builder.params.keys().collect();
601 assert_eq!(keys, vec!["apple", "mango", "zebra"]);
602 }
603}
604
605#[cfg(test)]
606mod property_tests {
607 use super::*;
608 use ccxt_core::ExchangeConfig;
609 use proptest::prelude::*;
610
611 // Strategy to generate valid parameter keys (alphanumeric, non-empty)
612 fn param_key_strategy() -> impl Strategy<Value = String> {
613 "[a-zA-Z][a-zA-Z0-9]{0,19}".prop_map(|s| s)
614 }
615
616 // Strategy to generate valid parameter values
617 fn param_value_strategy() -> impl Strategy<Value = String> {
618 "[a-zA-Z0-9._-]{1,50}".prop_map(|s| s)
619 }
620
621 // Strategy to generate a BTreeMap of parameters
622 fn params_strategy() -> impl Strategy<Value = BTreeMap<String, String>> {
623 proptest::collection::btree_map(param_key_strategy(), param_value_strategy(), 0..10)
624 }
625
626 proptest! {
627 #![proptest_config(ProptestConfig::with_cases(100))]
628
629 /// **Feature: binance-signed-request-refactor, Property 2: Signed Request Contains Required Fields**
630 ///
631 /// *For any* executed signed request with valid credentials, the signed parameters SHALL contain:
632 /// - A `timestamp` field with a valid millisecond timestamp
633 /// - A `signature` field with a 64-character hex string (HMAC-SHA256)
634 /// - A `recvWindow` field matching the configured value
635 ///
636 /// **Validates: Requirements 1.3, 1.4, 1.5, 1.6**
637 #[test]
638 fn prop_signed_request_contains_required_fields(
639 params in params_strategy(),
640 recv_window in 1000u64..60000u64
641 ) {
642 // Create a Binance instance with credentials
643 let mut config = ExchangeConfig::default();
644 config.api_key = Some("test_api_key".to_string().into());
645 config.secret = Some("test_secret_key".to_string().into());
646
647 let mut options = super::super::BinanceOptions::default();
648 options.recv_window = recv_window;
649
650 let binance = super::super::Binance::new_with_options(config, options).unwrap();
651
652 // Get auth and sign parameters manually to verify the signing logic
653 let auth = binance.get_auth().unwrap();
654 let timestamp = 1234567890000i64; // Fixed timestamp for testing
655
656 let signed_params = auth.sign_with_timestamp(¶ms, timestamp, Some(recv_window)).unwrap();
657
658 // Property 1: timestamp field exists and matches
659 prop_assert!(signed_params.contains_key("timestamp"));
660 prop_assert_eq!(signed_params.get("timestamp").unwrap(), ×tamp.to_string());
661
662 // Property 2: signature field exists and is 64 hex characters
663 prop_assert!(signed_params.contains_key("signature"));
664 let signature = signed_params.get("signature").unwrap();
665 prop_assert_eq!(signature.len(), 64, "Signature should be 64 hex characters");
666 prop_assert!(signature.chars().all(|c| c.is_ascii_hexdigit()), "Signature should be hex");
667
668 // Property 3: recvWindow field exists and matches configured value
669 prop_assert!(signed_params.contains_key("recvWindow"));
670 prop_assert_eq!(signed_params.get("recvWindow").unwrap(), &recv_window.to_string());
671
672 // Property 4: All original parameters are preserved
673 for (key, value) in ¶ms {
674 prop_assert!(signed_params.contains_key(key), "Original param {} should be preserved", key);
675 prop_assert_eq!(signed_params.get(key).unwrap(), value, "Original param {} value should match", key);
676 }
677 }
678
679 /// **Feature: binance-signed-request-refactor, Property 5: Optional Parameters Conditional Addition**
680 ///
681 /// *For any* call to `optional_param(key, value)`:
682 /// - If `value` is `Some(v)`, the parameter SHALL be added with key and v.to_string()
683 /// - If `value` is `None`, the parameter SHALL NOT be added
684 ///
685 /// **Validates: Requirements 2.2**
686 #[test]
687 fn prop_optional_param_conditional_addition(
688 key in param_key_strategy(),
689 value in proptest::option::of(param_value_strategy()),
690 other_key in param_key_strategy().prop_filter("other_key must differ from key", |k| k != "z"),
691 other_value in param_value_strategy()
692 ) {
693 // Skip if keys are the same to avoid overwrite conflicts
694 prop_assume!(key != other_key);
695
696 let config = ExchangeConfig::default();
697 let binance = super::super::Binance::new(config).unwrap();
698
699 // Build with optional parameter
700 let builder = SignedRequestBuilder::new(&binance, "/test")
701 .param(&other_key, &other_value)
702 .optional_param(&key, value.clone());
703
704 // Property: If value is Some, parameter should be present with correct value
705 // If value is None, parameter should NOT be present
706 match value {
707 Some(v) => {
708 prop_assert!(
709 builder.params.contains_key(&key),
710 "Parameter {} should be present when value is Some",
711 key
712 );
713 prop_assert_eq!(
714 builder.params.get(&key).unwrap(),
715 &v,
716 "Parameter {} should have correct value",
717 key
718 );
719 }
720 None => {
721 // Only check if key is different from other_key
722 if key != other_key {
723 prop_assert!(
724 !builder.params.contains_key(&key),
725 "Parameter {} should NOT be present when value is None",
726 key
727 );
728 }
729 }
730 }
731
732 // Property: Other parameters should always be present
733 prop_assert!(
734 builder.params.contains_key(&other_key),
735 "Other parameter {} should always be present",
736 other_key
737 );
738 prop_assert_eq!(
739 builder.params.get(&other_key).unwrap(),
740 &other_value,
741 "Other parameter {} should have correct value",
742 other_key
743 );
744 }
745 }
746}