onemoney_protocol/client/
http.rs

1//! HTTP client implementation.
2
3use super::{builder::ClientBuilder, config::Network, hooks::Hook};
4use crate::{Error, Result, error::ErrorResponse};
5use reqwest::{Client as HttpClient, header};
6use serde::{Serialize, de::DeserializeOwned};
7use serde_json;
8use std::fmt::{Debug, Formatter, Result as FmtResult};
9use url::Url;
10
11/// OneMoney API client.
12pub struct Client {
13    pub(crate) base_url: Url,
14    http_client: HttpClient,
15    hooks: Vec<Box<dyn Hook>>,
16}
17
18impl Debug for Client {
19    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
20        f.debug_struct("Client")
21            .field("base_url", &self.base_url)
22            .field("hooks_count", &self.hooks.len())
23            .finish()
24    }
25}
26
27impl Client {
28    /// Create a new client for mainnet.
29    pub fn mainnet() -> Result<Self> {
30        ClientBuilder::new().network(Network::Mainnet).build()
31    }
32
33    /// Create a new client for testnet.
34    pub fn testnet() -> Result<Self> {
35        ClientBuilder::new().network(Network::Testnet).build()
36    }
37
38    /// Create a new client for local development.
39    pub fn local() -> Result<Self> {
40        ClientBuilder::new().network(Network::Local).build()
41    }
42
43    /// Create a new client instance.
44    pub(crate) fn new(base_url: Url, http_client: HttpClient, hooks: Vec<Box<dyn Hook>>) -> Self {
45        Self {
46            base_url,
47            http_client,
48            hooks,
49        }
50    }
51
52    /// Perform a GET request.
53    pub async fn get<T>(&self, path: &str) -> Result<T>
54    where
55        T: DeserializeOwned,
56    {
57        let url = self.base_url.join(path)?;
58        let url_str = url.as_str().to_string();
59
60        // Execute hooks
61        for hook in &self.hooks {
62            hook.before_request("GET", &url_str, None);
63        }
64
65        let response = self.http_client.get(url).send().await?;
66        let status = response.status();
67
68        let response_text = response.text().await?;
69
70        // Execute hooks
71        for hook in &self.hooks {
72            hook.after_response("GET", &url_str, status.as_u16(), Some(&response_text));
73        }
74
75        if !status.is_success() {
76            return Err(self.handle_error_response(status.as_u16(), &response_text));
77        }
78
79        let result: T = serde_json::from_str(&response_text)?;
80        Ok(result)
81    }
82
83    /// Perform a POST request.
84    pub async fn post<B, T>(&self, path: &str, body: &B) -> Result<T>
85    where
86        B: Serialize,
87        T: DeserializeOwned,
88    {
89        let url = self.base_url.join(path)?;
90        let url_str = url.as_str().to_string();
91
92        let body_json = serde_json::to_string(body)?;
93
94        // Execute hooks
95        for hook in &self.hooks {
96            hook.before_request("POST", &url_str, Some(&body_json));
97        }
98
99        let response = self
100            .http_client
101            .post(url)
102            .header(header::CONTENT_TYPE, "application/json")
103            .body(body_json)
104            .send()
105            .await?;
106
107        let status = response.status();
108        let response_text = response.text().await?;
109
110        // Execute hooks
111        for hook in &self.hooks {
112            hook.after_response("POST", &url_str, status.as_u16(), Some(&response_text));
113        }
114
115        if !status.is_success() {
116            return Err(self.handle_error_response(status.as_u16(), &response_text));
117        }
118
119        let result: T = serde_json::from_str(&response_text)?;
120        Ok(result)
121    }
122
123    /// Handle error responses from the API.
124    fn handle_error_response(&self, status_code: u16, body: &str) -> Error {
125        // Try to parse as structured error response first (L1 compatible)
126        if let Ok(error_response) = serde_json::from_str::<ErrorResponse>(body) {
127            // Classify error based on status code and error code
128            Self::classify_error(
129                status_code,
130                &error_response.error_code,
131                &error_response.message,
132            )
133        } else {
134            // Fallback based on status code
135            match status_code {
136                400 => Error::invalid_parameter("request", body),
137                401 => Error::authentication(body),
138                403 => Error::authorization(body),
139                404 => Error::resource_not_found("unknown", body),
140                408 => Error::request_timeout("unknown", 0),
141                422 => Error::business_logic("validation", body),
142                429 => Error::rate_limit_exceeded(None),
143                500..=599 => Error::http_transport(body, Some(status_code)),
144                _ => Error::api(status_code, "unknown".to_string(), body.to_string()),
145            }
146        }
147    }
148
149    /// Classify structured errors based on L1 error patterns.
150    fn classify_error(status_code: u16, error_code: &str, message: &str) -> Error {
151        match (status_code, error_code) {
152            // 400 Bad Request - Validation Errors
153            (400, code) if code.starts_with("validation_") => {
154                let param = code.strip_prefix("validation_").unwrap_or("unknown");
155                Error::invalid_parameter(param, message)
156            }
157
158            // 401 Unauthorized
159            (401, _) => Error::authentication(message),
160
161            // 403 Forbidden
162            (403, _) => Error::authorization(message),
163
164            // 404 Not Found - Resource Errors
165            (404, code) if code.starts_with("resource_") => {
166                let resource = code.strip_prefix("resource_").unwrap_or("unknown");
167                Error::resource_not_found(resource, message)
168            }
169
170            // 408 Request Timeout
171            (408, "request_timeout") => Error::request_timeout(message, 0),
172
173            // 422 Unprocessable Entity - Business Logic
174            (422, code) if code.starts_with("business_") => {
175                let operation = code.strip_prefix("business_").unwrap_or("unknown");
176                Error::business_logic(operation, message)
177            }
178
179            // 429 Too Many Requests
180            (429, "rate_limit_exceeded") => Error::rate_limit_exceeded(None),
181
182            // 500+ Server Errors
183            (500..=599, code) if code.starts_with("system_") => {
184                Error::http_transport(message, Some(status_code))
185            }
186
187            // Default to generic API error
188            _ => Error::api(status_code, error_code.to_string(), message.to_string()),
189        }
190    }
191
192    /// Test helper method to expose handle_error_response for comprehensive testing.
193    ///
194    /// **This method is intended only for testing and should not be used in production code.**
195    #[doc(hidden)]
196    pub fn test_handle_error_response(&self, status_code: u16, body: &str) -> Error {
197        self.handle_error_response(status_code, body)
198    }
199
200    /// Test helper method to expose classify_error for comprehensive testing.
201    ///
202    /// **This method is intended only for testing and should not be used in production code.**
203    #[doc(hidden)]
204    pub fn test_classify_error(status_code: u16, error_code: &str, message: &str) -> Error {
205        Self::classify_error(status_code, error_code, message)
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use serde::{Deserialize, Serialize};
213
214    #[derive(Serialize, Deserialize, PartialEq, Debug)]
215    struct TestResponse {
216        id: u64,
217        message: String,
218    }
219
220    #[derive(Serialize)]
221    #[allow(dead_code)]
222    struct TestRequest {
223        data: String,
224    }
225
226    #[test]
227    fn test_client_creation_methods() {
228        // Test mainnet client creation
229        let mainnet_client = Client::mainnet();
230        assert!(mainnet_client.is_ok());
231        let client = mainnet_client.unwrap();
232        assert!(client.base_url.as_str().contains("mainnet"));
233
234        // Test testnet client creation
235        let testnet_client = Client::testnet();
236        assert!(testnet_client.is_ok());
237        let client = testnet_client.unwrap();
238        assert!(client.base_url.as_str().contains("testnet"));
239
240        // Test local client creation
241        let local_client = Client::local();
242        assert!(local_client.is_ok());
243        let client = local_client.unwrap();
244        assert!(client.base_url.as_str().contains("127.0.0.1"));
245    }
246
247    #[test]
248    fn test_client_debug_implementation() {
249        let client = Client::mainnet().expect("Failed to create mainnet client");
250        let debug_str = format!("{:?}", client);
251
252        assert!(debug_str.contains("Client"));
253        assert!(debug_str.contains("base_url"));
254        assert!(debug_str.contains("hooks_count"));
255        assert!(debug_str.contains("0")); // Default hooks count
256    }
257
258    #[test]
259    fn test_error_classification_validation_errors() {
260        // Test validation error classification
261        let error =
262            Client::test_classify_error(400, "validation_address", "Invalid address format");
263        assert!(
264            matches!(error, Error::InvalidParameter { parameter, .. } if parameter == "address")
265        );
266
267        let error = Client::test_classify_error(400, "validation_amount", "Invalid amount");
268        assert!(
269            matches!(error, Error::InvalidParameter { parameter, .. } if parameter == "amount")
270        );
271
272        let error =
273            Client::test_classify_error(400, "validation_unknown", "Unknown validation error");
274        assert!(
275            matches!(error, Error::InvalidParameter { parameter, .. } if parameter == "unknown")
276        );
277    }
278
279    #[test]
280    fn test_error_classification_authentication_errors() {
281        let error =
282            Client::test_classify_error(401, "invalid_signature", "Signature verification failed");
283        assert!(matches!(error, Error::Authentication { .. }));
284
285        let error = Client::test_classify_error(401, "expired_token", "Token has expired");
286        assert!(matches!(error, Error::Authentication { .. }));
287    }
288
289    #[test]
290    fn test_error_classification_authorization_errors() {
291        let error = Client::test_classify_error(403, "insufficient_permissions", "Access denied");
292        assert!(matches!(error, Error::Authorization { .. }));
293
294        let error =
295            Client::test_classify_error(403, "forbidden_resource", "Resource access forbidden");
296        assert!(matches!(error, Error::Authorization { .. }));
297    }
298
299    #[test]
300    fn test_error_classification_resource_not_found_errors() {
301        let error =
302            Client::test_classify_error(404, "resource_transaction", "Transaction not found");
303        assert!(
304            matches!(error, Error::ResourceNotFound { resource_type, .. } if resource_type == "transaction")
305        );
306
307        let error = Client::test_classify_error(404, "resource_account", "Account not found");
308        assert!(
309            matches!(error, Error::ResourceNotFound { resource_type, .. } if resource_type == "account")
310        );
311
312        let error = Client::test_classify_error(404, "resource_unknown", "Resource not found");
313        assert!(
314            matches!(error, Error::ResourceNotFound { resource_type, .. } if resource_type == "unknown")
315        );
316    }
317
318    #[test]
319    fn test_error_classification_timeout_errors() {
320        let error = Client::test_classify_error(408, "request_timeout", "Request timed out");
321        assert!(matches!(error, Error::RequestTimeout { .. }));
322    }
323
324    #[test]
325    fn test_error_classification_business_logic_errors() {
326        let error =
327            Client::test_classify_error(422, "business_insufficient_funds", "Insufficient balance");
328        assert!(
329            matches!(error, Error::BusinessLogic { operation, .. } if operation == "insufficient_funds")
330        );
331
332        let error = Client::test_classify_error(422, "business_token_paused", "Token is paused");
333        assert!(
334            matches!(error, Error::BusinessLogic { operation, .. } if operation == "token_paused")
335        );
336    }
337
338    #[test]
339    fn test_error_classification_rate_limit_errors() {
340        let error = Client::test_classify_error(429, "rate_limit_exceeded", "Too many requests");
341        assert!(matches!(error, Error::RateLimitExceeded { .. }));
342    }
343
344    #[test]
345    fn test_error_classification_server_errors() {
346        let error =
347            Client::test_classify_error(500, "system_database_error", "Database connection failed");
348        assert!(matches!(error, Error::HttpTransport { .. }));
349
350        let error = Client::test_classify_error(
351            503,
352            "system_service_unavailable",
353            "Service temporarily unavailable",
354        );
355        assert!(matches!(error, Error::HttpTransport { .. }));
356    }
357
358    #[test]
359    fn test_error_classification_generic_api_errors() {
360        // Test unknown error code
361        let error = Client::test_classify_error(400, "unknown_error", "Unknown error occurred");
362        assert!(
363            matches!(error, Error::Api { status_code: 400, error_code, .. } if error_code == "unknown_error")
364        );
365
366        // Test unexpected status code
367        let error = Client::test_classify_error(418, "teapot", "I'm a teapot");
368        assert!(
369            matches!(error, Error::Api { status_code: 418, error_code, .. } if error_code == "teapot")
370        );
371    }
372
373    #[test]
374    fn test_handle_error_response_with_structured_json() {
375        let client = Client::mainnet().expect("Failed to create client");
376
377        // Test structured error response parsing
378        let structured_error =
379            r#"{"error_code": "validation_address", "message": "Invalid address format"}"#;
380        let error = client.test_handle_error_response(400, structured_error);
381        assert!(
382            matches!(error, Error::InvalidParameter { parameter, .. } if parameter == "address")
383        );
384
385        // Test structured business logic error
386        let business_error = r#"{"error_code": "business_insufficient_funds", "message": "Insufficient balance for transaction"}"#;
387        let error = client.test_handle_error_response(422, business_error);
388        assert!(
389            matches!(error, Error::BusinessLogic { operation, .. } if operation == "insufficient_funds")
390        );
391    }
392
393    #[test]
394    fn test_handle_error_response_fallback_to_status_code() {
395        let client = Client::mainnet().expect("Failed to create client");
396
397        // Test fallback to status code classification when JSON parsing fails
398        let invalid_json = "Not a JSON response";
399
400        let error = client.test_handle_error_response(400, invalid_json);
401        assert!(matches!(error, Error::InvalidParameter { .. }));
402
403        let error = client.test_handle_error_response(401, invalid_json);
404        assert!(matches!(error, Error::Authentication { .. }));
405
406        let error = client.test_handle_error_response(403, invalid_json);
407        assert!(matches!(error, Error::Authorization { .. }));
408
409        let error = client.test_handle_error_response(404, invalid_json);
410        assert!(matches!(error, Error::ResourceNotFound { .. }));
411
412        let error = client.test_handle_error_response(408, invalid_json);
413        assert!(matches!(error, Error::RequestTimeout { .. }));
414
415        let error = client.test_handle_error_response(422, invalid_json);
416        assert!(matches!(error, Error::BusinessLogic { .. }));
417
418        let error = client.test_handle_error_response(429, invalid_json);
419        assert!(matches!(error, Error::RateLimitExceeded { .. }));
420
421        let error = client.test_handle_error_response(500, invalid_json);
422        assert!(matches!(error, Error::HttpTransport { .. }));
423
424        let error = client.test_handle_error_response(418, invalid_json);
425        assert!(matches!(
426            error,
427            Error::Api {
428                status_code: 418,
429                ..
430            }
431        ));
432    }
433
434    #[test]
435    fn test_network_url_configuration() {
436        // Test that different networks use correct base URLs
437        let mainnet = Client::mainnet().unwrap();
438        assert!(mainnet.base_url.as_str().contains("mainnet.1money.network"));
439
440        let testnet = Client::testnet().unwrap();
441        assert!(testnet.base_url.as_str().contains("testnet.1money.network"));
442
443        let local = Client::local().unwrap();
444        assert!(local.base_url.as_str().contains("127.0.0.1:18555"));
445    }
446
447    #[test]
448    fn test_client_new_method() {
449        use reqwest::Client as HttpClient;
450        use url::Url;
451
452        let base_url = Url::parse("https://test.example.com").expect("Valid URL");
453        let http_client = HttpClient::new();
454        let hooks: Vec<Box<dyn Hook>> = vec![];
455
456        let client = Client::new(base_url.clone(), http_client, hooks);
457
458        assert_eq!(client.base_url, base_url);
459        assert_eq!(client.hooks.len(), 0);
460    }
461}