Skip to main content

wechat_mp_sdk/client/
wechat_client.rs

1//! WeChat HTTP Client
2//!
3//! Provides HTTP client wrapper for WeChat API calls.
4
5use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
6use reqwest::Client;
7use serde::de::DeserializeOwned;
8use std::future::Future;
9use std::pin::Pin;
10use std::sync::Arc;
11use std::task::{Context, Poll};
12use std::time::Duration;
13use tower::Service;
14
15use crate::error::{HttpError, WechatError};
16use crate::types::{AppId, AppSecret};
17
18pub(crate) const DEFAULT_BASE_URL: &str = "https://api.weixin.qq.com";
19pub(crate) const DEFAULT_TIMEOUT_SECS: u64 = 30;
20pub(crate) const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 10;
21
22type MiddlewareFuture =
23    Pin<Box<dyn Future<Output = Result<reqwest::Response, reqwest::Error>> + Send>>;
24type MiddlewareExecutor = Arc<dyn Fn(reqwest::Request) -> MiddlewareFuture + Send + Sync>;
25
26/// WeChat API Client
27///
28/// Reusable HTTP client for calling WeChat APIs.
29/// Built with reqwest for async HTTP requests.
30#[derive(Clone)]
31pub struct WechatClient {
32    http: Client,
33    appid: AppId,
34    secret: AppSecret,
35    base_url: String,
36    middleware_executor: Option<MiddlewareExecutor>,
37}
38
39impl std::fmt::Debug for WechatClient {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        f.debug_struct("WechatClient")
42            .field("appid", &self.appid)
43            .field("base_url", &self.base_url)
44            .field(
45                "middleware_executor",
46                &self.middleware_executor.as_ref().map(|_| ".."),
47            )
48            .finish_non_exhaustive()
49    }
50}
51
52impl WechatClient {
53    /// Create a new client builder
54    pub fn builder() -> WechatClientBuilder {
55        WechatClientBuilder::default()
56    }
57
58    /// Get the appid
59    pub fn appid(&self) -> &str {
60        self.appid.as_str()
61    }
62
63    /// Get the app secret
64    pub(crate) fn secret(&self) -> &str {
65        self.secret.as_str()
66    }
67
68    /// Get the base URL
69    pub fn base_url(&self) -> &str {
70        &self.base_url
71    }
72
73    pub(crate) fn append_access_token(path: &str, access_token: &str) -> String {
74        let encoded = utf8_percent_encode(access_token, NON_ALPHANUMERIC);
75
76        if path.contains("access_token={}") {
77            return path.replacen("access_token={}", &format!("access_token={encoded}"), 1);
78        }
79
80        let separator = if path.contains('?') { '&' } else { '?' };
81        format!("{path}{separator}access_token={encoded}")
82    }
83
84    /// Returns the underlying [`reqwest::Client`] for raw HTTP requests.
85    ///
86    /// Note: requests made through this client bypass the middleware pipeline.
87    /// Use [`get`](Self::get) or [`post`](Self::post) for middleware-aware requests.
88    pub fn http(&self) -> &Client {
89        &self.http
90    }
91
92    pub(crate) fn with_middleware_executor(mut self, executor: MiddlewareExecutor) -> Self {
93        self.middleware_executor = Some(executor);
94        self
95    }
96
97    pub(crate) async fn send_request(
98        &self,
99        request: reqwest::Request,
100    ) -> Result<reqwest::Response, reqwest::Error> {
101        if let Some(executor) = &self.middleware_executor {
102            (executor)(request).await
103        } else {
104            self.http.execute(request).await
105        }
106    }
107
108    async fn execute<T: DeserializeOwned>(
109        &self,
110        request: reqwest::Request,
111    ) -> Result<T, WechatError> {
112        let response = self.send_request(request).await?;
113
114        if let Err(e) = response.error_for_status_ref() {
115            return Err(e.into());
116        }
117
118        let value: serde_json::Value = response.json().await?;
119
120        if let Some(errcode) = value.get("errcode").and_then(|v| v.as_i64()) {
121            if errcode != 0 {
122                let errmsg = value
123                    .get("errmsg")
124                    .and_then(|v| v.as_str())
125                    .unwrap_or("unknown error");
126                return Err(WechatError::Api {
127                    code: errcode.try_into().unwrap_or(i32::MAX),
128                    message: errmsg.to_string(),
129                });
130            }
131        }
132
133        serde_json::from_value(value)
134            .map_err(|e| WechatError::Http(HttpError::Decode(e.to_string())))
135    }
136
137    /// Make a GET request to WeChat API
138    ///
139    /// # Arguments
140    /// * `path` - API endpoint path (e.g., "/cgi-bin/token")
141    /// * `query` - Query parameters as key-value pairs
142    ///
143    /// # Returns
144    /// Deserialized response of type T
145    ///
146    /// # Errors
147    /// - Returns `WechatError::Http` for non-2xx HTTP status codes or decode failures
148    /// - Returns `WechatError::Api` when WeChat API returns errcode != 0
149    pub async fn get<T: DeserializeOwned>(
150        &self,
151        path: &str,
152        query: &[(&str, &str)],
153    ) -> Result<T, WechatError> {
154        let url = format!("{}{}", self.base_url, path);
155        let request = self.http.get(url).query(query).build()?;
156        self.execute(request).await
157    }
158
159    /// Make a POST request to WeChat API
160    ///
161    /// # Arguments
162    /// * `path` - API endpoint path (e.g., "/wxa/getwxadevinfo")
163    /// * `body` - Request body to serialize as JSON
164    ///
165    /// # Returns
166    /// Deserialized response of type T
167    ///
168    /// # Errors
169    /// - Returns `WechatError::Http` for non-2xx HTTP status codes or decode failures
170    /// - Returns `WechatError::Api` when WeChat API returns errcode != 0
171    pub async fn post<T: DeserializeOwned, B: serde::Serialize>(
172        &self,
173        path: &str,
174        body: &B,
175    ) -> Result<T, WechatError> {
176        let url = format!("{}{}", self.base_url, path);
177        let request = self.http.post(url).json(body).build()?;
178        self.execute(request).await
179    }
180}
181
182impl Service<reqwest::Request> for WechatClient {
183    type Response = reqwest::Response;
184    type Error = reqwest::Error;
185    type Future = MiddlewareFuture;
186
187    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
188        Poll::Ready(Ok(()))
189    }
190
191    fn call(&mut self, req: reqwest::Request) -> Self::Future {
192        let client = self.http.clone();
193        Box::pin(async move { client.execute(req).await })
194    }
195}
196
197/// Builder for WechatClient
198///
199/// # Example
200///
201/// ```rust
202/// use wechat_mp_sdk::client::WechatClient;
203/// use wechat_mp_sdk::types::{AppId, AppSecret};
204///
205/// #[tokio::main]
206/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
207///     let appid = AppId::new("wx1234567890abcdef")?;
208///     let secret = AppSecret::new("abc1234567890abcdef")?;
209///
210///     let client = WechatClient::builder()
211///         .appid(appid)
212///         .secret(secret)
213///         .build()?;
214///
215///     Ok(())
216/// }
217/// ```
218#[derive(Debug, Default)]
219pub struct WechatClientBuilder {
220    appid: Option<AppId>,
221    secret: Option<AppSecret>,
222    base_url: Option<String>,
223    timeout: Option<Duration>,
224    connect_timeout: Option<Duration>,
225}
226
227impl WechatClientBuilder {
228    /// Set the WeChat AppID
229    pub fn appid(mut self, appid: AppId) -> Self {
230        self.appid = Some(appid);
231        self
232    }
233
234    /// Set the WeChat AppSecret
235    pub fn secret(mut self, secret: AppSecret) -> Self {
236        self.secret = Some(secret);
237        self
238    }
239
240    /// Set the base URL for API calls
241    ///
242    /// Default: `<https://api.weixin.qq.com>`
243    pub fn base_url(mut self, url: impl Into<String>) -> Self {
244        self.base_url = Some(url.into());
245        self
246    }
247
248    /// Set the total timeout for requests
249    ///
250    /// Default: 30 seconds
251    pub fn timeout(mut self, timeout: Duration) -> Self {
252        self.timeout = Some(timeout);
253        self
254    }
255
256    /// Set the connection timeout
257    ///
258    /// Default: 10 seconds
259    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
260        self.connect_timeout = Some(timeout);
261        self
262    }
263
264    /// Build the WechatClient
265    ///
266    /// # Errors
267    /// Returns an error if appid or secret is not set
268    pub fn build(self) -> Result<WechatClient, WechatError> {
269        let appid = self
270            .appid
271            .ok_or_else(|| WechatError::Config("appid is required".to_string()))?;
272        let secret = self
273            .secret
274            .ok_or_else(|| WechatError::Config("secret is required".to_string()))?;
275
276        let base_url = self
277            .base_url
278            .unwrap_or_else(|| DEFAULT_BASE_URL.to_string());
279
280        let timeout = self
281            .timeout
282            .unwrap_or(Duration::from_secs(DEFAULT_TIMEOUT_SECS));
283        let connect_timeout = self
284            .connect_timeout
285            .unwrap_or(Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS));
286
287        let client = Client::builder()
288            .timeout(timeout)
289            .connect_timeout(connect_timeout)
290            .build()?;
291
292        Ok(WechatClient {
293            http: client,
294            appid,
295            secret,
296            base_url,
297            middleware_executor: None,
298        })
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_builder_default_values() {
308        let appid = AppId::new("wx1234567890abcdef").unwrap();
309        let secret = AppSecret::new("secret1234567890ab").unwrap();
310
311        let client = WechatClient::builder()
312            .appid(appid.clone())
313            .secret(secret.clone())
314            .build()
315            .unwrap();
316
317        assert_eq!(client.appid(), appid.as_str());
318        assert_eq!(client.base_url(), DEFAULT_BASE_URL);
319    }
320
321    #[test]
322    fn test_builder_custom_base_url() {
323        let appid = AppId::new("wx1234567890abcdef").unwrap();
324        let secret = AppSecret::new("secret1234567890ab").unwrap();
325
326        let client = WechatClient::builder()
327            .appid(appid)
328            .secret(secret)
329            .base_url("https://custom.api.example.com")
330            .build()
331            .unwrap();
332
333        assert_eq!(client.base_url(), "https://custom.api.example.com");
334    }
335
336    #[test]
337    fn test_builder_custom_timeouts() {
338        let appid = AppId::new("wx1234567890abcdef").unwrap();
339        let secret = AppSecret::new("secret1234567890ab").unwrap();
340
341        let client = WechatClient::builder()
342            .appid(appid)
343            .secret(secret)
344            .timeout(Duration::from_secs(60))
345            .connect_timeout(Duration::from_secs(5))
346            .build()
347            .unwrap();
348
349        // Verify client was built successfully with custom timeouts.
350        // reqwest::Client doesn't expose timeout getters, so we verify
351        // the builder accepted the values and produced a valid client.
352        assert_eq!(client.base_url(), DEFAULT_BASE_URL);
353        assert_eq!(client.appid(), "wx1234567890abcdef");
354    }
355
356    #[test]
357    fn test_builder_missing_appid() {
358        let secret = AppSecret::new("secret1234567890ab").unwrap();
359
360        let result = WechatClient::builder().secret(secret).build();
361
362        assert!(result.is_err());
363    }
364
365    #[test]
366    fn test_builder_missing_secret() {
367        let appid = AppId::new("wx1234567890abcdef").unwrap();
368
369        let result = WechatClient::builder().appid(appid).build();
370
371        assert!(result.is_err());
372    }
373}