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