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::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        // Mock empty response
234        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        // Mock API error response with error code and message
287        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        // Mock API error response without error code and message
326        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        // Mock API error response with only error message
363        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}