1mod builder;
2
3use std::str::FromStr;
4
5use crate::{
6 error::{CryptoBotError, CryptoBotResult},
7 models::{APIMethod, ApiResponse, Method},
8};
9
10#[cfg(test)]
11use crate::models::ExchangeRate;
12
13use builder::{ClientBuilder, NoAPIToken};
14use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
15use serde::{de::DeserializeOwned, Serialize};
16
17pub const DEFAULT_API_URL: &str = "https://pay.crypt.bot/api";
18pub const DEFAULT_TIMEOUT: u64 = 30;
19pub const DEFAULT_WEBHOOK_EXPIRATION_TIME: u64 = 600;
20
21#[derive(Debug)]
22pub struct CryptoBot {
23 pub(crate) api_token: String,
24 pub(crate) client: reqwest::Client,
25 pub(crate) base_url: String,
26 pub(crate) headers: Option<Vec<(HeaderName, HeaderValue)>>,
27 #[cfg(test)]
28 pub(crate) test_rates: Option<Vec<ExchangeRate>>,
29}
30
31impl CryptoBot {
32 pub fn builder() -> ClientBuilder<NoAPIToken> {
67 ClientBuilder::new()
68 }
69
70 pub(crate) async fn make_request<T, R>(&self, method: &APIMethod, params: Option<&T>) -> CryptoBotResult<R>
80 where
81 T: Serialize + ?Sized,
82 R: DeserializeOwned,
83 {
84 let url = format!("{}/{}", self.base_url, method.endpoint.as_str());
85
86 let mut request_headers = HeaderMap::new();
87
88 let token_header = HeaderName::from_str("Crypto-Pay-Api-Token")?;
89
90 request_headers.insert(token_header, HeaderValue::from_str(&self.api_token)?);
91
92 if let Some(custom_headers) = &self.headers {
93 for (name, value) in custom_headers.iter() {
94 request_headers.insert(name, value.clone());
95 }
96 }
97
98 let mut request = match method.method {
99 Method::POST => self.client.post(&url).headers(request_headers),
100 Method::GET => self.client.get(&url).headers(request_headers),
101 Method::DELETE => self.client.delete(&url).headers(request_headers),
102 };
103
104 if let Some(params) = params {
105 request = request.json(params);
106 }
107
108 let response = request.send().await?;
109
110 if !response.status().is_success() {
111 return Err(CryptoBotError::HttpError(response.error_for_status().unwrap_err()));
112 }
113
114 let text = response.text().await?;
115
116 let api_response: ApiResponse<R> = serde_json::from_str(&text).map_err(|e| CryptoBotError::ApiError {
117 code: -1,
118 message: "Failed to parse API response".to_string(),
119 details: Some(serde_json::json!({ "error": e.to_string() })),
120 })?;
121
122 if !api_response.ok {
123 return Err(CryptoBotError::ApiError {
124 code: api_response.error_code.unwrap_or(0),
125 message: api_response.error.unwrap_or_default(),
126 details: None,
127 });
128 }
129
130 api_response.result.ok_or(CryptoBotError::NoResult)
131 }
132
133 #[cfg(test)]
134 pub fn test_client() -> Self {
135 use crate::utils::test_utils::TestContext;
136
137 Self {
138 api_token: "test_token".to_string(),
139 client: reqwest::Client::new(),
140 base_url: "http://test.example.com".to_string(),
141 headers: None,
142 test_rates: Some(TestContext::mock_exchange_rates()),
143 }
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use mockito::Mock;
150 use serde_json::json;
151
152 use crate::{
153 api::BalanceAPI,
154 models::{APIEndpoint, Balance},
155 utils::test_utils::TestContext,
156 };
157
158 use super::*;
159 impl TestContext {
160 pub fn mock_malformed_json_response(&mut self) -> Mock {
161 self.server
162 .mock("GET", "/getBalance")
163 .with_header("content-type", "application/json")
164 .with_body("invalid json{")
165 .create()
166 }
167 }
168
169 #[test]
170 fn test_malformed_json_response() {
171 let mut ctx = TestContext::new();
172 let _m = ctx.mock_malformed_json_response();
173
174 let client = CryptoBot::builder()
175 .api_token("test")
176 .base_url(ctx.server.url())
177 .build()
178 .unwrap();
179
180 let result = ctx.run(async { client.get_balance().await });
181
182 assert!(matches!(
183 result,
184 Err(CryptoBotError::ApiError {
185 code: -1,
186 message,
187 details: Some(details)
188 }) if message == "Failed to parse API response"
189 && details.get("error").is_some()
190 ));
191 }
192
193 #[test]
194 fn test_invalid_response_structure() {
195 let mut ctx = TestContext::new();
196
197 let _m = ctx
198 .server
199 .mock("GET", "/getBalance")
200 .with_header("content-type", "application/json")
201 .with_body(
202 json!({
203 "ok": true,
204 "result": "not_an_array"
205 })
206 .to_string(),
207 )
208 .create();
209
210 let client = CryptoBot::builder()
211 .api_token("test")
212 .base_url(ctx.server.url())
213 .build()
214 .unwrap();
215
216 let result = ctx.run(async { client.get_balance().await });
217
218 assert!(matches!(
219 result,
220 Err(CryptoBotError::ApiError {
221 code: -1,
222 message,
223 details: Some(details)
224 }) if message == "Failed to parse API response"
225 && details.get("error").is_some()
226 ));
227 }
228
229 #[test]
230 fn test_empty_response() {
231 let mut ctx = TestContext::new();
232
233 let _m = ctx
235 .server
236 .mock("GET", "/getBalance")
237 .with_header("content-type", "application/json")
238 .with_body("")
239 .create();
240
241 let client = CryptoBot::builder()
242 .api_token("test")
243 .base_url(ctx.server.url())
244 .build()
245 .unwrap();
246
247 let result = ctx.run(async { client.get_balance().await });
248
249 assert!(matches!(
250 result,
251 Err(CryptoBotError::ApiError {
252 code: -1,
253 message,
254 details: Some(details)
255 }) if message == "Failed to parse API response"
256 && details.get("error").is_some()
257 ));
258 }
259
260 #[test]
261 fn test_invalid_api_token_header() {
262 let client = CryptoBot {
263 api_token: "invalid\u{0000}token".to_string(),
264 client: reqwest::Client::new(),
265 base_url: "http://test.example.com".to_string(),
266 headers: None,
267 #[cfg(test)]
268 test_rates: None,
269 };
270
271 let method = APIMethod {
272 endpoint: APIEndpoint::GetBalance,
273 method: Method::GET,
274 };
275 let ctx = TestContext::new();
276
277 let result = ctx.run(async { client.make_request::<(), Vec<Balance>>(&method, None).await });
278
279 assert!(matches!(result, Err(CryptoBotError::InvalidHeaderValue(_))));
280 }
281
282 #[test]
283 fn test_api_error_response() {
284 let mut ctx = TestContext::new();
285
286 let _m = ctx
288 .server
289 .mock("GET", "/getBalance")
290 .with_header("content-type", "application/json")
291 .with_body(
292 json!({
293 "ok": false,
294 "error": "Test error message",
295 "error_code": 123
296 })
297 .to_string(),
298 )
299 .create();
300
301 let client = CryptoBot::builder()
302 .api_token("test")
303 .base_url(ctx.server.url())
304 .build()
305 .unwrap();
306
307 let result = ctx.run(async { client.get_balance().await });
308
309 assert!(matches!(
310 result,
311 Err(CryptoBotError::ApiError {
312 code,
313 message,
314 details,
315 }) if code == 123
316 && message == "Test error message"
317 && details.is_none()
318 ));
319 }
320
321 #[test]
322 fn test_api_error_response_missing_fields() {
323 let mut ctx = TestContext::new();
324
325 let _m = ctx
327 .server
328 .mock("GET", "/getBalance")
329 .with_header("content-type", "application/json")
330 .with_body(
331 json!({
332 "ok": false
333 })
334 .to_string(),
335 )
336 .create();
337
338 let client = CryptoBot::builder()
339 .api_token("test")
340 .base_url(ctx.server.url())
341 .build()
342 .unwrap();
343
344 let result = ctx.run(async { client.get_balance().await });
345
346 assert!(matches!(
347 result,
348 Err(CryptoBotError::ApiError {
349 code,
350 message,
351 details,
352 }) if code == 0
353 && message.is_empty()
354 && details.is_none()
355 ));
356 }
357
358 #[test]
359 fn test_api_error_response_partial_fields() {
360 let mut ctx = TestContext::new();
361
362 let _m = ctx
364 .server
365 .mock("GET", "/getBalance")
366 .with_header("content-type", "application/json")
367 .with_body(
368 json!({
369 "ok": false,
370 "error": "Test error message"
371 })
372 .to_string(),
373 )
374 .create();
375
376 let client = CryptoBot::builder()
377 .api_token("test")
378 .base_url(ctx.server.url())
379 .build()
380 .unwrap();
381
382 let result = ctx.run(async { client.get_balance().await });
383
384 assert!(matches!(
385 result,
386 Err(CryptoBotError::ApiError {
387 code,
388 message,
389 details,
390 }) if code == 0
391 && message == "Test error message"
392 && details.is_none()
393 ));
394 }
395
396 #[test]
397 fn test_http_error_response() {
398 let mut ctx = TestContext::new();
399 let _m = ctx.server.mock("GET", "/getBalance").with_status(404).create();
400
401 let client = CryptoBot::builder()
402 .api_token("test")
403 .base_url(ctx.server.url())
404 .build()
405 .unwrap();
406
407 let result = ctx.run(async { client.get_balance().await });
408
409 assert!(matches!(result, Err(CryptoBotError::HttpError(_))));
410 }
411}