1use 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#[derive(Debug, Clone)]
22pub struct WeChatHttpClient {
23 client: Client,
24 config: Config,
25}
26
27impl WeChatHttpClient {
28 pub fn new() -> Result<Self> {
30 Self::with_config(Config::default())
31 }
32
33 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 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 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 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 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 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 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 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 let mime_type = mime_guess::from_path(&safe_filename)
113 .first_or_octet_stream()
114 .to_string();
115
116 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 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 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 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 let mime_type = mime_guess::from_path(&safe_filename)
160 .first_or_octet_stream()
161 .to_string();
162
163 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 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 if response.status().is_success() {
194 return Ok(response);
195 } else {
196 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 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 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 if attempt < self.config.retry.max_attempts {
235 let base_delay = last_error
237 .as_ref()
238 .map(|e| e.retry_delay())
239 .unwrap_or(self.config.retry_base_delay());
240
241 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 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 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 pub async fn download_with_limit(&self, url: &str, max_size: u64) -> Result<Vec<u8>> {
286 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 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#[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#[derive(Debug, Deserialize, Serialize)]
366pub struct WeChatResponse<T> {
367 #[serde(default)]
369 pub errcode: i32,
370 #[serde(default)]
372 pub errmsg: String,
373 #[serde(flatten)]
375 pub data: Option<T>,
376}
377
378impl<T: std::fmt::Debug> WeChatResponse<T> {
379 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#[derive(Debug, Deserialize, Serialize)]
396pub struct AccessTokenResponse {
397 pub access_token: String,
398 pub expires_in: u64,
399}
400
401#[derive(Debug, Deserialize, Serialize)]
403pub struct ImageUploadResponse {
404 pub url: String,
405}
406
407#[derive(Debug, Deserialize, Serialize)]
409pub struct MaterialUploadResponse {
410 pub media_id: String,
411 pub url: String,
412}
413
414#[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}