use super::Binance;
use super::error::BinanceApiError;
use super::rate_limiter::RateLimitInfo;
use ccxt_core::Result;
use reqwest::header::HeaderMap;
use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HttpMethod {
#[default]
Get,
Post,
Put,
Delete,
}
pub struct SignedRequestBuilder<'a> {
binance: &'a Binance,
params: BTreeMap<String, String>,
endpoint: String,
method: HttpMethod,
}
impl<'a> SignedRequestBuilder<'a> {
pub fn new(binance: &'a Binance, endpoint: impl Into<String>) -> Self {
Self {
binance,
params: BTreeMap::new(),
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 merge_json_params(mut self, params: Option<Value>) -> Self {
if let Some(Value::Object(map)) = params {
for (key, value) in map {
let string_value = match value {
Value::String(s) => s,
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => continue, };
self.params.insert(key, string_value);
}
}
self
}
pub async fn execute(self) -> Result<Value> {
if let Some(wait_duration) = self.binance.rate_limiter().wait_duration() {
tokio::time::sleep(wait_duration).await;
}
self.binance.check_required_credentials()?;
match self.execute_inner().await {
Ok(result) => Ok(result),
Err(err) => {
if self.binance.is_timestamp_error(&err) {
tracing::warn!("Timestamp error detected, resyncing time and retrying");
if self.binance.sync_time().await.is_ok() {
return self.execute_inner().await;
}
}
Err(err)
}
}
}
async fn execute_inner(&self) -> Result<Value> {
let timestamp = self.binance.get_signing_timestamp().await?;
let auth = self.binance.get_auth()?;
let signed_params = auth.sign_with_timestamp(
&self.params,
timestamp,
Some(self.binance.options().recv_window),
)?;
let mut headers = HeaderMap::new();
auth.add_auth_headers_reqwest(&mut headers);
let query_string = build_query_string(&signed_params);
let url = if query_string.is_empty() {
self.endpoint.clone()
} else {
format!("{}?{}", self.endpoint, query_string)
};
let result = match self.method {
HttpMethod::Get => {
self.binance
.base()
.http_client
.get(&url, Some(headers))
.await
}
HttpMethod::Post => {
self.binance
.base()
.http_client
.post(&url, Some(headers), None)
.await
}
HttpMethod::Put => {
self.binance
.base()
.http_client
.put(&url, Some(headers), None)
.await
}
HttpMethod::Delete => {
self.binance
.base()
.http_client
.delete(&url, Some(headers), None)
.await
}
};
let result = match result {
Ok(value) => value,
Err(err) => {
if let ccxt_core::error::Error::Exchange(ref exchange_err) = err {
let err_str = exchange_err.to_string();
if err_str.contains("IP banned") || err_str.contains("418") {
self.binance
.rate_limiter()
.set_ip_banned(std::time::Duration::from_secs(7200));
}
if let ccxt_core::error::Error::RateLimit { .. } = err {
}
}
return Err(err);
}
};
if let Some(resp_headers) = result.get("responseHeaders") {
let rate_info = RateLimitInfo::from_headers(resp_headers);
if rate_info.has_data() {
self.binance.rate_limiter().update(rate_info);
}
}
if let Some(api_error) = BinanceApiError::from_json(&result) {
if api_error.is_ip_banned() {
self.binance
.rate_limiter()
.set_ip_banned(std::time::Duration::from_secs(7200)); }
return Err(api_error.into());
}
Ok(result)
}
}
pub(crate) fn build_query_string(params: &BTreeMap<String, String>) -> String {
params
.iter()
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&")
}
pub(crate) struct ApiKeyRequestBuilder<'a> {
binance: &'a Binance,
endpoint: String,
method: HttpMethod,
query_params: BTreeMap<String, String>,
}
impl<'a> ApiKeyRequestBuilder<'a> {
pub fn new(binance: &'a Binance, endpoint: impl Into<String>) -> Self {
Self {
binance,
endpoint: endpoint.into(),
method: HttpMethod::default(),
query_params: BTreeMap::new(),
}
}
pub fn method(mut self, method: HttpMethod) -> Self {
self.method = method;
self
}
#[allow(dead_code)]
pub fn param(mut self, key: impl Into<String>, value: impl ToString) -> Self {
self.query_params.insert(key.into(), value.to_string());
self
}
pub async fn execute(self) -> Result<Value> {
if let Some(wait_duration) = self.binance.rate_limiter().wait_duration() {
tokio::time::sleep(wait_duration).await;
}
self.binance.check_required_credentials()?;
let query_string = build_query_string(&self.query_params);
let url = if query_string.is_empty() {
self.endpoint.clone()
} else {
format!("{}?{}", self.endpoint, query_string)
};
let mut headers = HeaderMap::new();
let auth = self.binance.get_auth()?;
auth.add_auth_headers_reqwest(&mut headers);
let result = match self.method {
HttpMethod::Get => {
self.binance
.base()
.http_client
.get(&url, Some(headers))
.await
}
HttpMethod::Post => {
self.binance
.base()
.http_client
.post(&url, Some(headers), None)
.await
}
HttpMethod::Put => {
self.binance
.base()
.http_client
.put(&url, Some(headers), None)
.await
}
HttpMethod::Delete => {
self.binance
.base()
.http_client
.delete(&url, Some(headers), None)
.await
}
};
let result = match result {
Ok(value) => value,
Err(err) => {
if let ccxt_core::error::Error::Exchange(ref exchange_err) = err {
let err_str = exchange_err.to_string();
if err_str.contains("IP banned") || err_str.contains("418") {
self.binance
.rate_limiter()
.set_ip_banned(std::time::Duration::from_secs(7200));
}
}
return Err(err);
}
};
if let Some(resp_headers) = result.get("responseHeaders") {
let rate_info = RateLimitInfo::from_headers(resp_headers);
if rate_info.has_data() {
self.binance.rate_limiter().update(rate_info);
}
}
if let Some(api_error) = BinanceApiError::from_json(&result) {
if api_error.is_ip_banned() {
self.binance
.rate_limiter()
.set_ip_banned(std::time::Duration::from_secs(7200));
}
return Err(api_error.into());
}
Ok(result)
}
}
#[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 binance = Binance::new(config).unwrap();
let builder = SignedRequestBuilder::new(&binance, "https://api.binance.com/api/v3/account");
assert_eq!(builder.endpoint, "https://api.binance.com/api/v3/account");
assert_eq!(builder.method, HttpMethod::Get);
assert!(builder.params.is_empty());
}
#[test]
fn test_builder_method_chaining() {
let config = ExchangeConfig::default();
let binance = Binance::new(config).unwrap();
let builder = SignedRequestBuilder::new(&binance, "/api/v3/order")
.method(HttpMethod::Post)
.param("symbol", "BTCUSDT")
.param("side", "BUY");
assert_eq!(builder.method, HttpMethod::Post);
assert_eq!(builder.params.get("symbol"), Some(&"BTCUSDT".to_string()));
assert_eq!(builder.params.get("side"), Some(&"BUY".to_string()));
}
#[test]
fn test_builder_param() {
let config = ExchangeConfig::default();
let binance = Binance::new(config).unwrap();
let builder = SignedRequestBuilder::new(&binance, "/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 binance = Binance::new(config).unwrap();
let builder = SignedRequestBuilder::new(&binance, "/test")
.optional_param("limit", Some(100u32))
.optional_param("since", Some(1234567890i64));
assert_eq!(builder.params.get("limit"), Some(&"100".to_string()));
assert_eq!(builder.params.get("since"), Some(&"1234567890".to_string()));
}
#[test]
fn test_builder_optional_param_none() {
let config = ExchangeConfig::default();
let binance = Binance::new(config).unwrap();
let none_value: Option<u32> = None;
let builder =
SignedRequestBuilder::new(&binance, "/test").optional_param("limit", none_value);
assert!(!builder.params.contains_key("limit"));
}
#[test]
fn test_builder_params_bulk() {
let config = ExchangeConfig::default();
let binance = Binance::new(config).unwrap();
let mut params = BTreeMap::new();
params.insert("symbol".to_string(), "BTCUSDT".to_string());
params.insert("side".to_string(), "BUY".to_string());
let builder = SignedRequestBuilder::new(&binance, "/test").params(params);
assert_eq!(builder.params.get("symbol"), Some(&"BTCUSDT".to_string()));
assert_eq!(builder.params.get("side"), Some(&"BUY".to_string()));
}
#[test]
fn test_builder_merge_json_params() {
let config = ExchangeConfig::default();
let binance = Binance::new(config).unwrap();
let json_params = Some(serde_json::json!({
"orderId": "12345",
"fromId": 67890,
"active": true,
"nested": {"ignored": "value"},
"array": [1, 2, 3]
}));
let builder = SignedRequestBuilder::new(&binance, "/test").merge_json_params(json_params);
assert_eq!(builder.params.get("orderId"), Some(&"12345".to_string()));
assert_eq!(builder.params.get("fromId"), Some(&"67890".to_string()));
assert_eq!(builder.params.get("active"), Some(&"true".to_string()));
assert!(!builder.params.contains_key("nested"));
assert!(!builder.params.contains_key("array"));
}
#[test]
fn test_builder_merge_json_params_none() {
let config = ExchangeConfig::default();
let binance = Binance::new(config).unwrap();
let builder = SignedRequestBuilder::new(&binance, "/test")
.param("existing", "value")
.merge_json_params(None);
assert_eq!(builder.params.get("existing"), Some(&"value".to_string()));
assert_eq!(builder.params.len(), 1);
}
#[test]
fn test_build_query_string() {
let mut params = BTreeMap::new();
params.insert("symbol".to_string(), "BTCUSDT".to_string());
params.insert("side".to_string(), "BUY".to_string());
params.insert("amount".to_string(), "1.5".to_string());
let query = build_query_string(¶ms);
assert_eq!(query, "amount=1.5&side=BUY&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_builder_all_http_methods() {
let config = ExchangeConfig::default();
let binance = Binance::new(config).unwrap();
let get_builder = SignedRequestBuilder::new(&binance, "/test").method(HttpMethod::Get);
assert_eq!(get_builder.method, HttpMethod::Get);
let post_builder = SignedRequestBuilder::new(&binance, "/test").method(HttpMethod::Post);
assert_eq!(post_builder.method, HttpMethod::Post);
let put_builder = SignedRequestBuilder::new(&binance, "/test").method(HttpMethod::Put);
assert_eq!(put_builder.method, HttpMethod::Put);
let delete_builder =
SignedRequestBuilder::new(&binance, "/test").method(HttpMethod::Delete);
assert_eq!(delete_builder.method, HttpMethod::Delete);
}
#[test]
fn test_builder_parameter_ordering() {
let config = ExchangeConfig::default();
let binance = Binance::new(config).unwrap();
let builder = SignedRequestBuilder::new(&binance, "/test")
.param("zebra", "z")
.param("apple", "a")
.param("mango", "m");
let keys: Vec<_> = builder.params.keys().collect();
assert_eq!(keys, vec!["apple", "mango", "zebra"]);
}
}
#[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_signed_request_contains_required_fields(
params in params_strategy(),
recv_window in 1000u64..60000u64
) {
let config = ExchangeConfig {
api_key: Some("test_api_key".to_string().into()),
secret: Some("test_secret_key".to_string().into()),
..Default::default()
};
let options = super::super::BinanceOptions {
recv_window,
..Default::default()
};
let binance = super::super::Binance::new_with_options(config, options).expect("Failed to create Binance");
let auth = binance.get_auth().expect("Failed to get auth");
let timestamp = 1234567890000i64;
let signed_params = auth.sign_with_timestamp(¶ms, timestamp, Some(recv_window)).expect("Sign timestamp failed");
prop_assert!(signed_params.contains_key("timestamp"));
prop_assert_eq!(signed_params.get("timestamp").expect("Missing timestamp"), ×tamp.to_string());
prop_assert!(signed_params.contains_key("signature"));
let signature = signed_params.get("signature").expect("Missing signature");
prop_assert_eq!(signature.len(), 64, "Signature should be 64 hex characters");
prop_assert!(signature.chars().all(|c| c.is_ascii_hexdigit()), "Signature should be hex");
prop_assert!(signed_params.contains_key("recvWindow"));
prop_assert_eq!(signed_params.get("recvWindow").expect("Missing recvWindow"), &recv_window.to_string());
for (key, value) in ¶ms {
prop_assert!(signed_params.contains_key(key), "Original param {} should be preserved", key);
prop_assert_eq!(signed_params.get(key).expect("Missing param"), value, "Original param {} value should match", key);
}
}
#[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().prop_filter("other_key must differ from key", |k| k != "z"),
other_value in param_value_strategy()
) {
prop_assume!(key != other_key);
let config = ExchangeConfig::default();
let binance = super::super::Binance::new(config).expect("Failed to create Binance");
let builder = SignedRequestBuilder::new(&binance, "/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).expect("Missing param"),
&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).expect("Missing other param"),
&other_value,
"Other parameter {} should have correct value",
other_key
);
}
}
}