Skip to main content

wechat_pub_rs/
http.rs

1//! HTTP client module with retry mechanisms and WeChat API integration.
2//!
3//! This module provides secure HTTP client functionality with:
4//! - Request size limits to prevent DoS attacks
5//! - Timeout configuration for reliability
6//! - Retry mechanisms with exponential backoff
7//! - Safe download limits for external content
8
9use crate::config::{Config, RetryConfig, SecurityConfig};
10use crate::error::{Result, WeChatError};
11use crate::traits::HttpClient;
12use reqwest::{Client, Response, multipart};
13use serde::{Deserialize, Serialize};
14use std::time::Duration;
15use tokio::time::sleep;
16use tracing::{debug, warn};
17
18// Note: RetryConfig and SecurityConfig are re-exported from config module for backward compatibility
19
20/// HTTP client wrapper for WeChat API calls with automatic retry and token management.
21#[derive(Debug, Clone)]
22pub struct WeChatHttpClient {
23    client: Client,
24    config: Config,
25}
26
27impl WeChatHttpClient {
28    /// Creates a new WeChat HTTP client.
29    pub fn new() -> Result<Self> {
30        Self::with_config(Config::default())
31    }
32
33    /// Creates a new client with custom configuration.
34    pub fn with_config(config: Config) -> Result<Self> {
35        let client = Client::builder()
36            .timeout(config.request_timeout())
37            .connect_timeout(config.connect_timeout())
38            .user_agent(&config.http.user_agent)
39            .build()?;
40
41        Ok(Self { client, config })
42    }
43
44    /// Creates a new client with custom retry configuration (legacy).
45    pub fn with_retry_config(retry_config: RetryConfig) -> Result<Self> {
46        let config = Config {
47            retry: retry_config,
48            ..Default::default()
49        };
50        Self::with_config(config)
51    }
52
53    /// Creates a new client with custom security configuration (legacy).
54    pub fn with_security_config(security_config: SecurityConfig) -> Result<Self> {
55        let config = Config {
56            security: security_config,
57            ..Default::default()
58        };
59        Self::with_config(config)
60    }
61
62    /// Makes a GET request with access token.
63    pub async fn get_with_token(&self, endpoint: &str, access_token: &str) -> Result<Response> {
64        let url = format!(
65            "{}{}?access_token={}",
66            self.config.http.base_url, endpoint, access_token
67        );
68        self.execute_with_retry(|| self.client.get(&url).send())
69            .await
70    }
71
72    /// Makes a POST request with JSON body and access token.
73    pub async fn post_json_with_token<T: Serialize>(
74        &self,
75        endpoint: &str,
76        access_token: &str,
77        body: &T,
78    ) -> Result<Response> {
79        let url = format!(
80            "{}{}?access_token={}",
81            self.config.http.base_url, endpoint, access_token
82        );
83        self.execute_with_retry(|| self.client.post(&url).json(body).send())
84            .await
85    }
86
87    /// Uploads a file using multipart form data with size validation.
88    pub async fn upload_file(
89        &self,
90        endpoint: &str,
91        access_token: &str,
92        field_name: &str,
93        file_data: Vec<u8>,
94        filename: &str,
95    ) -> Result<Response> {
96        // Validate file size
97        crate::utils::validate_file_size(
98            file_data.len() as u64,
99            self.config.security.max_upload_size,
100            "upload",
101        )
102        .map_err(WeChatError::config_error)?;
103
104        // Sanitize filename for security
105        let safe_filename = crate::utils::sanitize_filename(filename);
106        let url = format!(
107            "{}{}?access_token={}",
108            self.config.http.base_url, endpoint, access_token
109        );
110
111        // Guess MIME type from safe filename
112        let mime_type = mime_guess::from_path(&safe_filename)
113            .first_or_octet_stream()
114            .to_string();
115
116        // Clone data for each retry attempt
117        let field_name = field_name.to_string();
118        let url = url.clone();
119        let client = self.client.clone();
120
121        self.execute_with_retry(move || {
122            let part = multipart::Part::bytes(file_data.clone())
123                .file_name(safe_filename.clone())
124                .mime_str(&mime_type)
125                .unwrap();
126            let form = multipart::Form::new().part(field_name.clone(), part);
127            client.post(&url).multipart(form).send()
128        })
129        .await
130    }
131
132    /// Uploads a permanent material (for cover images) with size validation.
133    pub async fn upload_material(
134        &self,
135        access_token: &str,
136        material_type: &str,
137        file_data: Vec<u8>,
138        filename: &str,
139    ) -> Result<Response> {
140        // Validate file size
141        crate::utils::validate_file_size(
142            file_data.len() as u64,
143            self.config.security.max_upload_size,
144            "material",
145        )
146        .map_err(WeChatError::config_error)?;
147
148        // Sanitize filename for security
149        let safe_filename = crate::utils::sanitize_filename(filename);
150        let url = format!(
151            "{}{}?access_token={}&type={}",
152            self.config.http.base_url,
153            "/cgi-bin/material/add_material",
154            access_token,
155            material_type
156        );
157
158        // Guess MIME type from safe filename
159        let mime_type = mime_guess::from_path(&safe_filename)
160            .first_or_octet_stream()
161            .to_string();
162
163        // Clone data for each retry attempt
164        let url = url.clone();
165        let client = self.client.clone();
166
167        self.execute_with_retry(move || {
168            let part = multipart::Part::bytes(file_data.clone())
169                .file_name(safe_filename.clone())
170                .mime_str(&mime_type)
171                .unwrap();
172
173            let form = multipart::Form::new().part("media", part);
174
175            client.post(&url).multipart(form).send()
176        })
177        .await
178    }
179
180    /// Executes a request with intelligent retry logic.
181    async fn execute_with_retry<F, Fut>(&self, mut operation: F) -> Result<Response>
182    where
183        F: FnMut() -> Fut,
184        Fut: std::future::Future<Output = std::result::Result<Response, reqwest::Error>>,
185    {
186        let mut last_error = None;
187        let mut consecutive_failures = 0;
188
189        for attempt in 1..=self.config.retry.max_attempts {
190            match operation().await {
191                Ok(response) => {
192                    // Check for WeChat API errors in successful HTTP responses
193                    if response.status().is_success() {
194                        return Ok(response);
195                    } else {
196                        // Convert HTTP error to WeChatError
197                        let status = response.status();
198                        let error_text = response
199                            .text()
200                            .await
201                            .unwrap_or_else(|_| "Unknown error".to_string());
202
203                        let error = WeChatError::Internal {
204                            message: format!("HTTP {status}: {error_text}"),
205                        };
206
207                        // Use error-specific retry logic
208                        let max_retries = error.max_retries().min(self.config.retry.max_attempts);
209                        if attempt >= max_retries || !error.is_retryable() {
210                            return Err(error);
211                        }
212
213                        consecutive_failures += 1;
214                        last_error = Some(error);
215                    }
216                }
217                Err(e) => {
218                    let error = WeChatError::Network {
219                        message: e.to_string(),
220                    };
221
222                    // Use error-specific retry logic
223                    let max_retries = error.max_retries().min(self.config.retry.max_attempts);
224                    if attempt >= max_retries || !error.is_retryable() {
225                        return Err(error);
226                    }
227
228                    consecutive_failures += 1;
229                    last_error = Some(error);
230                }
231            }
232
233            // Wait before retry with intelligent backoff
234            if attempt < self.config.retry.max_attempts {
235                // Get delay from the last error or use base delay
236                let base_delay = last_error
237                    .as_ref()
238                    .map(|e| e.retry_delay())
239                    .unwrap_or(self.config.retry_base_delay());
240
241                // Add jitter to prevent thundering herd
242                let actual_delay = if self.config.retry.enable_jitter {
243                    let jitter = fastrand::u64(0..=base_delay.as_millis() as u64 / 4);
244                    base_delay + Duration::from_millis(jitter)
245                } else {
246                    base_delay
247                };
248
249                // Exponential backoff for consecutive failures
250                let backoff_multiplier = (consecutive_failures as f64).min(4.0);
251                let final_delay = std::cmp::min(
252                    Duration::from_millis(
253                        (actual_delay.as_millis() as f64
254                            * self.config.retry.backoff_factor.powf(backoff_multiplier))
255                            as u64,
256                    ),
257                    self.config.retry_max_delay(),
258                );
259
260                warn!(
261                    "Request failed (attempt {}/{}), retrying in {:?} (consecutive failures: {})",
262                    attempt, self.config.retry.max_attempts, final_delay, consecutive_failures
263                );
264
265                sleep(final_delay).await;
266            }
267        }
268
269        Err(last_error.unwrap_or_else(|| WeChatError::Internal {
270            message: "Retry loop completed without error".to_string(),
271        }))
272    }
273
274    /// Downloads content from a URL.
275    pub async fn download(&self, url: &str) -> Result<Vec<u8>> {
276        let response = self
277            .execute_with_retry(|| self.client.get(url).send())
278            .await?;
279
280        let bytes = response.bytes().await?;
281        Ok(bytes.to_vec())
282    }
283
284    /// Downloads content from a URL with size limits and streaming.
285    pub async fn download_with_limit(&self, url: &str, max_size: u64) -> Result<Vec<u8>> {
286        // Use the smaller of provided max_size or security config max
287        let effective_max_size = max_size.min(self.config.security.max_download_size);
288        use futures::StreamExt;
289
290        let response = self
291            .execute_with_retry(|| self.client.get(url).send())
292            .await?;
293
294        // Check content length if available
295        if let Some(content_length) = response.content_length()
296            && content_length > effective_max_size
297        {
298            return Err(WeChatError::ImageUpload {
299                path: url.to_string(),
300                reason: format!(
301                    "Content too large: {content_length} bytes (max: {effective_max_size} bytes)"
302                ),
303            });
304        }
305
306        let mut downloaded_size = 0u64;
307        let mut data = Vec::new();
308        let mut stream = response.bytes_stream();
309
310        while let Some(chunk_result) = stream.next().await {
311            let chunk = chunk_result?;
312            downloaded_size += chunk.len() as u64;
313
314            if downloaded_size > effective_max_size {
315                return Err(WeChatError::ImageUpload {
316                    path: url.to_string(),
317                    reason: format!(
318                        "Content too large during download: {downloaded_size} bytes (max: {effective_max_size} bytes)"
319                    ),
320                });
321            }
322
323            data.extend_from_slice(&chunk);
324        }
325
326        debug!("Downloaded {downloaded_size} bytes from {url}");
327        Ok(data)
328    }
329}
330
331// Implement the HttpClient trait for WeChatHttpClient
332#[async_trait::async_trait]
333impl HttpClient for WeChatHttpClient {
334    async fn get_with_token(&self, endpoint: &str, token: &str) -> Result<reqwest::Response> {
335        self.get_with_token(endpoint, token).await
336    }
337
338    async fn post_json_with_token<T: serde::Serialize + Send + Sync>(
339        &self,
340        endpoint: &str,
341        token: &str,
342        body: &T,
343    ) -> Result<reqwest::Response> {
344        self.post_json_with_token(endpoint, token, body).await
345    }
346
347    async fn upload_file(
348        &self,
349        endpoint: &str,
350        token: &str,
351        field_name: &str,
352        file_data: Vec<u8>,
353        filename: &str,
354    ) -> Result<reqwest::Response> {
355        self.upload_file(endpoint, token, field_name, file_data, filename)
356            .await
357    }
358
359    async fn download_with_limit(&self, url: &str, max_size: u64) -> Result<Vec<u8>> {
360        self.download_with_limit(url, max_size).await
361    }
362}
363
364/// Standard WeChat API response structure.
365#[derive(Debug, Deserialize, Serialize)]
366pub struct WeChatResponse<T> {
367    /// Error code (0 for success)
368    #[serde(default)]
369    pub errcode: i32,
370    /// Error message
371    #[serde(default)]
372    pub errmsg: String,
373    /// Response data (flattened)
374    #[serde(flatten)]
375    pub data: Option<T>,
376}
377
378impl<T: std::fmt::Debug> WeChatResponse<T> {
379    /// Converts the response to a Result, checking for API errors.
380    pub fn into_result(self) -> Result<T> {
381        if self.errcode == 0 {
382            self.data.ok_or_else(|| WeChatError::Internal {
383                message: format!(
384                    "Missing response data. errcode: {}, errmsg: {}",
385                    self.errcode, self.errmsg
386                ),
387            })
388        } else {
389            Err(WeChatError::from_api_response(self.errcode, self.errmsg))
390        }
391    }
392}
393
394/// Access token response from WeChat API.
395#[derive(Debug, Deserialize, Serialize)]
396pub struct AccessTokenResponse {
397    pub access_token: String,
398    pub expires_in: u64,
399}
400
401/// Image upload response from WeChat API (uploadimg endpoint).
402#[derive(Debug, Deserialize, Serialize)]
403pub struct ImageUploadResponse {
404    pub url: String,
405}
406
407/// Material upload response from WeChat API (for permanent materials like cover images).
408#[derive(Debug, Deserialize, Serialize)]
409pub struct MaterialUploadResponse {
410    pub media_id: String,
411    pub url: String,
412}
413
414/// Draft creation response from WeChat API.
415#[derive(Debug, Deserialize, Serialize)]
416pub struct DraftResponse {
417    pub media_id: String,
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[tokio::test]
425    async fn test_http_client_creation() {
426        let client = WeChatHttpClient::new();
427        assert!(client.is_ok());
428    }
429
430    #[test]
431    fn test_retry_config() {
432        let config = RetryConfig::default();
433        assert_eq!(config.max_attempts, 3);
434        assert_eq!(config.base_delay_ms, 500);
435        assert_eq!(config.backoff_factor, 2.0);
436    }
437
438    #[test]
439    fn test_wechat_response_success() {
440        let response: WeChatResponse<AccessTokenResponse> = WeChatResponse {
441            errcode: 0,
442            errmsg: "ok".to_string(),
443            data: Some(AccessTokenResponse {
444                access_token: "test_token".to_string(),
445                expires_in: 7200,
446            }),
447        };
448
449        let result = response.into_result();
450        assert!(result.is_ok());
451        assert_eq!(result.unwrap().access_token, "test_token");
452    }
453
454    #[test]
455    fn test_wechat_response_error() {
456        let response: WeChatResponse<AccessTokenResponse> = WeChatResponse {
457            errcode: 40001,
458            errmsg: "invalid credential".to_string(),
459            data: None,
460        };
461
462        let result = response.into_result();
463        assert!(result.is_err());
464
465        if let Err(WeChatError::WeChatApi { code, message }) = result {
466            assert_eq!(code, 40001);
467            assert_eq!(message, "invalid credential");
468        } else {
469            panic!("Expected WeChatApi error");
470        }
471    }
472}