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 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 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 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 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 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}