Skip to main content

wecomx_transport/common/
endpoint.rs

1//! Type-indexed capability bag for transport backends.
2//!
3//! An [`Endpoint`] is a **capability bag** keyed by [`TypeId`]. Each transport
4//! backend declares its own capability type and reads it via [`Endpoint::get`]
5//! or [`Endpoint::require`].
6//!
7//! # Built-in capabilities
8//!
9//! | Capability | Location | Consumer |
10//! |---|---|---|
11//! | [`HttpEndpoint`](crate::http::HttpEndpoint) | [`crate::http`] | HttpTransportBackend |
12//! | [`PollEndpoint`](crate::PollEndpoint) | [`crate::common`] | http polling (TaskQuery mode) |
13//!
14//! # Reading capability-specific data
15//!
16//! Import the extension trait for the capability you need:
17//!
18//! ```ignore
19//! use wecomx_transport::EndpointHttpExt;  // .base_url(), .path(), .full_url(), .host(), …
20//! ```
21//!
22//! Or use the new typed API:
23//!
24//! ```ignore
25//! let http = ep.get::<HttpEndpoint>().unwrap();
26//! let url  = http.full_url();
27//! ```
28//!
29//! # Constructing endpoints
30//!
31//! ```ignore
32//! // HTTP-only (base_url from transport default)
33//! let ep = Endpoint::new().with(HttpEndpoint::new("/cgi/action"));
34//!
35//! // HTTP-only with explicit base_url
36//! let ep = Endpoint::new().with(
37//!     HttpEndpoint::new("/cgi/action").with_base_url("https://api.example.com")
38//! );
39//!
40//! // With a custom request envelope strategy
41//! let http = HttpEndpoint::new("/cgi").with_req_envelope(custom_req_envelope);
42//! let ep = Endpoint::new().with(http);
43//! ```
44
45use std::any::{Any, TypeId};
46use std::borrow::Cow;
47use std::collections::HashMap;
48use std::fmt::Debug;
49
50// ── EndpointExt trait ────────────────────────────────────────
51
52/// A value that can be stored in the [`Endpoint`] capability bag.
53///
54/// Automatically implemented for any `Clone + Debug + Send + Sync + 'static`
55/// type via a blanket impl — no manual implementation needed.
56pub trait EndpointExt: Any + Debug + Send + Sync + 'static {
57    fn as_any(&self) -> &dyn Any;
58    fn clone_box(&self) -> Box<dyn EndpointExt>;
59    /// Convert an owned boxed capability into a boxed `Any`, enabling
60    /// type-safe downcast to the concrete `T` inside [`Endpoint::map`].
61    fn into_any(self: Box<Self>) -> Box<dyn Any>;
62}
63
64impl<T: Any + Debug + Clone + Send + Sync + 'static> EndpointExt for T {
65    fn as_any(&self) -> &dyn Any {
66        self
67    }
68    fn clone_box(&self) -> Box<dyn EndpointExt> {
69        Box::new(self.clone())
70    }
71    fn into_any(self: Box<Self>) -> Box<dyn Any> {
72        self
73    }
74}
75
76// ── Endpoint capability bag ──────────────────────────────────
77
78/// Transport-level unified addressing — a type-indexed capability bag.
79///
80/// Does not prescribe any fields. Each transport backend reads only the
81/// capability types it declared, via [`Endpoint::get`] or [`Endpoint::require`].
82///
83/// For capability-specific accessors (`base_url()`, `full_url()`, …),
84/// import the corresponding extension trait:
85/// - [`EndpointHttpExt`](crate::http::EndpointHttpExt)
86#[derive(Default)]
87pub struct Endpoint {
88    ext: HashMap<TypeId, Box<dyn EndpointExt>>,
89}
90
91impl Clone for Endpoint {
92    fn clone(&self) -> Self {
93        let mut ext: HashMap<TypeId, Box<dyn EndpointExt>> = HashMap::with_capacity(self.ext.len());
94        for (k, v) in &self.ext {
95            ext.insert(*k, (**v).clone_box());
96        }
97        Self { ext }
98    }
99}
100
101impl Debug for Endpoint {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        f.debug_set().entries(self.ext.values()).finish()
104    }
105}
106
107impl Endpoint {
108    // ── Capability API ───────────────────────────────────────
109
110    /// Create an empty capability bag. Build up with `.with(...)`.
111    pub fn new() -> Self {
112        Self::default()
113    }
114
115    /// Attach or overwrite a capability (builder style). Same-type
116    /// capabilities are replaced (last wins).
117    #[must_use]
118    pub fn with<T: EndpointExt>(mut self, cap: T) -> Self {
119        self.ext.insert(TypeId::of::<T>(), Box::new(cap));
120        self
121    }
122
123    /// Attach or overwrite a capability in place.
124    pub fn set<T: EndpointExt>(&mut self, cap: T) {
125        self.ext.insert(TypeId::of::<T>(), Box::new(cap));
126    }
127
128    /// Transform a capability of type `T`, returning the updated bag.
129    ///
130    /// When the capability is present, it is taken out of the bag (owned) and
131    /// passed to `f` by value; its return value replaces the old one, and all
132    /// other capabilities are kept. When the capability is absent, `f` is not
133    /// called and the bag is returned unchanged.
134    ///
135    /// Pairs with capability-specific derivations such as
136    /// [`HttpEndpoint::with_path_derived`](crate::http::HttpEndpoint::with_path_derived):
137    ///
138    /// ```ignore
139    /// let new_ep = ep.map::<HttpEndpoint>(|e| e.with_path_derived("/task/query"));
140    /// ```
141    #[must_use]
142    pub fn map<T: EndpointExt>(mut self, f: impl FnOnce(T) -> T) -> Self {
143        if let Some(cap) = self.ext.remove(&TypeId::of::<T>())
144            && let Ok(boxed) = cap.into_any().downcast::<T>()
145        {
146            self.set(f(*boxed));
147        }
148        self
149    }
150
151    /// Read a capability by type. Returns `None` if absent.
152    pub fn get<T: EndpointExt>(&self) -> Option<&T> {
153        let b = self.ext.get(&TypeId::of::<T>())?;
154        (**b).as_any().downcast_ref::<T>()
155    }
156
157    /// Read a **required** capability. Missing → `Error::Config` with
158    /// the transport name and the Rust type name of the missing capability.
159    pub fn require<T: EndpointExt>(&self, transport: &str) -> crate::Result<&T> {
160        self.get::<T>().ok_or_else(|| {
161            crate::Error::Config(format!(
162                "`{transport}` transport requires endpoint capability `{}`",
163                std::any::type_name::<T>()
164            ))
165        })
166    }
167}
168
169// ── IntoCowEndpoint ──────────────────────────────────────────
170
171/// Trait for converting various endpoint representations into `Cow<'a, Endpoint>`.
172///
173/// - `&'a Endpoint`        → `Cow::Borrowed` (zero-copy)
174/// - `Endpoint`            → `Cow::Owned`
175/// - `Cow<'a, Endpoint>`   → pass-through
176///
177/// Capability values should be wrapped in an [`Endpoint`] by the caller:
178/// `Endpoint::new().with(capability)`.
179pub trait IntoCowEndpoint<'a> {
180    fn into_cow_endpoint(self) -> Cow<'a, Endpoint>;
181}
182
183impl<'a> IntoCowEndpoint<'a> for &'a Endpoint {
184    fn into_cow_endpoint(self) -> Cow<'a, Endpoint> {
185        Cow::Borrowed(self)
186    }
187}
188
189impl<'a> IntoCowEndpoint<'a> for Endpoint {
190    fn into_cow_endpoint(self) -> Cow<'a, Endpoint> {
191        Cow::Owned(self)
192    }
193}
194
195impl<'a> IntoCowEndpoint<'a> for Cow<'a, Endpoint> {
196    fn into_cow_endpoint(self) -> Cow<'a, Endpoint> {
197        self
198    }
199}
200
201// ── Tests ────────────────────────────────────────────────────
202
203#[cfg(test)]
204mod tests {
205    //! ## 模块摘要:common::endpoint(Endpoint 能力袋核心 API)
206    //!
207    //! ### 关键接口
208    //! - [Endpoint::new] / [Endpoint::with] / [Endpoint::get] / [Endpoint::require] / [Endpoint::set] — 能力袋增删改查
209    //! - [Endpoint::map] — 就地转换单个能力,保留其余;缺失时 no-op
210    //! - [IntoCowEndpoint] — 端点参数零拷贝(借用)/ 拥有 / 透传转换
211    //!
212    //! ### 关键分支与异常路径
213    //! - 仅 HTTP / 多能力端点构造
214    //! - 路径规范化:缺前导斜杠自动补 `/`,空路径 → `"/"`
215    //! - envelope 默认 StandardEnvelope;with_envelope 仅替换策略
216    //! - [Endpoint::require] 能力缺失 → Err(Config)
217    //! - req/res envelope 默认 PassthroughReq / GatewayRes;with_req_envelope 仅替换策略
218    //! - [Endpoint::set] 原地覆盖;[Endpoint::with] 同类型后者胜出
219    //!
220    //! ### 上下游交互
221    //! - 上游:调用方(wecomx、bot-lib、transport e2e helpers)构造 Endpoint
222    //! - 下游:TransportRequest → TransportBackend::execute 消费 Endpoint 做路由
223
224    use super::*;
225    use crate::HttpEndpoint;
226    use crate::http::EndpointHttpExt;
227
228    /// 第二个能力类型,用于验证袋操作不触碰非目标能力。
229    #[derive(Clone, Debug, PartialEq, Eq)]
230    struct TestCapability(String);
231
232    /// 测试助手:创建仅含 HTTP 能力的 Endpoint。
233    fn http_endpoint(base: &str, path: &str) -> Endpoint {
234        let http = HttpEndpoint::new(path).with_base_url(base);
235        Endpoint::new().with(http)
236    }
237
238    // ── Endpoint::http ──
239
240    /// P0:[Endpoint::http] 构造仅含 HTTP 能力的端点
241    /// 条件:创建 base_url + path 的 HTTP-only Endpoint
242    /// 断言:base_url() 与 path() 与构造值一致
243    #[test]
244    fn http_constructs_correct_fields() {
245        let e = http_endpoint("https://api.example.com", "/cgi-bin/x");
246        assert_eq!(e.base_url(), "https://api.example.com");
247        assert_eq!(e.path(), "/cgi-bin/x");
248    }
249
250    // ── envelope ──
251
252    /// 测试用自定义请求侧信封(core 只提供 PassthroughReq 默认策略)。
253    #[derive(Debug, Clone, Copy, Default)]
254    struct WrapPayloadReq;
255    impl crate::http::envelope::RequestEnvelope for WrapPayloadReq {
256        fn encode(&self, payload: serde_json::Value) -> serde_json::Value {
257            serde_json::json!({ "payload": payload.to_string() })
258        }
259        fn name(&self) -> &'static str {
260            "wrap-payload"
261        }
262    }
263
264    /// P0:req/res envelope 默认 PassthroughReq / GatewayRes
265    /// 条件:构造 HTTP-only 端点与空端点
266    /// 断言:req_envelope() / res_envelope() 均为默认策略
267    #[test]
268    fn envelope_defaults_to_passthrough_and_gateway() {
269        let b = http_endpoint("https://x.com", "/p");
270        assert_eq!(b.req_envelope().name(), "passthrough");
271        assert_eq!(b.res_envelope().name(), "gateway");
272        assert_eq!(Endpoint::new().req_envelope().name(), "passthrough");
273        assert_eq!(Endpoint::new().res_envelope().name(), "gateway");
274    }
275
276    /// P0:with_req_envelope 仅替换策略,base_url 与 path 不变
277    /// 条件:对已有端点调用 with_req_envelope(WrapPayloadReq)
278    /// 断言:req envelope 为 wrap-payload,res envelope 与 base_url/path 不变
279    #[test]
280    fn with_req_envelope_sets_strategy_only() {
281        let base = http_endpoint("https://x.com", "/service/discovery");
282        let wrapped = base.clone().with_req_envelope(WrapPayloadReq);
283        assert_eq!(wrapped.req_envelope().name(), "wrap-payload");
284        assert_eq!(wrapped.res_envelope().name(), "gateway");
285        assert_eq!(wrapped.base_url(), base.base_url());
286        assert_eq!(wrapped.path(), base.path());
287    }
288
289    /// P1:path 无前导斜杠时自动补齐
290    /// 条件:path 传入 "service/discovery"
291    /// 断言:path() 规范化为 "/service/discovery"
292    #[test]
293    fn http_derives_without_leading_slash() {
294        let e = http_endpoint("", "service/discovery");
295        assert_eq!(e.path(), "/service/discovery");
296    }
297
298    /// P1:空 path 规范化为 `"/"`
299    /// 条件:path 传入空串
300    /// 断言:path() == "/"
301    #[test]
302    fn http_normalizes_empty_path_to_slash() {
303        let e = http_endpoint("", "");
304        assert_eq!(e.path(), "/");
305    }
306    /// P1:path 无前导斜杠时规范化
307    /// 条件:path 传入 "foo/bar"
308    /// 断言:path() == "/foo/bar"
309    #[test]
310    fn http_normalizes_path() {
311        let http = HttpEndpoint::new("foo/bar").with_base_url("https://x.com");
312        let e = Endpoint::new().with(http);
313        assert_eq!(e.path(), "/foo/bar");
314    }
315
316    // ── IntoCowEndpoint ──
317
318    /// P0:[IntoCowEndpoint] `&Endpoint` → `Cow::Borrowed`
319    /// 条件:对 &Endpoint 调用 into_cow_endpoint()
320    /// 断言:结果为 Cow::Borrowed
321    #[test]
322    fn into_cow_endpoint_borrowed() {
323        let e = http_endpoint("https://x.com", "/p");
324        let cow = (&e).into_cow_endpoint();
325        match cow {
326            Cow::Borrowed(_) => {}
327            Cow::Owned(_) => panic!("&Endpoint should yield Cow::Borrowed"),
328        }
329    }
330
331    /// P0:[IntoCowEndpoint] `Endpoint` → `Cow::Owned`
332    /// 条件:对 Endpoint 值调用 into_cow_endpoint()
333    /// 断言:结果为 Cow::Owned
334    #[test]
335    fn into_cow_endpoint_owned() {
336        let e = http_endpoint("https://x.com", "/p");
337        let cow = e.into_cow_endpoint();
338        match cow {
339            Cow::Owned(_) => {}
340            Cow::Borrowed(_) => panic!("Endpoint should yield Cow::Owned"),
341        }
342    }
343
344    /// P0:[IntoCowEndpoint] `Cow::Borrowed` 透传
345    /// 条件:传入 Cow::Borrowed 调用 into_cow_endpoint()
346    /// 断言:结果仍为 Cow::Borrowed
347    #[test]
348    fn into_cow_endpoint_passthrough_borrowed() {
349        let e = http_endpoint("https://x.com", "/p");
350        let cow_in: Cow<'_, Endpoint> = Cow::Borrowed(&e);
351        let cow_out = cow_in.into_cow_endpoint();
352        match cow_out {
353            Cow::Borrowed(_) => {}
354            Cow::Owned(_) => panic!("Cow::Borrowed should pass through as Borrowed"),
355        }
356    }
357
358    /// P0:[IntoCowEndpoint] `Cow::Owned` 透传
359    /// 条件:传入 Cow::Owned 调用 into_cow_endpoint()
360    /// 断言:结果仍为 Cow::Owned
361    #[test]
362    fn into_cow_endpoint_passthrough_owned() {
363        let e = http_endpoint("https://x.com", "/p");
364        let cow_in: Cow<'_, Endpoint> = Cow::Owned(e);
365        let cow_out = cow_in.into_cow_endpoint();
366        match cow_out {
367            Cow::Owned(_) => {}
368            Cow::Borrowed(_) => panic!("Cow::Owned should pass through as Owned"),
369        }
370    }
371
372    // ── Clone ──
373
374    /// P2:[Endpoint] Clone 保留全部能力
375    /// 条件:端点含 HttpEndpoint 与 TestCapability 两种能力后 clone
376    /// 断言:clone 后的两种能力与原值相等
377    #[test]
378    fn clone_preserves_capabilities() {
379        let e = http_endpoint("https://x.com", "/p").with(TestCapability("c".into()));
380        let cloned = e.clone();
381        assert_eq!(
382            e.get::<HttpEndpoint>(),
383            cloned.get::<HttpEndpoint>(),
384            "HttpEndpoint should be equal after clone"
385        );
386        assert_eq!(
387            e.get::<TestCapability>(),
388            cloned.get::<TestCapability>(),
389            "TestCapability should be equal after clone"
390        );
391    }
392
393    /// P2:[Endpoint] 不同 HttpEndpoint 能力不相等
394    /// 条件:构造 path 不同的两个端点
395    /// 断言:两者 get::<HttpEndpoint>() 不相等
396    #[test]
397    fn different_http_endpoints_are_not_equal() {
398        let a = http_endpoint("https://x.com", "/a");
399        let b = http_endpoint("https://x.com", "/b");
400        assert_ne!(a.get::<HttpEndpoint>(), b.get::<HttpEndpoint>());
401    }
402
403    // ── Capability access ──
404
405    /// P0:[Endpoint::with/get] 写入读取 round-trip
406    /// 条件:with(HttpEndpoint) 后 get::<HttpEndpoint>()
407    /// 断言:取回的能力字段与写入一致
408    #[test]
409    fn with_and_get_roundtrip() {
410        let ep = Endpoint::new()
411            .with(HttpEndpoint::new("/test").with_base_url("https://api.example.com"));
412        let http = ep.get::<HttpEndpoint>().unwrap();
413        assert_eq!(http.base_url(), Some("https://api.example.com"));
414        assert_eq!(http.path(), "/test");
415    }
416
417    /// P0:[Endpoint::require] 能力存在时返回 Ok
418    /// 条件:袋中含 HttpEndpoint,以 "test-transport" 调用 require
419    /// 断言:返回 Ok 且字段正确
420    #[test]
421    fn require_returns_ok_when_present() {
422        let ep = Endpoint::new()
423            .with(HttpEndpoint::new("/test").with_base_url("https://api.example.com"));
424        let http = ep.require::<HttpEndpoint>("test-transport").unwrap();
425        assert_eq!(http.base_url(), Some("https://api.example.com"));
426    }
427
428    /// P0:[Endpoint::require] 能力缺失时返回 Err(Config)
429    /// 条件:空袋调用 require::<HttpEndpoint>
430    /// 断言:错误信息包含 transport 名与能力类型名
431    #[test]
432    fn require_returns_config_error_when_missing() {
433        let ep = Endpoint::new();
434        let err = ep.require::<HttpEndpoint>("test-transport").unwrap_err();
435        let msg = format!("{err}");
436        assert!(
437            msg.contains("test-transport"),
438            "error should mention transport name, got: {msg}"
439        );
440        assert!(
441            msg.contains("HttpEndpoint"),
442            "error should mention capability type, got: {msg}"
443        );
444    }
445
446    /// P0:[Endpoint::set] 原地覆盖能力
447    /// 条件:set 新 HttpEndpoint 到已有袋
448    /// 断言:get 返回新值(base_url/path 更新)
449    #[test]
450    fn set_overwrites_capability() {
451        let mut ep = Endpoint::new()
452            .with(HttpEndpoint::new("/old").with_base_url("https://old.example.com"));
453        ep.set(HttpEndpoint::new("/new").with_base_url("https://new.example.com"));
454        let http = ep.get::<HttpEndpoint>().unwrap();
455        assert_eq!(http.base_url(), Some("https://new.example.com"));
456    }
457
458    // ── Endpoint::map ──
459
460    /// P0:[Endpoint::map] 转换存在的能力,保留其余
461    /// 条件:bag 含 HttpEndpoint + TestCapability,用 with_path_derived 改写 path
462    /// 断言:path 更新,base_url 保留,TestCapability 原样保留
463    #[test]
464    fn map_transforms_present_capability_and_keeps_others() {
465        let http = HttpEndpoint::new("/original").with_base_url("https://api.example.com");
466        let ep = Endpoint::new()
467            .with(http)
468            .with(TestCapability("keep".into()))
469            .map::<HttpEndpoint>(|h| h.with_path_derived("/task/query"));
470        assert_eq!(ep.path(), "/task/query");
471        assert_eq!(ep.base_url(), "https://api.example.com");
472        assert_eq!(
473            ep.get::<TestCapability>(),
474            Some(&TestCapability("keep".into())),
475            "non-target capability should be preserved"
476        );
477    }
478
479    /// P1:[Endpoint::map] 目标能力缺失时 no-op
480    /// 条件:bag 仅含 TestCapability,对 HttpEndpoint 调用 map
481    /// 断言:闭包不执行,HttpEndpoint 仍缺失,TestCapability 原样保留
482    #[test]
483    fn map_is_noop_when_capability_absent() {
484        let ep = Endpoint::new()
485            .with(TestCapability("keep".into()))
486            .map::<HttpEndpoint>(|h| {
487                panic!("closure must not run when capability is absent: {h:?}")
488            });
489        assert!(ep.get::<HttpEndpoint>().is_none());
490        assert_eq!(
491            ep.get::<TestCapability>(),
492            Some(&TestCapability("keep".into())),
493            "non-target capability should be preserved"
494        );
495    }
496
497    /// P1:[Endpoint::with] 同类型覆盖(后者胜出)
498    /// 条件:连续 with 两个同类型 HttpEndpoint
499    /// 断言:get 返回第二个(base_url 为 second)
500    #[test]
501    fn with_overwrites_same_type() {
502        let ep = Endpoint::new()
503            .with(HttpEndpoint::new("/a").with_base_url("https://first.example.com"))
504            .with(HttpEndpoint::new("/b").with_base_url("https://second.example.com"));
505        let http = ep.get::<HttpEndpoint>().unwrap();
506        assert_eq!(http.base_url(), Some("https://second.example.com"));
507    }
508
509    // ── Debug ──
510
511    /// P2:[Endpoint::Debug] 能力袋 debug 输出包含能力信息
512    /// 条件:格式化含 HttpEndpoint 的端点 debug 输出
513    /// 断言:输出包含 base_url 内容
514    #[test]
515    fn endpoint_debug_includes_capability_names() {
516        let ep = Endpoint::new()
517            .with(HttpEndpoint::new("/test").with_base_url("https://api.example.com"));
518        let debug_str = format!("{ep:?}");
519        assert!(
520            debug_str.contains("https://api.example.com"),
521            "Debug should include base_url, got: {debug_str}"
522        );
523    }
524}