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