mod builder;
use std::str::FromStr;
use crate::{
error::{CryptoBotError, CryptoBotResult},
models::{APIMethod, ApiResponse, Method},
};
#[cfg(test)]
use crate::models::ExchangeRate;
use builder::{ClientBuilder, NoAPIToken};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde::{de::DeserializeOwned, Serialize};
pub const DEFAULT_API_URL: &str = "https://pay.crypt.bot/api";
pub const DEFAULT_TIMEOUT: u64 = 30;
pub const DEFAULT_WEBHOOK_EXPIRATION_TIME: u64 = 600;
#[derive(Debug)]
pub struct CryptoBot {
pub(crate) api_token: String,
pub(crate) client: reqwest::Client,
pub(crate) base_url: String,
pub(crate) headers: Option<Vec<(HeaderName, HeaderValue)>>,
#[cfg(test)]
pub(crate) test_rates: Option<Vec<ExchangeRate>>,
}
impl CryptoBot {
pub fn builder() -> ClientBuilder<NoAPIToken> {
ClientBuilder::new()
}
pub(crate) async fn make_request<T, R>(&self, method: &APIMethod, params: Option<&T>) -> CryptoBotResult<R>
where
T: Serialize + ?Sized,
R: DeserializeOwned,
{
let url = format!("{}/{}", self.base_url, method.endpoint.as_str());
let mut request_headers = HeaderMap::new();
let token_header = HeaderName::from_str("Crypto-Pay-Api-Token")?;
request_headers.insert(token_header, HeaderValue::from_str(&self.api_token)?);
if let Some(custom_headers) = &self.headers {
for (name, value) in custom_headers.iter() {
request_headers.insert(name, value.clone());
}
}
let mut request = match method.method {
Method::POST => self.client.post(&url).headers(request_headers),
Method::GET => self.client.get(&url).headers(request_headers),
Method::DELETE => self.client.delete(&url).headers(request_headers),
};
if let Some(params) = params {
request = request.json(params);
}
let response = request.send().await?;
if !response.status().is_success() {
return Err(CryptoBotError::HttpError(response.error_for_status().unwrap_err()));
}
let text = response.text().await?;
let api_response: ApiResponse<R> = serde_json::from_str(&text).map_err(|e| CryptoBotError::ApiError {
code: -1,
message: "Failed to parse API response".to_string(),
details: Some(serde_json::json!({ "error": e.to_string() })),
})?;
if !api_response.ok {
return Err(CryptoBotError::ApiError {
code: api_response.error_code.unwrap_or(0),
message: api_response.error.unwrap_or_default(),
details: None,
});
}
api_response.result.ok_or(CryptoBotError::NoResult)
}
#[cfg(test)]
pub fn test_client() -> Self {
use crate::utils::test_utils::TestContext;
Self {
api_token: "test_token".to_string(),
client: reqwest::Client::new(),
base_url: "http://test.example.com".to_string(),
headers: None,
test_rates: Some(TestContext::mock_exchange_rates()),
}
}
}
#[cfg(test)]
mod tests {
use mockito::{Matcher, Mock};
use reqwest::header::{HeaderName, HeaderValue};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::{
api::BalanceAPI,
models::{APIEndpoint, Balance},
utils::test_utils::TestContext,
};
use super::*;
#[derive(Debug, Serialize)]
struct DummyPayload {
value: String,
}
#[derive(Debug, Deserialize, PartialEq)]
struct DummyResponse {
stored: String,
}
#[derive(Debug, Serialize)]
struct DeletePayload {
invoice_id: u64,
}
impl TestContext {
pub fn mock_malformed_json_response(&mut self) -> Mock {
self.server
.mock("GET", "/getBalance")
.with_header("content-type", "application/json")
.with_body("invalid json{")
.create()
}
}
#[test]
fn test_malformed_json_response() {
let mut ctx = TestContext::new();
let _m = ctx.mock_malformed_json_response();
let client = CryptoBot::builder()
.api_token("test")
.base_url(ctx.server.url())
.build()
.unwrap();
let result = ctx.run(async { client.get_balance().execute().await });
assert!(matches!(
result,
Err(CryptoBotError::ApiError {
code: -1,
message,
details: Some(details)
}) if message == "Failed to parse API response"
&& details.get("error").is_some()
));
}
#[test]
fn test_invalid_response_structure() {
let mut ctx = TestContext::new();
let _m = ctx
.server
.mock("GET", "/getBalance")
.with_header("content-type", "application/json")
.with_body(
json!({
"ok": true,
"result": "not_an_array"
})
.to_string(),
)
.create();
let client = CryptoBot::builder()
.api_token("test")
.base_url(ctx.server.url())
.build()
.unwrap();
let result = ctx.run(async { client.get_balance().execute().await });
assert!(matches!(
result,
Err(CryptoBotError::ApiError {
code: -1,
message,
details: Some(details)
}) if message == "Failed to parse API response"
&& details.get("error").is_some()
));
}
#[test]
fn test_empty_response() {
let mut ctx = TestContext::new();
let _m = ctx
.server
.mock("GET", "/getBalance")
.with_header("content-type", "application/json")
.with_body("")
.create();
let client = CryptoBot::builder()
.api_token("test")
.base_url(ctx.server.url())
.build()
.unwrap();
let result = ctx.run(async { client.get_balance().execute().await });
assert!(matches!(
result,
Err(CryptoBotError::ApiError {
code: -1,
message,
details: Some(details)
}) if message == "Failed to parse API response"
&& details.get("error").is_some()
));
}
#[test]
fn test_invalid_api_token_header() {
let client = CryptoBot {
api_token: "invalid\u{0000}token".to_string(),
client: reqwest::Client::new(),
base_url: "http://test.example.com".to_string(),
headers: None,
#[cfg(test)]
test_rates: None,
};
let method = APIMethod {
endpoint: APIEndpoint::GetBalance,
method: Method::GET,
};
let ctx = TestContext::new();
let result = ctx.run(async { client.make_request::<(), Vec<Balance>>(&method, None).await });
assert!(matches!(result, Err(CryptoBotError::InvalidHeaderValue(_))));
}
#[test]
fn test_api_error_response() {
let mut ctx = TestContext::new();
let _m = ctx
.server
.mock("GET", "/getBalance")
.with_header("content-type", "application/json")
.with_body(
json!({
"ok": false,
"error": "Test error message",
"error_code": 123
})
.to_string(),
)
.create();
let client = CryptoBot::builder()
.api_token("test")
.base_url(ctx.server.url())
.build()
.unwrap();
let result = ctx.run(async { client.get_balance().execute().await });
assert!(matches!(
result,
Err(CryptoBotError::ApiError {
code,
message,
details,
}) if code == 123
&& message == "Test error message"
&& details.is_none()
));
}
#[test]
fn test_api_error_response_missing_fields() {
let mut ctx = TestContext::new();
let _m = ctx
.server
.mock("GET", "/getBalance")
.with_header("content-type", "application/json")
.with_body(
json!({
"ok": false
})
.to_string(),
)
.create();
let client = CryptoBot::builder()
.api_token("test")
.base_url(ctx.server.url())
.build()
.unwrap();
let result = ctx.run(async { client.get_balance().execute().await });
assert!(matches!(
result,
Err(CryptoBotError::ApiError {
code,
message,
details,
}) if code == 0
&& message.is_empty()
&& details.is_none()
));
}
#[test]
fn test_api_error_response_partial_fields() {
let mut ctx = TestContext::new();
let _m = ctx
.server
.mock("GET", "/getBalance")
.with_header("content-type", "application/json")
.with_body(
json!({
"ok": false,
"error": "Test error message"
})
.to_string(),
)
.create();
let client = CryptoBot::builder()
.api_token("test")
.base_url(ctx.server.url())
.build()
.unwrap();
let result = ctx.run(async { client.get_balance().execute().await });
assert!(matches!(
result,
Err(CryptoBotError::ApiError {
code,
message,
details,
}) if code == 0
&& message == "Test error message"
&& details.is_none()
));
}
#[test]
fn test_http_error_response() {
let mut ctx = TestContext::new();
let _m = ctx.server.mock("GET", "/getBalance").with_status(404).create();
let client = CryptoBot::builder()
.api_token("test")
.base_url(ctx.server.url())
.build()
.unwrap();
let result = ctx.run(async { client.get_balance().execute().await });
assert!(matches!(result, Err(CryptoBotError::HttpError(_))));
}
#[test]
fn test_make_request_with_custom_headers_and_body() {
let mut ctx = TestContext::new();
let _m = ctx
.server
.mock("POST", "/createInvoice")
.match_header("X-Custom-Header", "test-value")
.match_header("Crypto-Pay-Api-Token", "test")
.match_body(Matcher::JsonString(
json!({
"value": "payload"
})
.to_string(),
))
.with_header("content-type", "application/json")
.with_body(
json!({
"ok": true,
"result": {
"stored": "payload"
}
})
.to_string(),
)
.create();
let client = CryptoBot::builder()
.headers(vec![(
HeaderName::from_static("x-custom-header"),
HeaderValue::from_static("test-value"),
)])
.api_token("test")
.base_url(ctx.server.url())
.build()
.unwrap();
let method = APIMethod {
endpoint: APIEndpoint::CreateInvoice,
method: Method::POST,
};
let payload = DummyPayload {
value: "payload".to_string(),
};
let result: Result<DummyResponse, _> = ctx.run(async { client.make_request(&method, Some(&payload)).await });
assert_eq!(
result.unwrap(),
DummyResponse {
stored: "payload".to_string()
}
);
}
#[test]
fn test_make_request_delete_with_payload_and_headers() {
let mut ctx = TestContext::new();
let _m = ctx
.server
.mock("DELETE", "/deleteInvoice")
.match_header("X-Extra", "extra")
.match_header("Crypto-Pay-Api-Token", "test")
.match_body(Matcher::JsonString(
json!({
"invoice_id": 7
})
.to_string(),
))
.with_header("content-type", "application/json")
.with_body(json!({"ok": true, "result": true}).to_string())
.create();
let client = CryptoBot::builder()
.headers(vec![(
HeaderName::from_static("x-extra"),
HeaderValue::from_static("extra"),
)])
.api_token("test")
.base_url(ctx.server.url())
.build()
.unwrap();
let method = APIMethod {
endpoint: APIEndpoint::DeleteInvoice,
method: Method::DELETE,
};
let payload = DeletePayload { invoice_id: 7 };
let result: Result<bool, _> = ctx.run(async { client.make_request(&method, Some(&payload)).await });
assert_eq!(result.unwrap(), true);
}
}