Skip to main content

wecomx_runtime/
builder.rs

1//! [`WecomClientBuilder`]:一键构建带鉴权的 WeCom 客户端 / transport。
2//!
3//! 组合底层 HTTP 传输配置(base_url / 默认头 / 超时)与
4//! [`TokenProvider`](wecomx_auth::TokenProvider)(token 注入、853004 静默
5//! 刷新),产出可直接使用的 [`WecomClient`](crate::WecomClient)(或仅 transport)。
6
7use std::sync::Arc;
8use std::time::Duration;
9
10use wecomx_auth::TokenProvider;
11use wecomx_transport::{HttpTransportBackend, Transport};
12
13use crate::backend::WecomBackend;
14use crate::client::WecomClient;
15
16/// 默认 API base URL。
17///
18/// 缺省时用于兜底;CLI 侧可经 `WECOM_CLI_BASE_URL` / `config.json::base_url`
19/// 覆盖(`custom-endpoint` feature)。
20pub const DEFAULT_BASE_URL: &str = "https://qyapi.weixin.qq.com/cli";
21
22/// 错误提示中的默认命令名(如「请先运行 `wecom-cli auth init` 登录」)。
23pub const DEFAULT_BIN_NAME: &str = "wecom-cli";
24
25/// 带鉴权的客户端构建器。
26///
27/// # Example
28///
29/// ```ignore
30/// let client = WecomClientBuilder::new()
31///     .token_provider(Arc::new(provider))
32///     .timeout(Duration::from_secs(10))
33///     .build()
34///     .await?;
35/// ```
36#[derive(Clone)]
37pub struct WecomClientBuilder {
38    base_url: String,
39    provider: Option<Arc<dyn TokenProvider>>,
40    initial_token: Option<String>,
41    timeout: Option<Duration>,
42    headers: Vec<(String, String)>,
43    bin_name: String,
44}
45
46impl Default for WecomClientBuilder {
47    fn default() -> Self {
48        Self::new()
49    }
50}
51
52impl WecomClientBuilder {
53    /// 创建构建器:默认指向 product/正式环境网关。
54    pub fn new() -> Self {
55        Self {
56            base_url: DEFAULT_BASE_URL.to_string(),
57            provider: None,
58            initial_token: None,
59            timeout: None,
60            headers: Vec::new(),
61            bin_name: DEFAULT_BIN_NAME.to_string(),
62        }
63    }
64
65    /// 覆写服务 base URL。
66    #[must_use]
67    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
68        self.base_url = base_url.into();
69        self
70    }
71
72    /// 设置 token provider(token 注入与 853004 静默刷新的来源)。
73    #[must_use]
74    pub fn token_provider(mut self, provider: Arc<dyn TokenProvider>) -> Self {
75        self.provider = Some(provider);
76        self
77    }
78
79    /// 预置初始 token(缺省时经 provider 读取;如 CLI 的环境变量覆写)。
80    #[must_use]
81    pub fn initial_token(mut self, token: impl Into<String>) -> Self {
82        self.initial_token = Some(token.into());
83        self
84    }
85
86    /// 设置每请求超时。
87    #[must_use]
88    pub fn timeout(mut self, timeout: Duration) -> Self {
89        self.timeout = Some(timeout);
90        self
91    }
92
93    /// 添加默认请求头(对每个请求生效;header 名/值在 build 时统一校验)。
94    #[must_use]
95    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
96        self.headers.push((name.into(), value.into()));
97        self
98    }
99
100    /// 设置错误提示中的命令名(影响 `auth init` 引导文案)。
101    #[must_use]
102    pub fn bin_name(mut self, bin_name: impl Into<String>) -> Self {
103        self.bin_name = bin_name.into();
104        self
105    }
106
107    /// Build the authenticated [`WecomClient`](crate::WecomClient).
108    ///
109    /// 等价于 [`build_transport`](Self::build_transport) 之后经
110    /// [`WecomClient::from_transport`](crate::WecomClient::from_transport)
111    /// 组装完整客户端(自动挂网关协议端点目录,直接可调服务方法)。
112    ///
113    /// # Errors
114    ///
115    /// 底层传输配置非法(header 校验等)或客户端构建失败时返回
116    /// [`wecomx::Error`]。
117    pub async fn build(self) -> Result<WecomClient, wecomx::Error> {
118        let transport = self.build_transport().await.map_err(wecomx::Error::from)?;
119        WecomClient::from_transport(transport)
120    }
121
122    /// Build only the authenticated [`Transport`](不组装客户端)。
123    ///
124    /// 适合需要完全掌控 [`wecomx::Client`] 配置(沙箱 FS、helper、扩展命令、
125    /// 端点目录覆写等)的调用方。
126    ///
127    /// 初始 token 优先取 [`initial_token`](Self::initial_token),否则经
128    /// provider 读取(无凭据为 None,不报错);transport 装饰为
129    /// [`WecomBackend`](动态注入 + 门禁 + 853004 静默刷新)。
130    ///
131    /// # Errors
132    ///
133    /// 底层传输配置非法(header 校验等)或初始 token 读取失败时返回
134    /// [`wecomx_transport::Error`]。
135    pub async fn build_transport(self) -> Result<Transport, wecomx_transport::Error> {
136        let mut builder = HttpTransportBackend::builder().base_url(self.base_url);
137        for (name, value) in &self.headers {
138            builder = builder.header(name.as_str(), value.as_str());
139        }
140        if let Some(timeout) = self.timeout {
141            builder = builder.timeout(timeout);
142        }
143        let transport = builder.build()?;
144
145        let initial_token = match self.initial_token {
146            Some(token) => Some(token),
147            None => match &self.provider {
148                Some(provider) => provider
149                    .access_token()
150                    .await
151                    .map_err(wecomx_transport::Error::from)?
152                    .map(|t| t.into_inner()),
153                None => None,
154            },
155        };
156
157        Ok(transport.wrap_backend(move |backend| {
158            Arc::new(
159                WecomBackend::new(backend, self.provider, initial_token)
160                    .with_bin_name(self.bin_name),
161            )
162        }))
163    }
164}
165
166#[cfg(test)]
167pub(crate) mod tests {
168    //! ## 模块摘要:builder(WecomClientBuilder)
169    //!
170    //! ### 关键接口
171    //! - [WecomClientBuilder::build] — 组合 HTTP 配置与 token provider,
172    //!   产出 WecomClient(含网关端点目录)
173    //! - [WecomClientBuilder::build_transport] — 仅产出带鉴权的 transport
174
175    use std::sync::Arc;
176
177    use serde_json::json;
178    use wecomx_auth::{BotCredential, BotGatewayTokenProvider, Credentials, MemoryCredentialStore};
179    use wiremock::matchers::{method, path};
180    use wiremock::{Mock, MockServer, ResponseTemplate};
181
182    use super::*;
183
184    /// 装配鉴权引导端点(FlatRes 信封 + SuppressAuth)。
185    pub(crate) fn auth_ep(url: &str) -> wecomx_transport::Endpoint {
186        wecomx_auth::auth_endpoint(url)
187    }
188
189    /// P0:build 后 token 经 provider 从存储读取,RequireAuth 端点可正常注入
190    /// 条件:存储含 bot + token;业务 mock 要求 Bearer tok-stored
191    /// 断言:经 client.transport 调用成功
192    #[tokio::test]
193    async fn build_reads_initial_token_from_provider() {
194        let server = MockServer::start().await;
195        Mock::given(method("POST"))
196            .and(path("/api"))
197            .and(wiremock::matchers::header(
198                "authorization",
199                "Bearer tok-stored",
200            ))
201            .respond_with(
202                ResponseTemplate::new(200).set_body_json(json!({"result": "{\"ok\":true}"})),
203            )
204            .expect(1)
205            .mount(&server)
206            .await;
207
208        let store = MemoryCredentialStore::new(Credentials {
209            bot: Some(BotCredential::new("bot1".into(), "secret1".into())),
210            token: Some("tok-stored".into()),
211        })
212        .shared();
213        let provider = BotGatewayTokenProvider::from_store(store)
214            .with_auth_endpoint(auth_ep(&format!("{}/bootstrap", server.uri())));
215
216        let client = WecomClientBuilder::new()
217            .base_url(server.uri())
218            .token_provider(Arc::new(provider))
219            .timeout(std::time::Duration::from_secs(5))
220            .build()
221            .await
222            .unwrap();
223
224        let endpoint = wecomx_transport::Endpoint::new()
225            .with(wecomx_transport::HttpEndpoint::new("/api").with_service(server.uri()));
226        let v = client
227            .transport()
228            .invoke(&endpoint, json!({}))
229            .await
230            .unwrap()
231            .into_result()
232            .unwrap();
233        assert_eq!(v, json!({"ok": true}));
234        server.verify().await;
235    }
236
237    /// P1:initial_token 覆盖 provider 读取的存储 token
238    /// 条件:builder 传 initial_token("tok-override"),存储 token 为 tok-stored
239    /// 断言:请求携带 Bearer tok-override
240    #[tokio::test]
241    async fn initial_token_overrides_provider() {
242        let server = MockServer::start().await;
243        Mock::given(method("POST"))
244            .and(path("/api"))
245            .and(wiremock::matchers::header(
246                "authorization",
247                "Bearer tok-override",
248            ))
249            .respond_with(
250                ResponseTemplate::new(200).set_body_json(json!({"result": "{\"ok\":true}"})),
251            )
252            .expect(1)
253            .mount(&server)
254            .await;
255
256        let store = MemoryCredentialStore::new(Credentials {
257            bot: Some(BotCredential::new("bot1".into(), "secret1".into())),
258            token: Some("tok-stored".into()),
259        })
260        .shared();
261        let provider = BotGatewayTokenProvider::from_store(store);
262
263        let transport = WecomClientBuilder::new()
264            .base_url(server.uri())
265            .token_provider(Arc::new(provider))
266            .initial_token("tok-override")
267            .build_transport()
268            .await
269            .unwrap();
270
271        let endpoint = wecomx_transport::Endpoint::new()
272            .with(wecomx_transport::HttpEndpoint::new("/api").with_service(server.uri()));
273        let v = transport
274            .invoke(&endpoint, json!({}))
275            .await
276            .unwrap()
277            .into_result()
278            .unwrap();
279        assert_eq!(v, json!({"ok": true}));
280        server.verify().await;
281    }
282
283    /// P1:无 provider 时 build 成功,transport 无鉴权能力(无 token 缓存)
284    #[tokio::test]
285    async fn build_without_provider() {
286        let client = WecomClientBuilder::new()
287            .base_url("http://localhost")
288            .build()
289            .await
290            .unwrap();
291        assert_eq!(client.transport().name(), "http");
292    }
293
294    /// P1:build 产出的 WecomClient 挂有网关端点目录(`from_transport` 路径)
295    /// 条件:经 build 组装
296    /// 断言:client.inner() 可正常产出 endpoint、暴露 service API
297    #[tokio::test]
298    async fn build_attaches_gateway_catalog() {
299        let client = WecomClientBuilder::new()
300            .base_url("http://localhost")
301            .build()
302            .await
303            .unwrap();
304        // 端点目录由 Client 内部持有;此处仅验证客户端可正常产出 endpoint。
305        let _endpoint = client.inner().endpoint("media/upload");
306    }
307}