Skip to main content

wecomx_auth/
gateway.rs

1//! AI Bot CLI 网关协议:扁平响应信封与鉴权能力标记。
2//!
3//! 真实网关协议为顶层 `{errcode, errmsg, results_json}`,由 [`NestedRes`]
4//! 完成 errcode 校验 + results_json 内层脱壳;鉴权引导等不套网关信封的接口
5//! 使用 [`FlatRes`]——业务数据平铺在顶层,经 [`FlatApiResponse::extra`] 透传,
6//! errcode 校验与 [`NestedRes`] 共用 [`validate_flat_api_response`]。
7//!
8//! 鉴权语义:
9//! - [`RequireAuth`] 作为**门禁**标记挂在 [`Endpoint`](wecomx_transport::Endpoint)
10//!   能力袋上:挂载该标记的端点若无可用的 token,请求直接报
11//!   [`AuthError::MissingCredentials`](crate::error::AuthError) 且不发出。
12//! - [`SuppressAuth`] 作为**抑制注入**标记:携带该标记的端点(如换取 token
13//!   的鉴权引导接口)即使持有 token 也不注入 `Authorization` 头。
14//! - 默认行为(不挂任何标记):只要持有 token 就注入
15//!   `Authorization: Bearer <token>`,没有 token 则忽略(不报错)。
16
17use indexmap::IndexMap;
18use wecomx_transport::{
19    HttpEndpoint, ResponseEnvelope,
20    backend::protocol::{ApiResponse, validate_api_response},
21};
22
23/// 端点调用前的 token 门禁标记(存在即生效)。
24///
25/// 挂进 [`Endpoint`](wecomx_transport::Endpoint) 能力袋——鉴权门禁按 endpoint
26/// 单独声明:挂载后调用前必须已有可用 token,无 token 时报
27/// [`AuthError::MissingCredentials`](crate::error::AuthError),请求不发出。
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
29pub struct RequireAuth;
30
31/// 抑制 `Authorization` 注入的标记(换取 token 的引导端点专用)。
32///
33/// 默认所有端点「有 token 就携带、无 token 则忽略」;仅鉴权引导等换取 token
34/// 的接口挂此标记,保证引导请求绝不携带失效 token,避免 853004 刷新自死锁。
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
36pub struct SuppressAuth;
37
38/// 鉴权引导端点默认 URL(botid+secret 签名调用换取 Bearer token,product/正式环境)。
39pub const DEFAULT_AUTH_ENDPOINT: &str =
40    "https://qyapi.weixin.qq.com/cgi-bin/aibot/cli/get_cli_config";
41
42/// 网关扁平协议响应体:顶层只有 `errcode` / `errmsg` / `results_json`。
43///
44/// `results_json` 为字符串,内层直接复用 [`ApiResponse`]。
45#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
46pub struct FlatApiResponse {
47    pub errcode: Option<i64>,
48    pub errmsg: Option<String>,
49    pub results_json: Option<String>,
50    #[serde(flatten)]
51    pub extra: IndexMap<String, serde_json::Value>,
52}
53
54/// 网关扁平协议响应信封。
55///
56/// 顶层 `{errcode, errmsg, results_json}`:`errcode` 校验 →
57/// `results_json` 脱壳为 [`ApiResponse`](含 `error.code` 校验)。除鉴权引导
58/// ([`FlatRes`],扁平整体响应)外,所有端点均走此协议。
59#[derive(Debug, Clone, Copy, Default)]
60pub struct NestedRes;
61
62impl ResponseEnvelope for NestedRes {
63    fn decode(
64        &self,
65        url: &str,
66        body: serde_json::Value,
67    ) -> std::result::Result<ApiResponse, wecomx_transport::Error> {
68        // 扁平协议顶层解析 + errcode 校验。
69        let flat: FlatApiResponse =
70            serde_json::from_value(body).map_err(|e| wecomx_transport::Error::Parse {
71                message: format!("Parse FlatApiResponse failed for {url}: {e:#}"),
72                endpoint: url.to_string(),
73                body: Box::new(serde_json::Value::Null),
74                source: Some(e),
75            })?;
76        let flat = validate_flat_api_response(url, flat)?;
77
78        // 解析 results_json 内层(复用 ApiResponse,含 error.code 校验)。
79        // 网关扁平响应必须携带 results_json,缺失视为协议异常。
80        let results_json =
81            flat.results_json
82                .as_deref()
83                .ok_or_else(|| wecomx_transport::Error::Parse {
84                    message: "API response missing `results_json` field".to_string(),
85                    endpoint: url.to_string(),
86                    body: Box::new(serde_json::to_value(&flat).unwrap_or_default()),
87                    source: None,
88                })?;
89
90        let inner: ApiResponse =
91            serde_json::from_str(results_json).map_err(|e| wecomx_transport::Error::Parse {
92                message: format!("Parse `results_json` JSON failed: {e:#}"),
93                endpoint: url.to_string(),
94                body: Box::new(serde_json::Value::String(results_json.to_string())),
95                source: Some(e),
96            })?;
97
98        validate_api_response(url, inner)
99    }
100
101    fn name(&self) -> &'static str {
102        "nested"
103    }
104}
105
106/// 扁平响应信封(鉴权引导等「不套网关 `results_json` 信封」的接口使用)。
107///
108/// 与 [`NestedRes`] 同为网关扁平协议([`FlatApiResponse`]),区别仅在
109/// 业务数据的位置:`results_json` 字符串 vs 顶层平铺字段(`extra`)。
110/// `errcode` 校验复用 [`validate_flat_api_response`],`extra` 即业务结果。
111/// 引导端点须显式挂 [`SuppressAuth`]
112/// 抑制 Authorization 注入(换取 token 的请求不得携带 token)。
113#[derive(Debug, Clone, Copy, Default)]
114pub struct FlatRes;
115
116impl ResponseEnvelope for FlatRes {
117    fn decode(
118        &self,
119        url: &str,
120        body: serde_json::Value,
121    ) -> std::result::Result<ApiResponse, wecomx_transport::Error> {
122        let flat: FlatApiResponse =
123            serde_json::from_value(body).map_err(|e| wecomx_transport::Error::Parse {
124                message: format!("Parse FlatApiResponse failed for {url}: {e:#}"),
125                endpoint: url.to_string(),
126                body: Box::new(serde_json::Value::Null),
127                source: Some(e),
128            })?;
129
130        let flat = validate_flat_api_response(url, flat)?;
131
132        // 业务数据平铺在顶层,经 extra 透传。
133        Ok(ApiResponse {
134            result: Some(serde_json::to_string(&flat.extra).unwrap_or_default()),
135            error: None,
136            taskid: None,
137            poll_mode: None,
138            long_task_poll: None,
139            extra: Default::default(),
140        })
141    }
142
143    fn name(&self) -> &'static str {
144        "flat"
145    }
146}
147
148/// 校验已反序列化的扁平响应([`FlatApiResponse`])的业务错误码。
149///
150/// `errcode != 0` → [`wecomx_transport::Error::Api`](errmsg 为消息);缺失视为 0。
151fn validate_flat_api_response(
152    url: &str,
153    data: FlatApiResponse,
154) -> std::result::Result<FlatApiResponse, wecomx_transport::Error> {
155    let code = data.errcode.unwrap_or(0);
156    if code != 0 {
157        return Err(wecomx_transport::Error::Api {
158            message: data
159                .errmsg
160                .clone()
161                .unwrap_or_else(|| "Unknown error".to_string()),
162            action: url.to_string(),
163            code: Some(code),
164            body: Box::new(serde_json::to_value(&data).unwrap_or_default()),
165        })
166        .inspect_err(|e| tracing::error!(error = %e, "API error response"));
167    }
168    Ok(data)
169}
170
171/// 按 URL 装配鉴权引导端点(换取 Bearer token 的专用 Endpoint)——引导端点
172/// 的唯一装配原语。
173///
174/// 使用 [`FlatRes`] 扁平响应信封(整体 JSON body 即业务结果
175/// `{errcode, errmsg, token}`),并挂 [`SuppressAuth`] 抑制标记——即使持有
176/// token 也不携带 Authorization 头(换取 token 的引导请求不得带失效 token,
177/// 否则 853004 刷新会自死锁)。
178pub fn auth_endpoint(url: &str) -> wecomx_transport::Endpoint {
179    wecomx_transport::Endpoint::new()
180        .with(HttpEndpoint::from_url(url).with_res_envelope(FlatRes))
181        .with(SuppressAuth)
182}
183
184#[cfg(test)]
185mod tests {
186    //! ## 模块摘要:gateway(AI Bot CLI 网关协议:NestedRes / FlatRes / 鉴权标记)
187    //!
188    //! ### 关键接口
189    //! - [FlatRes::decode] — 复用 [FlatApiResponse] 解析 +
190    //!   [validate_flat_api_response] 校验 errcode;业务数据在顶层平铺字段(extra)中
191    //!
192    //! ### 关键分支与异常路径
193    //! - errcode != 0 → `wecomx_transport::Error::Api`(message 取后台 errmsg,
194    //!   body 含 errcode/errmsg,保留后台 errcode);errcode 缺失视为 0
195
196    use serde_json::json;
197
198    use super::*;
199
200    /// P0:[FlatRes::decode] errcode=0 时顶层平铺字段(extra)作为 result 透传
201    /// 条件:body 为 {"errcode":0,"errmsg":"ok","token":"t1"}
202    /// 断言:decode 成功,result 为 {"token":"t1"} 的 JSON 字符串
203    #[test]
204    fn flat_res_returns_extra_on_success() {
205        let body = json!({"errcode": 0, "errmsg": "ok", "token": "t1"});
206        let resp = FlatRes.decode("/auth", body).unwrap();
207        let result: serde_json::Value =
208            serde_json::from_str(resp.result.as_deref().unwrap()).unwrap();
209        assert_eq!(result, json!({"token": "t1"}));
210    }
211
212    /// P1:[FlatRes::decode] errcode 缺失视为 0
213    /// 条件:body 无 errcode 字段
214    /// 断言:decode 成功,result 为顶层平铺字段
215    #[test]
216    fn flat_res_missing_errcode_is_ok() {
217        let resp = FlatRes.decode("/auth", json!({"token": "t1"})).unwrap();
218        let result: serde_json::Value =
219            serde_json::from_str(resp.result.as_deref().unwrap()).unwrap();
220        assert_eq!(result, json!({"token": "t1"}));
221    }
222
223    /// P0:[FlatRes::decode] errcode!=0 → Api 错误取后台 errmsg
224    /// 条件:body 为 {"errcode":853000,"errmsg":"invalid credential"}
225    /// 断言:Api 错误 message=errmsg、code=853000、action=url、body 含 errcode/errmsg
226    #[test]
227    fn flat_res_errcode_is_api_error() {
228        let err = FlatRes
229            .decode(
230                "/auth",
231                json!({"errcode": 853000, "errmsg": "invalid credential"}),
232            )
233            .unwrap_err();
234        match err {
235            wecomx_transport::Error::Api {
236                message,
237                action,
238                code,
239                body,
240            } => {
241                assert_eq!(message, "invalid credential");
242                assert_eq!(action, "/auth");
243                assert_eq!(code, Some(853000));
244                assert_eq!(body["errcode"], json!(853000));
245                assert_eq!(body["errmsg"], json!("invalid credential"));
246            }
247            other => panic!("expected Api error, got {other:?}"),
248        }
249    }
250
251    /// P1:[FlatRes::decode] errmsg 缺失时回退默认文案,code 保留
252    /// 条件:errcode=853004 且无 errmsg
253    /// 断言:Api 错误 message 为 "Unknown error",code=853004
254    #[test]
255    fn flat_res_missing_errmsg_falls_back_to_default() {
256        let err = FlatRes
257            .decode("/auth", json!({"errcode": 853004}))
258            .unwrap_err();
259        match err {
260            wecomx_transport::Error::Api { message, code, .. } => {
261                assert_eq!(message, "Unknown error");
262                assert_eq!(code, Some(853004));
263            }
264            other => panic!("expected Api error, got {other:?}"),
265        }
266    }
267}