wecomx_transport/common/
endpoint.rs1use std::any::{Any, TypeId};
46use std::borrow::Cow;
47use std::collections::HashMap;
48use std::fmt::Debug;
49
50pub trait EndpointExt: Any + Debug + Send + Sync + 'static {
57 fn as_any(&self) -> &dyn Any;
58 fn clone_box(&self) -> Box<dyn EndpointExt>;
59 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#[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 pub fn new() -> Self {
112 Self::default()
113 }
114
115 #[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 pub fn set<T: EndpointExt>(&mut self, cap: T) {
125 self.ext.insert(TypeId::of::<T>(), Box::new(cap));
126 }
127
128 #[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 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 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
169pub 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#[cfg(test)]
204mod tests {
205 use super::*;
225 use crate::HttpEndpoint;
226 use crate::http::EndpointHttpExt;
227
228 #[derive(Clone, Debug, PartialEq, Eq)]
230 struct TestCapability(String);
231
232 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 #[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 #[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 #[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 #[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 #[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 #[test]
302 fn http_normalizes_empty_path_to_slash() {
303 let e = http_endpoint("", "");
304 assert_eq!(e.path(), "/");
305 }
306 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}