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::{Matcher, Mock};
150 use reqwest::header::{HeaderName, HeaderValue};
151 use serde::{Deserialize, Serialize};
152 use serde_json::json;
153
154 use crate::{
155 api::BalanceAPI,
156 models::{APIEndpoint, Balance},
157 utils::test_utils::TestContext,
158 };
159
160 use super::*;
161
162 #[derive(Debug, Serialize)]
163 struct DummyPayload {
164 value: String,
165 }
166
167 #[derive(Debug, Deserialize, PartialEq)]
168 struct DummyResponse {
169 stored: String,
170 }
171 #[derive(Debug, Serialize)]
172 struct DeletePayload {
173 invoice_id: u64,
174 }
175 impl TestContext {
176 pub fn mock_malformed_json_response(&mut self) -> Mock {
177 self.server
178 .mock("GET", "/getBalance")
179 .with_header("content-type", "application/json")
180 .with_body("invalid json{")
181 .create()
182 }
183 }
184
185 #[test]
186 fn test_malformed_json_response() {
187 let mut ctx = TestContext::new();
188 let _m = ctx.mock_malformed_json_response();
189
190 let client = CryptoBot::builder()
191 .api_token("test")
192 .base_url(ctx.server.url())
193 .build()
194 .unwrap();
195
196 let result = ctx.run(async { client.get_balance().execute().await });
197
198 assert!(matches!(
199 result,
200 Err(CryptoBotError::ApiError {
201 code: -1,
202 message,
203 details: Some(details)
204 }) if message == "Failed to parse API response"
205 && details.get("error").is_some()
206 ));
207 }
208
209 #[test]
210 fn test_invalid_response_structure() {
211 let mut ctx = TestContext::new();
212
213 let _m = ctx
214 .server
215 .mock("GET", "/getBalance")
216 .with_header("content-type", "application/json")
217 .with_body(
218 json!({
219 "ok": true,
220 "result": "not_an_array"
221 })
222 .to_string(),
223 )
224 .create();
225
226 let client = CryptoBot::builder()
227 .api_token("test")
228 .base_url(ctx.server.url())
229 .build()
230 .unwrap();
231
232 let result = ctx.run(async { client.get_balance().execute().await });
233
234 assert!(matches!(
235 result,
236 Err(CryptoBotError::ApiError {
237 code: -1,
238 message,
239 details: Some(details)
240 }) if message == "Failed to parse API response"
241 && details.get("error").is_some()
242 ));
243 }
244
245 #[test]
246 fn test_empty_response() {
247 let mut ctx = TestContext::new();
248
249 let _m = ctx
251 .server
252 .mock("GET", "/getBalance")
253 .with_header("content-type", "application/json")
254 .with_body("")
255 .create();
256
257 let client = CryptoBot::builder()
258 .api_token("test")
259 .base_url(ctx.server.url())
260 .build()
261 .unwrap();
262
263 let result = ctx.run(async { client.get_balance().execute().await });
264
265 assert!(matches!(
266 result,
267 Err(CryptoBotError::ApiError {
268 code: -1,
269 message,
270 details: Some(details)
271 }) if message == "Failed to parse API response"
272 && details.get("error").is_some()
273 ));
274 }
275
276 #[test]
277 fn test_invalid_api_token_header() {
278 let client = CryptoBot {
279 api_token: "invalid\u{0000}token".to_string(),
280 client: reqwest::Client::new(),
281 base_url: "http://test.example.com".to_string(),
282 headers: None,
283 #[cfg(test)]
284 test_rates: None,
285 };
286
287 let method = APIMethod {
288 endpoint: APIEndpoint::GetBalance,
289 method: Method::GET,
290 };
291 let ctx = TestContext::new();
292
293 let result = ctx.run(async { client.make_request::<(), Vec<Balance>>(&method, None).await });
294
295 assert!(matches!(result, Err(CryptoBotError::InvalidHeaderValue(_))));
296 }
297
298 #[test]
299 fn test_api_error_response() {
300 let mut ctx = TestContext::new();
301
302 let _m = ctx
304 .server
305 .mock("GET", "/getBalance")
306 .with_header("content-type", "application/json")
307 .with_body(
308 json!({
309 "ok": false,
310 "error": "Test error message",
311 "error_code": 123
312 })
313 .to_string(),
314 )
315 .create();
316
317 let client = CryptoBot::builder()
318 .api_token("test")
319 .base_url(ctx.server.url())
320 .build()
321 .unwrap();
322
323 let result = ctx.run(async { client.get_balance().execute().await });
324
325 assert!(matches!(
326 result,
327 Err(CryptoBotError::ApiError {
328 code,
329 message,
330 details,
331 }) if code == 123
332 && message == "Test error message"
333 && details.is_none()
334 ));
335 }
336
337 #[test]
338 fn test_api_error_response_missing_fields() {
339 let mut ctx = TestContext::new();
340
341 let _m = ctx
343 .server
344 .mock("GET", "/getBalance")
345 .with_header("content-type", "application/json")
346 .with_body(
347 json!({
348 "ok": false
349 })
350 .to_string(),
351 )
352 .create();
353
354 let client = CryptoBot::builder()
355 .api_token("test")
356 .base_url(ctx.server.url())
357 .build()
358 .unwrap();
359
360 let result = ctx.run(async { client.get_balance().execute().await });
361
362 assert!(matches!(
363 result,
364 Err(CryptoBotError::ApiError {
365 code,
366 message,
367 details,
368 }) if code == 0
369 && message.is_empty()
370 && details.is_none()
371 ));
372 }
373
374 #[test]
375 fn test_api_error_response_partial_fields() {
376 let mut ctx = TestContext::new();
377
378 let _m = ctx
380 .server
381 .mock("GET", "/getBalance")
382 .with_header("content-type", "application/json")
383 .with_body(
384 json!({
385 "ok": false,
386 "error": "Test error message"
387 })
388 .to_string(),
389 )
390 .create();
391
392 let client = CryptoBot::builder()
393 .api_token("test")
394 .base_url(ctx.server.url())
395 .build()
396 .unwrap();
397
398 let result = ctx.run(async { client.get_balance().execute().await });
399
400 assert!(matches!(
401 result,
402 Err(CryptoBotError::ApiError {
403 code,
404 message,
405 details,
406 }) if code == 0
407 && message == "Test error message"
408 && details.is_none()
409 ));
410 }
411
412 #[test]
413 fn test_http_error_response() {
414 let mut ctx = TestContext::new();
415 let _m = ctx.server.mock("GET", "/getBalance").with_status(404).create();
416
417 let client = CryptoBot::builder()
418 .api_token("test")
419 .base_url(ctx.server.url())
420 .build()
421 .unwrap();
422
423 let result = ctx.run(async { client.get_balance().execute().await });
424
425 assert!(matches!(result, Err(CryptoBotError::HttpError(_))));
426 }
427
428 #[test]
429 fn test_make_request_with_custom_headers_and_body() {
430 let mut ctx = TestContext::new();
431
432 let _m = ctx
433 .server
434 .mock("POST", "/createInvoice")
435 .match_header("X-Custom-Header", "test-value")
436 .match_header("Crypto-Pay-Api-Token", "test")
437 .match_body(Matcher::JsonString(
438 json!({
439 "value": "payload"
440 })
441 .to_string(),
442 ))
443 .with_header("content-type", "application/json")
444 .with_body(
445 json!({
446 "ok": true,
447 "result": {
448 "stored": "payload"
449 }
450 })
451 .to_string(),
452 )
453 .create();
454
455 let client = CryptoBot::builder()
456 .headers(vec![(
457 HeaderName::from_static("x-custom-header"),
458 HeaderValue::from_static("test-value"),
459 )])
460 .api_token("test")
461 .base_url(ctx.server.url())
462 .build()
463 .unwrap();
464
465 let method = APIMethod {
466 endpoint: APIEndpoint::CreateInvoice,
467 method: Method::POST,
468 };
469
470 let payload = DummyPayload {
471 value: "payload".to_string(),
472 };
473
474 let result: Result<DummyResponse, _> = ctx.run(async { client.make_request(&method, Some(&payload)).await });
475
476 assert_eq!(
477 result.unwrap(),
478 DummyResponse {
479 stored: "payload".to_string()
480 }
481 );
482 }
483
484 #[test]
485 fn test_make_request_delete_with_payload_and_headers() {
486 let mut ctx = TestContext::new();
487 let _m = ctx
488 .server
489 .mock("DELETE", "/deleteInvoice")
490 .match_header("X-Extra", "extra")
491 .match_header("Crypto-Pay-Api-Token", "test")
492 .match_body(Matcher::JsonString(
493 json!({
494 "invoice_id": 7
495 })
496 .to_string(),
497 ))
498 .with_header("content-type", "application/json")
499 .with_body(json!({"ok": true, "result": true}).to_string())
500 .create();
501
502 let client = CryptoBot::builder()
503 .headers(vec![(
504 HeaderName::from_static("x-extra"),
505 HeaderValue::from_static("extra"),
506 )])
507 .api_token("test")
508 .base_url(ctx.server.url())
509 .build()
510 .unwrap();
511
512 let method = APIMethod {
513 endpoint: APIEndpoint::DeleteInvoice,
514 method: Method::DELETE,
515 };
516
517 let payload = DeletePayload { invoice_id: 7 };
518
519 let result: Result<bool, _> = ctx.run(async { client.make_request(&method, Some(&payload)).await });
520 assert_eq!(result.unwrap(), true);
521 }
522}