Skip to main content

ccxt_exchanges/bitget/
signed_request.rs

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