use super::Bybit;
use ccxt_core::{Error, ParseError, Result};
use reqwest::header::{HeaderMap, HeaderValue};
use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HttpMethod {
#[default]
Get,
Post,
Delete,
}
pub struct BybitSignedRequestBuilder<'a> {
bybit: &'a Bybit,
params: BTreeMap<String, String>,
body: Option<Value>,
endpoint: String,
method: HttpMethod,
}
impl<'a> BybitSignedRequestBuilder<'a> {
pub fn new(bybit: &'a Bybit, endpoint: impl Into<String>) -> Self {
Self {
bybit,
params: BTreeMap::new(),
body: None,
endpoint: endpoint.into(),
method: HttpMethod::default(),
}
}
pub fn method(mut self, method: HttpMethod) -> Self {
self.method = method;
self
}
pub fn param(mut self, key: impl Into<String>, value: impl ToString) -> Self {
self.params.insert(key.into(), value.to_string());
self
}
pub fn optional_param<T: ToString>(mut self, key: impl Into<String>, value: Option<T>) -> Self {
if let Some(v) = value {
self.params.insert(key.into(), v.to_string());
}
self
}
pub fn params(mut self, params: BTreeMap<String, String>) -> Self {
self.params.extend(params);
self
}
pub fn body(mut self, body: Value) -> Self {
self.body = Some(body);
self
}
pub async fn execute(self) -> Result<Value> {
self.bybit.check_required_credentials()?;
let timestamp = chrono::Utc::now().timestamp_millis().to_string();
let auth = self.bybit.get_auth()?;
let recv_window = self.bybit.options().recv_window;
let sign_params = if self.method == HttpMethod::Get {
build_query_string(&self.params)
} else if let Some(ref body) = self.body {
body.to_string()
} else if !self.params.is_empty() {
serde_json::to_string(&self.params).map_err(|e| {
ccxt_core::Error::from(ccxt_core::ParseError::invalid_format(
"request params",
format!("JSON serialization failed: {}", e),
))
})?
} else {
String::new()
};
let signature = auth.sign(×tamp, recv_window, &sign_params);
let mut headers = HeaderMap::new();
auth.add_auth_headers(&mut headers, ×tamp, &signature, recv_window);
headers.insert("Content-Type", HeaderValue::from_static("application/json"));
let urls = self.bybit.urls();
let full_url = format!("{}{}", urls.rest, self.endpoint);
match self.method {
HttpMethod::Get => {
let query_string = build_query_string(&self.params);
let url = if query_string.is_empty() {
full_url
} else {
format!("{}?{}", full_url, query_string)
};
self.bybit.base().http_client.get(&url, Some(headers)).await
}
HttpMethod::Post => {
let body = if let Some(b) = self.body {
b
} else {
serde_json::to_value(&self.params).map_err(|e| {
Error::from(ParseError::invalid_format(
"data",
format!("Failed to serialize request body: {}", e),
))
})?
};
self.bybit
.base()
.http_client
.post(&full_url, Some(headers), Some(body))
.await
}
HttpMethod::Delete => {
let body = if let Some(b) = self.body {
b
} else {
serde_json::to_value(&self.params).map_err(|e| {
Error::from(ParseError::invalid_format(
"data",
format!("Failed to serialize request body: {}", e),
))
})?
};
self.bybit
.base()
.http_client
.delete(&full_url, Some(headers), Some(body))
.await
}
}
}
}
#[allow(dead_code)]
fn method_to_string(method: HttpMethod) -> String {
match method {
HttpMethod::Get => "GET".to_string(),
HttpMethod::Post => "POST".to_string(),
HttpMethod::Delete => "DELETE".to_string(),
}
}
fn build_query_string(params: &BTreeMap<String, String>) -> String {
params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&")
}
#[cfg(test)]
mod tests {
use super::*;
use ccxt_core::ExchangeConfig;
#[test]
fn test_http_method_default() {
assert_eq!(HttpMethod::default(), HttpMethod::Get);
}
#[test]
fn test_builder_construction() {
let config = ExchangeConfig::default();
let bybit = Bybit::new(config).unwrap();
let builder = BybitSignedRequestBuilder::new(&bybit, "/v5/account/wallet-balance");
assert_eq!(builder.endpoint, "/v5/account/wallet-balance");
assert_eq!(builder.method, HttpMethod::Get);
assert!(builder.params.is_empty());
assert!(builder.body.is_none());
}
#[test]
fn test_builder_method_chaining() {
let config = ExchangeConfig::default();
let bybit = Bybit::new(config).unwrap();
let builder = BybitSignedRequestBuilder::new(&bybit, "/v5/order/create")
.method(HttpMethod::Post)
.param("category", "spot")
.param("symbol", "BTCUSDT");
assert_eq!(builder.method, HttpMethod::Post);
assert_eq!(builder.params.get("category"), Some(&"spot".to_string()));
assert_eq!(builder.params.get("symbol"), Some(&"BTCUSDT".to_string()));
}
#[test]
fn test_builder_param() {
let config = ExchangeConfig::default();
let bybit = Bybit::new(config).unwrap();
let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
.param("string_param", "value")
.param("int_param", 123)
.param("float_param", 45.67);
assert_eq!(
builder.params.get("string_param"),
Some(&"value".to_string())
);
assert_eq!(builder.params.get("int_param"), Some(&"123".to_string()));
assert_eq!(
builder.params.get("float_param"),
Some(&"45.67".to_string())
);
}
#[test]
fn test_builder_optional_param_some() {
let config = ExchangeConfig::default();
let bybit = Bybit::new(config).unwrap();
let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
.optional_param("limit", Some(100u32))
.optional_param("startTime", Some(1234567890i64));
assert_eq!(builder.params.get("limit"), Some(&"100".to_string()));
assert_eq!(
builder.params.get("startTime"),
Some(&"1234567890".to_string())
);
}
#[test]
fn test_builder_optional_param_none() {
let config = ExchangeConfig::default();
let bybit = Bybit::new(config).unwrap();
let none_value: Option<u32> = None;
let builder =
BybitSignedRequestBuilder::new(&bybit, "/test").optional_param("limit", none_value);
assert!(builder.params.get("limit").is_none());
}
#[test]
fn test_builder_params_bulk() {
let config = ExchangeConfig::default();
let bybit = Bybit::new(config).unwrap();
let mut params = BTreeMap::new();
params.insert("category".to_string(), "spot".to_string());
params.insert("symbol".to_string(), "BTCUSDT".to_string());
let builder = BybitSignedRequestBuilder::new(&bybit, "/test").params(params);
assert_eq!(builder.params.get("category"), Some(&"spot".to_string()));
assert_eq!(builder.params.get("symbol"), Some(&"BTCUSDT".to_string()));
}
#[test]
fn test_builder_body() {
let config = ExchangeConfig::default();
let bybit = Bybit::new(config).unwrap();
let body = serde_json::json!({
"category": "spot",
"symbol": "BTCUSDT"
});
let builder = BybitSignedRequestBuilder::new(&bybit, "/test").body(body.clone());
assert_eq!(builder.body, Some(body));
}
#[test]
fn test_builder_all_http_methods() {
let config = ExchangeConfig::default();
let bybit = Bybit::new(config).unwrap();
let get_builder = BybitSignedRequestBuilder::new(&bybit, "/test").method(HttpMethod::Get);
assert_eq!(get_builder.method, HttpMethod::Get);
let post_builder = BybitSignedRequestBuilder::new(&bybit, "/test").method(HttpMethod::Post);
assert_eq!(post_builder.method, HttpMethod::Post);
let delete_builder =
BybitSignedRequestBuilder::new(&bybit, "/test").method(HttpMethod::Delete);
assert_eq!(delete_builder.method, HttpMethod::Delete);
}
#[test]
fn test_builder_parameter_ordering() {
let config = ExchangeConfig::default();
let bybit = Bybit::new(config).unwrap();
let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
.param("zebra", "z")
.param("apple", "a")
.param("mango", "m");
let keys: Vec<_> = builder.params.keys().collect();
assert_eq!(keys, vec!["apple", "mango", "zebra"]);
}
#[test]
fn test_method_to_string() {
assert_eq!(method_to_string(HttpMethod::Get), "GET");
assert_eq!(method_to_string(HttpMethod::Post), "POST");
assert_eq!(method_to_string(HttpMethod::Delete), "DELETE");
}
#[test]
fn test_build_query_string() {
let mut params = BTreeMap::new();
params.insert("category".to_string(), "spot".to_string());
params.insert("symbol".to_string(), "BTCUSDT".to_string());
params.insert("limit".to_string(), "50".to_string());
let query = build_query_string(¶ms);
assert_eq!(query, "category=spot&limit=50&symbol=BTCUSDT");
}
#[test]
fn test_build_query_string_empty() {
let params = BTreeMap::new();
let query = build_query_string(¶ms);
assert!(query.is_empty());
}
#[test]
fn test_build_query_string_with_special_chars() {
let mut params = BTreeMap::new();
params.insert("symbol".to_string(), "BTC/USDT".to_string());
let query = build_query_string(¶ms);
assert_eq!(query, "symbol=BTC%2FUSDT");
}
}
#[cfg(test)]
mod property_tests {
use super::*;
use ccxt_core::ExchangeConfig;
use proptest::prelude::*;
fn param_key_strategy() -> impl Strategy<Value = String> {
"[a-zA-Z][a-zA-Z0-9]{0,19}".prop_map(|s| s)
}
fn param_value_strategy() -> impl Strategy<Value = String> {
"[a-zA-Z0-9._-]{1,50}".prop_map(|s| s)
}
fn params_strategy() -> impl Strategy<Value = BTreeMap<String, String>> {
proptest::collection::btree_map(param_key_strategy(), param_value_strategy(), 0..10)
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_fluent_api_method_chaining(
params in params_strategy(),
other_key in param_key_strategy(),
other_value in param_value_strategy()
) {
let config = ExchangeConfig::default();
let bybit = Bybit::new(config).unwrap();
let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
.method(HttpMethod::Post)
.params(params.clone())
.param(&other_key, &other_value)
.optional_param("optional", Some("value"));
for (key, value) in ¶ms {
prop_assert_eq!(builder.params.get(key), Some(value));
}
prop_assert_eq!(builder.params.get(&other_key), Some(&other_value));
prop_assert_eq!(builder.params.get("optional"), Some(&"value".to_string()));
prop_assert_eq!(builder.method, HttpMethod::Post);
}
#[test]
fn prop_optional_param_conditional_addition(
key in param_key_strategy(),
value in proptest::option::of(param_value_strategy()),
other_key in param_key_strategy(),
other_value in param_value_strategy()
) {
let config = ExchangeConfig::default();
let bybit = Bybit::new(config).unwrap();
let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
.param(&other_key, &other_value)
.optional_param(&key, value.clone());
match value {
Some(v) => {
prop_assert!(
builder.params.contains_key(&key),
"Parameter {} should be present when value is Some",
key
);
prop_assert_eq!(
builder.params.get(&key).unwrap(),
&v,
"Parameter {} should have correct value",
key
);
}
None => {
if key != other_key {
prop_assert!(
!builder.params.contains_key(&key),
"Parameter {} should NOT be present when value is None",
key
);
}
}
}
prop_assert!(
builder.params.contains_key(&other_key),
"Other parameter {} should always be present",
other_key
);
prop_assert_eq!(
builder.params.get(&other_key).unwrap(),
&other_value,
"Other parameter {} should have correct value",
other_key
);
}
#[test]
fn prop_http_method_determines_param_location(
params in params_strategy(),
method in prop_oneof![
Just(HttpMethod::Get),
Just(HttpMethod::Post),
Just(HttpMethod::Delete)
]
) {
let config = ExchangeConfig::default();
let bybit = Bybit::new(config).unwrap();
let builder = BybitSignedRequestBuilder::new(&bybit, "/test")
.method(method)
.params(params.clone());
prop_assert_eq!(builder.method, method);
for (key, value) in ¶ms {
prop_assert_eq!(builder.params.get(key), Some(value));
}
}
}
}