/*
* Binance Spot WebSocket API
*
* OpenAPI Specifications for the Binance Spot WebSocket API
*
* API documents:
* - [Github web-socket-api documentation file](https://github.com/binance/binance-spot-api-docs/blob/master/web-socket-api.md)
* - [General API information for web-socket-api on website](https://developers.binance.com/docs/binance-spot-api-docs/web-socket-api/general-api-information)
*
*
* The version of the OpenAPI document: 1.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
#![allow(unused_imports)]
use anyhow::Context;
use async_trait::async_trait;
use derive_builder::Builder;
use rust_decimal::prelude::*;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{collections::BTreeMap, sync::Arc};
use crate::common::{
errors::WebsocketError,
models::{ParamBuildError, WebsocketApiResponse},
utils::remove_empty_value,
websocket::{WebsocketApi, WebsocketMessageSendOptions},
};
use crate::spot::websocket_api::models;
#[async_trait]
pub trait GeneralApi: Send + Sync {
async fn exchange_info(
&self,
params: ExchangeInfoParams,
) -> anyhow::Result<WebsocketApiResponse<Box<models::ExchangeInfoResponseResult>>>;
async fn execution_rules(
&self,
params: ExecutionRulesParams,
) -> anyhow::Result<WebsocketApiResponse<Box<models::ExecutionRulesResponseResult>>>;
async fn ping(
&self,
params: PingParams,
) -> anyhow::Result<WebsocketApiResponse<serde_json::Value>>;
async fn time(
&self,
params: TimeParams,
) -> anyhow::Result<WebsocketApiResponse<Box<models::TimeResponseResult>>>;
}
#[derive(Clone)]
pub struct GeneralApiClient {
websocket_api_base: Arc<WebsocketApi>,
}
impl GeneralApiClient {
pub fn new(websocket_api_base: Arc<WebsocketApi>) -> Self {
Self { websocket_api_base }
}
}
#[allow(non_camel_case_types)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ExchangeInfoSymbolStatusEnum {
#[serde(rename = "TRADING")]
Trading,
#[serde(rename = "END_OF_DAY")]
EndOfDay,
#[serde(rename = "HALT")]
Halt,
#[serde(rename = "BREAK")]
Break,
#[serde(rename = "NON_REPRESENTABLE")]
NonRepresentable,
}
impl ExchangeInfoSymbolStatusEnum {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Trading => "TRADING",
Self::EndOfDay => "END_OF_DAY",
Self::Halt => "HALT",
Self::Break => "BREAK",
Self::NonRepresentable => "NON_REPRESENTABLE",
}
}
}
impl std::str::FromStr for ExchangeInfoSymbolStatusEnum {
type Err = Box<dyn std::error::Error + Send + Sync>;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"TRADING" => Ok(Self::Trading),
"END_OF_DAY" => Ok(Self::EndOfDay),
"HALT" => Ok(Self::Halt),
"BREAK" => Ok(Self::Break),
"NON_REPRESENTABLE" => Ok(Self::NonRepresentable),
other => Err(format!("invalid ExchangeInfoSymbolStatusEnum: {}", other).into()),
}
}
}
#[allow(non_camel_case_types)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ExecutionRulesSymbolStatusEnum {
#[serde(rename = "TRADING")]
Trading,
#[serde(rename = "END_OF_DAY")]
EndOfDay,
#[serde(rename = "HALT")]
Halt,
#[serde(rename = "BREAK")]
Break,
#[serde(rename = "NON_REPRESENTABLE")]
NonRepresentable,
}
impl ExecutionRulesSymbolStatusEnum {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Trading => "TRADING",
Self::EndOfDay => "END_OF_DAY",
Self::Halt => "HALT",
Self::Break => "BREAK",
Self::NonRepresentable => "NON_REPRESENTABLE",
}
}
}
impl std::str::FromStr for ExecutionRulesSymbolStatusEnum {
type Err = Box<dyn std::error::Error + Send + Sync>;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"TRADING" => Ok(Self::Trading),
"END_OF_DAY" => Ok(Self::EndOfDay),
"HALT" => Ok(Self::Halt),
"BREAK" => Ok(Self::Break),
"NON_REPRESENTABLE" => Ok(Self::NonRepresentable),
other => Err(format!("invalid ExecutionRulesSymbolStatusEnum: {}", other).into()),
}
}
}
/// Request parameters for the [`exchange_info`] operation.
///
/// This struct holds all of the inputs you can pass when calling
/// [`exchange_info`](#method.exchange_info).
#[derive(Clone, Debug, Builder, Default)]
#[builder(pattern = "owned", build_fn(error = "ParamBuildError"))]
pub struct ExchangeInfoParams {
/// Unique WebSocket request ID.
///
/// This field is **optional.
#[builder(setter(into), default)]
pub id: Option<String>,
/// Describe a single symbol
///
/// This field is **optional.
#[builder(setter(into), default)]
pub symbol: Option<String>,
/// List of symbols to query
///
/// This field is **optional.
#[builder(setter(into), default)]
pub symbols: Option<Vec<String>>,
///
/// The `permissions` parameter.
///
/// This field is **optional.
#[builder(setter(into), default)]
pub permissions: Option<Vec<String>>,
///
/// The `show_permission_sets` parameter.
///
/// This field is **optional.
#[builder(setter(into), default)]
pub show_permission_sets: Option<bool>,
///
/// The `symbol_status` parameter.
///
/// This field is **optional.
#[builder(setter(into), default)]
pub symbol_status: Option<ExchangeInfoSymbolStatusEnum>,
}
impl ExchangeInfoParams {
/// Create a builder for [`exchange_info`].
///
#[must_use]
pub fn builder() -> ExchangeInfoParamsBuilder {
ExchangeInfoParamsBuilder::default()
}
}
/// Request parameters for the [`execution_rules`] operation.
///
/// This struct holds all of the inputs you can pass when calling
/// [`execution_rules`](#method.execution_rules).
#[derive(Clone, Debug, Builder, Default)]
#[builder(pattern = "owned", build_fn(error = "ParamBuildError"))]
pub struct ExecutionRulesParams {
/// Unique WebSocket request ID.
///
/// This field is **optional.
#[builder(setter(into), default)]
pub id: Option<String>,
/// Describe a single symbol
///
/// This field is **optional.
#[builder(setter(into), default)]
pub symbol: Option<String>,
/// List of symbols to query
///
/// This field is **optional.
#[builder(setter(into), default)]
pub symbols: Option<Vec<String>>,
///
/// The `symbol_status` parameter.
///
/// This field is **optional.
#[builder(setter(into), default)]
pub symbol_status: Option<ExecutionRulesSymbolStatusEnum>,
}
impl ExecutionRulesParams {
/// Create a builder for [`execution_rules`].
///
#[must_use]
pub fn builder() -> ExecutionRulesParamsBuilder {
ExecutionRulesParamsBuilder::default()
}
}
/// Request parameters for the [`ping`] operation.
///
/// This struct holds all of the inputs you can pass when calling
/// [`ping`](#method.ping).
#[derive(Clone, Debug, Builder, Default)]
#[builder(pattern = "owned", build_fn(error = "ParamBuildError"))]
pub struct PingParams {
/// Unique WebSocket request ID.
///
/// This field is **optional.
#[builder(setter(into), default)]
pub id: Option<String>,
}
impl PingParams {
/// Create a builder for [`ping`].
///
#[must_use]
pub fn builder() -> PingParamsBuilder {
PingParamsBuilder::default()
}
}
/// Request parameters for the [`time`] operation.
///
/// This struct holds all of the inputs you can pass when calling
/// [`time`](#method.time).
#[derive(Clone, Debug, Builder, Default)]
#[builder(pattern = "owned", build_fn(error = "ParamBuildError"))]
pub struct TimeParams {
/// Unique WebSocket request ID.
///
/// This field is **optional.
#[builder(setter(into), default)]
pub id: Option<String>,
}
impl TimeParams {
/// Create a builder for [`time`].
///
#[must_use]
pub fn builder() -> TimeParamsBuilder {
TimeParamsBuilder::default()
}
}
#[async_trait]
impl GeneralApi for GeneralApiClient {
async fn exchange_info(
&self,
params: ExchangeInfoParams,
) -> anyhow::Result<WebsocketApiResponse<Box<models::ExchangeInfoResponseResult>>> {
let ExchangeInfoParams {
id,
symbol,
symbols,
permissions,
show_permission_sets,
symbol_status,
} = params;
let mut payload: BTreeMap<String, Value> = BTreeMap::new();
if let Some(value) = id {
payload.insert("id".to_string(), serde_json::json!(value));
}
if let Some(value) = symbol {
payload.insert("symbol".to_string(), serde_json::json!(value));
}
if let Some(value) = symbols {
payload.insert("symbols".to_string(), serde_json::json!(value));
}
if let Some(value) = permissions {
payload.insert("permissions".to_string(), serde_json::json!(value));
}
if let Some(value) = show_permission_sets {
payload.insert("showPermissionSets".to_string(), serde_json::json!(value));
}
if let Some(value) = symbol_status {
payload.insert("symbolStatus".to_string(), serde_json::json!(value));
}
let payload = remove_empty_value(payload);
self.websocket_api_base
.send_message::<Box<models::ExchangeInfoResponseResult>>(
"/exchangeInfo".trim_start_matches('/'),
payload,
WebsocketMessageSendOptions::new(),
)
.await
.map_err(anyhow::Error::from)?
.into_iter()
.next()
.ok_or(WebsocketError::NoResponse)
.map_err(anyhow::Error::from)
}
async fn execution_rules(
&self,
params: ExecutionRulesParams,
) -> anyhow::Result<WebsocketApiResponse<Box<models::ExecutionRulesResponseResult>>> {
let ExecutionRulesParams {
id,
symbol,
symbols,
symbol_status,
} = params;
let mut payload: BTreeMap<String, Value> = BTreeMap::new();
if let Some(value) = id {
payload.insert("id".to_string(), serde_json::json!(value));
}
if let Some(value) = symbol {
payload.insert("symbol".to_string(), serde_json::json!(value));
}
if let Some(value) = symbols {
payload.insert("symbols".to_string(), serde_json::json!(value));
}
if let Some(value) = symbol_status {
payload.insert("symbolStatus".to_string(), serde_json::json!(value));
}
let payload = remove_empty_value(payload);
self.websocket_api_base
.send_message::<Box<models::ExecutionRulesResponseResult>>(
"/executionRules".trim_start_matches('/'),
payload,
WebsocketMessageSendOptions::new(),
)
.await
.map_err(anyhow::Error::from)?
.into_iter()
.next()
.ok_or(WebsocketError::NoResponse)
.map_err(anyhow::Error::from)
}
async fn ping(
&self,
params: PingParams,
) -> anyhow::Result<WebsocketApiResponse<serde_json::Value>> {
let PingParams { id } = params;
let mut payload: BTreeMap<String, Value> = BTreeMap::new();
if let Some(value) = id {
payload.insert("id".to_string(), serde_json::json!(value));
}
let payload = remove_empty_value(payload);
self.websocket_api_base
.send_message::<serde_json::Value>(
"/ping".trim_start_matches('/'),
payload,
WebsocketMessageSendOptions::new(),
)
.await
.map_err(anyhow::Error::from)?
.into_iter()
.next()
.ok_or(WebsocketError::NoResponse)
.map_err(anyhow::Error::from)
}
async fn time(
&self,
params: TimeParams,
) -> anyhow::Result<WebsocketApiResponse<Box<models::TimeResponseResult>>> {
let TimeParams { id } = params;
let mut payload: BTreeMap<String, Value> = BTreeMap::new();
if let Some(value) = id {
payload.insert("id".to_string(), serde_json::json!(value));
}
let payload = remove_empty_value(payload);
self.websocket_api_base
.send_message::<Box<models::TimeResponseResult>>(
"/time".trim_start_matches('/'),
payload,
WebsocketMessageSendOptions::new(),
)
.await
.map_err(anyhow::Error::from)?
.into_iter()
.next()
.ok_or(WebsocketError::NoResponse)
.map_err(anyhow::Error::from)
}
}
#[cfg(all(test, feature = "spot"))]
mod tests {
use super::*;
use crate::TOKIO_SHARED_RT;
use crate::common::websocket::{WebsocketApi, WebsocketConnection, WebsocketHandler};
use crate::config::ConfigurationWebsocketApi;
use crate::errors::WebsocketError;
use crate::models::WebsocketApiRateLimit;
use serde_json::{Value, json};
use tokio::spawn;
use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel};
use tokio::time::{Duration, timeout};
use tokio_tungstenite::tungstenite::Message;
async fn setup() -> (
Arc<WebsocketApi>,
Arc<WebsocketConnection>,
UnboundedReceiver<Message>,
) {
let conn = WebsocketConnection::new("test-conn");
let (tx, rx) = unbounded_channel::<Message>();
{
let mut conn_state = conn.state.lock().await;
conn_state.ws_write_tx = Some(tx);
}
let config = ConfigurationWebsocketApi::builder()
.api_key("key")
.api_secret("secret")
.build()
.expect("Failed to build configuration");
let ws_api = WebsocketApi::new(config, vec![conn.clone()]);
conn.set_handler(ws_api.clone() as Arc<dyn WebsocketHandler>)
.await;
ws_api.clone().connect().await.unwrap();
(ws_api, conn, rx)
}
#[test]
fn exchange_info_success() {
TOKIO_SHARED_RT.block_on(async {
let (ws_api, conn, mut rx) = setup().await;
let client = GeneralApiClient::new(ws_api.clone());
let handle = spawn(async move {
let params = ExchangeInfoParams::builder().build().unwrap();
client.exchange_info(params).await
});
let sent = timeout(Duration::from_secs(1), rx.recv()).await.expect("send should occur").expect("channel closed");
let Message::Text(text) = sent else { panic!() };
let v: Value = serde_json::from_str(&text).unwrap();
let id = v["id"].as_str().unwrap();
assert_eq!(v["method"], "/exchangeInfo".trim_start_matches('/'));
let mut resp_json: Value = serde_json::from_str(r#"{"id":"5494febb-d167-46a2-996d-70533eb4d976","status":200,"result":{"timezone":"UTC","serverTime":1655969291181,"rateLimits":[{"rateLimitType":"REQUEST_WEIGHT","interval":"MINUTE","intervalNum":1,"limit":6000},{"rateLimitType":"ORDERS","interval":"SECOND","intervalNum":10,"limit":50},{"rateLimitType":"ORDERS","interval":"DAY","intervalNum":1,"limit":160000},{"rateLimitType":"CONNECTIONS","interval":"MINUTE","intervalNum":5,"limit":300}],"exchangeFilters":[],"symbols":[{"symbol":"BNBBTC","status":"TRADING","baseAsset":"BNB","baseAssetPrecision":8,"quoteAsset":"BTC","quotePrecision":8,"quoteAssetPrecision":8,"baseCommissionPrecision":8,"quoteCommissionPrecision":8,"orderTypes":["LIMIT LIMIT_MAKER MARKET STOP_LOSS_LIMIT TAKE_PROFIT_LIMIT"],"icebergAllowed":true,"ocoAllowed":true,"otoAllowed":true,"opoAllowed":true,"quoteOrderQtyMarketAllowed":true,"allowTrailingStop":true,"cancelReplaceAllowed":true,"amendAllowed":false,"pegInstructionsAllowed":true,"isSpotTradingAllowed":true,"isMarginTradingAllowed":true,"filters":[{"filterType":"PRICE_FILTER","minPrice":"0.00000100","maxPrice":"100000.00000000","tickSize":"0.00000100"},{"filterType":"LOT_SIZE","minQty":"0.00100000","maxQty":"100000.00000000","stepSize":"0.00100000"}],"permissions":[],"permissionSets":[["SPOT","MARGIN","TRD_GRP_004"]],"defaultSelfTradePreventionMode":"NONE","allowedSelfTradePreventionModes":["NONE"]}],"sors":[{"baseAsset":"BTC","symbols":["BTCUSDT BTCUSDC"]}]},"rateLimits":[{"rateLimitType":"REQUEST_WEIGHT","interval":"MINUTE","intervalNum":1,"limit":6000},{"rateLimitType":"ORDERS","interval":"DAY","intervalNum":1,"limit":160000},{"rateLimitType":"RAW_REQUESTS","interval":"MINUTE","intervalNum":5,"limit":61000}]}"#).unwrap();
resp_json["id"] = id.into();
let raw_data = resp_json.get("result").or_else(|| resp_json.get("response")).expect("no response in JSON");
let expected_data: Box<models::ExchangeInfoResponseResult> = serde_json::from_value(raw_data.clone()).expect("should parse raw response");
let empty_array = Value::Array(vec![]);
let raw_rate_limits = resp_json.get("rateLimits").unwrap_or(&empty_array);
let expected_rate_limits: Option<Vec<WebsocketApiRateLimit>> =
match raw_rate_limits.as_array() {
Some(arr) if arr.is_empty() => None,
Some(_) => Some(serde_json::from_value(raw_rate_limits.clone()).expect("should parse rateLimits array")),
None => None,
};
WebsocketHandler::on_message(&*ws_api, resp_json.to_string(), conn.clone()).await;
let response = timeout(Duration::from_secs(1), handle).await.expect("task done").expect("no panic").expect("no error");
let response_rate_limits = response.rate_limits.clone();
let response_data = response.data().expect("deserialize data");
assert_eq!(response_rate_limits, expected_rate_limits);
assert_eq!(response_data, expected_data);
});
}
#[test]
fn exchange_info_error_response() {
TOKIO_SHARED_RT.block_on(async {
let (ws_api, conn, mut rx) = setup().await;
let client = GeneralApiClient::new(ws_api.clone());
let handle = tokio::spawn(async move {
let params = ExchangeInfoParams::builder().build().unwrap();
client.exchange_info(params).await
});
let sent = timeout(Duration::from_secs(1), rx.recv()).await.unwrap().unwrap();
let Message::Text(text) = sent else { panic!() };
let v: Value = serde_json::from_str(&text).unwrap();
let id = v["id"].as_str().unwrap().to_string();
let resp_json = json!({
"id": id,
"status": 400,
"error": {
"code": -2010,
"msg": "Account has insufficient balance for requested action.",
},
"rateLimits": [
{
"rateLimitType": "ORDERS",
"interval": "SECOND",
"intervalNum": 10,
"limit": 50,
"count": 13
},
],
});
WebsocketHandler::on_message(&*ws_api, resp_json.to_string(), conn.clone()).await;
let join = timeout(Duration::from_secs(1), handle).await.unwrap();
match join {
Ok(Err(e)) => {
let msg = e.to_string();
assert!(
msg.contains("Server‐side response error (code -2010): Account has insufficient balance for requested action."),
"Expected error msg to contain server error, got: {msg}"
);
}
Ok(Ok(_)) => panic!("Expected error"),
Err(_) => panic!("Task panicked"),
}
});
}
#[test]
fn exchange_info_request_timeout() {
TOKIO_SHARED_RT.block_on(async {
let (ws_api, _conn, mut rx) = setup().await;
let client = GeneralApiClient::new(ws_api.clone());
let handle = spawn(async move {
let params = ExchangeInfoParams::builder().build().unwrap();
client.exchange_info(params).await
});
let sent = timeout(Duration::from_secs(1), rx.recv())
.await
.expect("send should occur")
.expect("channel closed");
let Message::Text(text) = sent else {
panic!("expected Message Text")
};
let _: Value = serde_json::from_str(&text).unwrap();
let result = handle.await.expect("task completed");
match result {
Err(e) => {
if let Some(inner) = e.downcast_ref::<WebsocketError>() {
assert!(matches!(inner, WebsocketError::Timeout));
} else {
panic!("Unexpected error type: {:?}", e);
}
}
Ok(_) => panic!("Expected timeout error"),
}
});
}
#[test]
fn execution_rules_success() {
TOKIO_SHARED_RT.block_on(async {
let (ws_api, conn, mut rx) = setup().await;
let client = GeneralApiClient::new(ws_api.clone());
let handle = spawn(async move {
let params = ExecutionRulesParams::builder().build().unwrap();
client.execution_rules(params).await
});
let sent = timeout(Duration::from_secs(1), rx.recv()).await.expect("send should occur").expect("channel closed");
let Message::Text(text) = sent else { panic!() };
let v: Value = serde_json::from_str(&text).unwrap();
let id = v["id"].as_str().unwrap();
assert_eq!(v["method"], "/executionRules".trim_start_matches('/'));
let mut resp_json: Value = serde_json::from_str(r#"{"id":"5162affb-0aba-4821-b475-f2625006eb43","status":200,"result":{"symbolRules":[{"symbol":"BAZUSD","rules":[{"ruleType":"PRICE_RANGE","bidLimitMultUp":"1.0001","bidLimitMultDown":"0.9999","askLimitMultUp":"1.0001","askLimitMultDown":"0.9999"}]}]}}"#).unwrap();
resp_json["id"] = id.into();
let raw_data = resp_json.get("result").or_else(|| resp_json.get("response")).expect("no response in JSON");
let expected_data: Box<models::ExecutionRulesResponseResult> = serde_json::from_value(raw_data.clone()).expect("should parse raw response");
let empty_array = Value::Array(vec![]);
let raw_rate_limits = resp_json.get("rateLimits").unwrap_or(&empty_array);
let expected_rate_limits: Option<Vec<WebsocketApiRateLimit>> =
match raw_rate_limits.as_array() {
Some(arr) if arr.is_empty() => None,
Some(_) => Some(serde_json::from_value(raw_rate_limits.clone()).expect("should parse rateLimits array")),
None => None,
};
WebsocketHandler::on_message(&*ws_api, resp_json.to_string(), conn.clone()).await;
let response = timeout(Duration::from_secs(1), handle).await.expect("task done").expect("no panic").expect("no error");
let response_rate_limits = response.rate_limits.clone();
let response_data = response.data().expect("deserialize data");
assert_eq!(response_rate_limits, expected_rate_limits);
assert_eq!(response_data, expected_data);
});
}
#[test]
fn execution_rules_error_response() {
TOKIO_SHARED_RT.block_on(async {
let (ws_api, conn, mut rx) = setup().await;
let client = GeneralApiClient::new(ws_api.clone());
let handle = tokio::spawn(async move {
let params = ExecutionRulesParams::builder().build().unwrap();
client.execution_rules(params).await
});
let sent = timeout(Duration::from_secs(1), rx.recv()).await.unwrap().unwrap();
let Message::Text(text) = sent else { panic!() };
let v: Value = serde_json::from_str(&text).unwrap();
let id = v["id"].as_str().unwrap().to_string();
let resp_json = json!({
"id": id,
"status": 400,
"error": {
"code": -2010,
"msg": "Account has insufficient balance for requested action.",
},
"rateLimits": [
{
"rateLimitType": "ORDERS",
"interval": "SECOND",
"intervalNum": 10,
"limit": 50,
"count": 13
},
],
});
WebsocketHandler::on_message(&*ws_api, resp_json.to_string(), conn.clone()).await;
let join = timeout(Duration::from_secs(1), handle).await.unwrap();
match join {
Ok(Err(e)) => {
let msg = e.to_string();
assert!(
msg.contains("Server‐side response error (code -2010): Account has insufficient balance for requested action."),
"Expected error msg to contain server error, got: {msg}"
);
}
Ok(Ok(_)) => panic!("Expected error"),
Err(_) => panic!("Task panicked"),
}
});
}
#[test]
fn execution_rules_request_timeout() {
TOKIO_SHARED_RT.block_on(async {
let (ws_api, _conn, mut rx) = setup().await;
let client = GeneralApiClient::new(ws_api.clone());
let handle = spawn(async move {
let params = ExecutionRulesParams::builder().build().unwrap();
client.execution_rules(params).await
});
let sent = timeout(Duration::from_secs(1), rx.recv())
.await
.expect("send should occur")
.expect("channel closed");
let Message::Text(text) = sent else {
panic!("expected Message Text")
};
let _: Value = serde_json::from_str(&text).unwrap();
let result = handle.await.expect("task completed");
match result {
Err(e) => {
if let Some(inner) = e.downcast_ref::<WebsocketError>() {
assert!(matches!(inner, WebsocketError::Timeout));
} else {
panic!("Unexpected error type: {:?}", e);
}
}
Ok(_) => panic!("Expected timeout error"),
}
});
}
#[test]
fn ping_success() {
TOKIO_SHARED_RT.block_on(async {
let (ws_api, conn, mut rx) = setup().await;
let client = GeneralApiClient::new(ws_api.clone());
let handle = spawn(async move {
let params = PingParams::builder().build().unwrap();
client.ping(params).await
});
let sent = timeout(Duration::from_secs(1), rx.recv()).await.expect("send should occur").expect("channel closed");
let Message::Text(text) = sent else { panic!() };
let v: Value = serde_json::from_str(&text).unwrap();
let id = v["id"].as_str().unwrap();
assert_eq!(v["method"], "/ping".trim_start_matches('/'));
let mut resp_json: Value = serde_json::from_str(r#"{"id":"922bcc6e-9de8-440d-9e84-7c80933a8d0d","status":200,"result":{},"rateLimits":[{"rateLimitType":"REQUEST_WEIGHT","interval":"MINUTE","intervalNum":1,"limit":6000,"count":1}]}"#).unwrap();
resp_json["id"] = id.into();
let raw_data = resp_json.get("result").or_else(|| resp_json.get("response")).expect("no response in JSON");
let expected_data: serde_json::Value = serde_json::from_value(raw_data.clone()).expect("should parse raw response");
let empty_array = Value::Array(vec![]);
let raw_rate_limits = resp_json.get("rateLimits").unwrap_or(&empty_array);
let expected_rate_limits: Option<Vec<WebsocketApiRateLimit>> =
match raw_rate_limits.as_array() {
Some(arr) if arr.is_empty() => None,
Some(_) => Some(serde_json::from_value(raw_rate_limits.clone()).expect("should parse rateLimits array")),
None => None,
};
WebsocketHandler::on_message(&*ws_api, resp_json.to_string(), conn.clone()).await;
let response = timeout(Duration::from_secs(1), handle).await.expect("task done").expect("no panic").expect("no error");
let response_rate_limits = response.rate_limits.clone();
let response_data = response.data().expect("deserialize data");
assert_eq!(response_rate_limits, expected_rate_limits);
assert_eq!(response_data, expected_data);
});
}
#[test]
fn ping_error_response() {
TOKIO_SHARED_RT.block_on(async {
let (ws_api, conn, mut rx) = setup().await;
let client = GeneralApiClient::new(ws_api.clone());
let handle = tokio::spawn(async move {
let params = PingParams::builder().build().unwrap();
client.ping(params).await
});
let sent = timeout(Duration::from_secs(1), rx.recv()).await.unwrap().unwrap();
let Message::Text(text) = sent else { panic!() };
let v: Value = serde_json::from_str(&text).unwrap();
let id = v["id"].as_str().unwrap().to_string();
let resp_json = json!({
"id": id,
"status": 400,
"error": {
"code": -2010,
"msg": "Account has insufficient balance for requested action.",
},
"rateLimits": [
{
"rateLimitType": "ORDERS",
"interval": "SECOND",
"intervalNum": 10,
"limit": 50,
"count": 13
},
],
});
WebsocketHandler::on_message(&*ws_api, resp_json.to_string(), conn.clone()).await;
let join = timeout(Duration::from_secs(1), handle).await.unwrap();
match join {
Ok(Err(e)) => {
let msg = e.to_string();
assert!(
msg.contains("Server‐side response error (code -2010): Account has insufficient balance for requested action."),
"Expected error msg to contain server error, got: {msg}"
);
}
Ok(Ok(_)) => panic!("Expected error"),
Err(_) => panic!("Task panicked"),
}
});
}
#[test]
fn ping_request_timeout() {
TOKIO_SHARED_RT.block_on(async {
let (ws_api, _conn, mut rx) = setup().await;
let client = GeneralApiClient::new(ws_api.clone());
let handle = spawn(async move {
let params = PingParams::builder().build().unwrap();
client.ping(params).await
});
let sent = timeout(Duration::from_secs(1), rx.recv())
.await
.expect("send should occur")
.expect("channel closed");
let Message::Text(text) = sent else {
panic!("expected Message Text")
};
let _: Value = serde_json::from_str(&text).unwrap();
let result = handle.await.expect("task completed");
match result {
Err(e) => {
if let Some(inner) = e.downcast_ref::<WebsocketError>() {
assert!(matches!(inner, WebsocketError::Timeout));
} else {
panic!("Unexpected error type: {:?}", e);
}
}
Ok(_) => panic!("Expected timeout error"),
}
});
}
#[test]
fn time_success() {
TOKIO_SHARED_RT.block_on(async {
let (ws_api, conn, mut rx) = setup().await;
let client = GeneralApiClient::new(ws_api.clone());
let handle = spawn(async move {
let params = TimeParams::builder().build().unwrap();
client.time(params).await
});
let sent = timeout(Duration::from_secs(1), rx.recv()).await.expect("send should occur").expect("channel closed");
let Message::Text(text) = sent else { panic!() };
let v: Value = serde_json::from_str(&text).unwrap();
let id = v["id"].as_str().unwrap();
assert_eq!(v["method"], "/time".trim_start_matches('/'));
let mut resp_json: Value = serde_json::from_str(r#"{"id":"187d3cb2-942d-484c-8271-4e2141bbadb1","status":200,"result":{"serverTime":1656400526260},"rateLimits":[{"rateLimitType":"REQUEST_WEIGHT","interval":"MINUTE","intervalNum":1,"limit":6000,"count":1}]}"#).unwrap();
resp_json["id"] = id.into();
let raw_data = resp_json.get("result").or_else(|| resp_json.get("response")).expect("no response in JSON");
let expected_data: Box<models::TimeResponseResult> = serde_json::from_value(raw_data.clone()).expect("should parse raw response");
let empty_array = Value::Array(vec![]);
let raw_rate_limits = resp_json.get("rateLimits").unwrap_or(&empty_array);
let expected_rate_limits: Option<Vec<WebsocketApiRateLimit>> =
match raw_rate_limits.as_array() {
Some(arr) if arr.is_empty() => None,
Some(_) => Some(serde_json::from_value(raw_rate_limits.clone()).expect("should parse rateLimits array")),
None => None,
};
WebsocketHandler::on_message(&*ws_api, resp_json.to_string(), conn.clone()).await;
let response = timeout(Duration::from_secs(1), handle).await.expect("task done").expect("no panic").expect("no error");
let response_rate_limits = response.rate_limits.clone();
let response_data = response.data().expect("deserialize data");
assert_eq!(response_rate_limits, expected_rate_limits);
assert_eq!(response_data, expected_data);
});
}
#[test]
fn time_error_response() {
TOKIO_SHARED_RT.block_on(async {
let (ws_api, conn, mut rx) = setup().await;
let client = GeneralApiClient::new(ws_api.clone());
let handle = tokio::spawn(async move {
let params = TimeParams::builder().build().unwrap();
client.time(params).await
});
let sent = timeout(Duration::from_secs(1), rx.recv()).await.unwrap().unwrap();
let Message::Text(text) = sent else { panic!() };
let v: Value = serde_json::from_str(&text).unwrap();
let id = v["id"].as_str().unwrap().to_string();
let resp_json = json!({
"id": id,
"status": 400,
"error": {
"code": -2010,
"msg": "Account has insufficient balance for requested action.",
},
"rateLimits": [
{
"rateLimitType": "ORDERS",
"interval": "SECOND",
"intervalNum": 10,
"limit": 50,
"count": 13
},
],
});
WebsocketHandler::on_message(&*ws_api, resp_json.to_string(), conn.clone()).await;
let join = timeout(Duration::from_secs(1), handle).await.unwrap();
match join {
Ok(Err(e)) => {
let msg = e.to_string();
assert!(
msg.contains("Server‐side response error (code -2010): Account has insufficient balance for requested action."),
"Expected error msg to contain server error, got: {msg}"
);
}
Ok(Ok(_)) => panic!("Expected error"),
Err(_) => panic!("Task panicked"),
}
});
}
#[test]
fn time_request_timeout() {
TOKIO_SHARED_RT.block_on(async {
let (ws_api, _conn, mut rx) = setup().await;
let client = GeneralApiClient::new(ws_api.clone());
let handle = spawn(async move {
let params = TimeParams::builder().build().unwrap();
client.time(params).await
});
let sent = timeout(Duration::from_secs(1), rx.recv())
.await
.expect("send should occur")
.expect("channel closed");
let Message::Text(text) = sent else {
panic!("expected Message Text")
};
let _: Value = serde_json::from_str(&text).unwrap();
let result = handle.await.expect("task completed");
match result {
Err(e) => {
if let Some(inner) = e.downcast_ref::<WebsocketError>() {
assert!(matches!(inner, WebsocketError::Timeout));
} else {
panic!("Unexpected error type: {:?}", e);
}
}
Ok(_) => panic!("Expected timeout error"),
}
});
}
}