crypto_pay_api/client/
mod.rs

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    /// Returns a new builder for creating a customized CryptoBot client
33    ///
34    /// The builder pattern allows you to customize all aspects of the client,
35    /// including timeout, base URL and headers settings.
36    ///
37    /// # Available Settings
38    /// * `api_token` - Required, the API token from [@CryptoBot](https://t.me/CryptoBot)
39    /// * `base_url` - Optional, defaults to "https://pay.crypt.bot/api"
40    /// * `timeout` - Optional, defaults to 30 seconds
41    /// * `headers` - Optional, custom headers for all requests
42    ///
43    /// # Example
44    /// ```
45    /// use crypto_pay_api::prelude::*;
46    /// use std::time::Duration;
47    ///
48    /// #[tokio::main]
49    /// async fn main() -> Result<(), CryptoBotError> {
50    ///     let client = CryptoBot::builder()
51    ///         .api_token("YOUR_API_TOKEN")
52    ///         .base_url("https://testnet-pay.crypt.bot/api")  // Use testnet
53    ///         .timeout(Duration::from_secs(60))               // 60 second timeout
54    ///     .headers(vec![(
55    ///         HeaderName::from_static("x-custom-header"),
56    ///         HeaderValue::from_static("custom_value")
57    ///     )])
58    ///     .build()?;
59    ///
60    ///     Ok(())
61    /// }
62    /// ```
63    ///
64    /// # See Also
65    /// * [`ClientBuilder`](struct.ClientBuilder.html) - The builder type
66    pub fn builder() -> ClientBuilder<NoAPIToken> {
67        ClientBuilder::new()
68    }
69
70    /// Makes a request to the CryptoBot API
71    ///
72    /// # Arguments
73    /// * `method` - The method to call, must be one of the ApiMethod enum values
74    /// * `params` - The parameters to pass to the method
75    ///
76    /// # Returns
77    /// * `Ok(R)` - The response from the API
78    /// * `Err(CryptoBotError)` - If the request fails or the response is not valid
79    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        // Mock empty response
250        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        // Mock API error response with error code and message
303        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        // Mock API error response without error code and message
342        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        // Mock API error response with only error message
379        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}