Skip to main content

toolkit/
client_hub.rs

1//! Minimalistic, type-safe `ClientHub`.
2//!
3//! Design goals:
4//! - Providers register an implementation once (local or remote).
5//! - Consumers fetch by *interface type* (trait object): `get::<dyn my::Api>()`.
6//! - For plugin-like scenarios, multiple implementations of the same interface can coexist
7//!   under different scopes (e.g. selected by GTS instance ID).
8//!
9//! Implementation details:
10//! - Key = type name. We use `type_name::<T>()`, which works for `T = dyn Trait`.
11//! - Value = `Arc<T>` stored as `Box<dyn Any + Send + Sync>` (downcast on read).
12//! - Sync hot path: `get()` is non-async; no hidden per-entry cells or lazy slots.
13//!
14//! Notes:
15//! - Re-registering overwrites the previous value atomically; existing Arcs held by consumers remain valid.
16//! - For testing, just register a mock under the same trait type.
17
18use parking_lot::RwLock;
19use std::{any::Any, collections::HashMap, fmt, sync::Arc};
20
21/// Stable type key for trait objects — uses fully-qualified `type_name::<T>()`.
22#[derive(Clone, Eq, PartialEq, Hash)]
23pub struct TypeKey(&'static str);
24
25impl TypeKey {
26    #[inline]
27    fn of<T: ?Sized + 'static>() -> Self {
28        TypeKey(std::any::type_name::<T>())
29    }
30}
31
32impl fmt::Debug for TypeKey {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        f.write_str(self.0)
35    }
36}
37
38/// A scope for resolving multiple implementations of the same interface type.
39///
40/// This is intentionally opaque: the scope semantics are defined by the caller.
41/// One common scope is a full GTS entity/instance ID.
42#[derive(Clone, Eq, PartialEq, Hash)]
43pub struct ClientScope(Arc<str>);
44
45impl ClientScope {
46    /// Create a new scope from an arbitrary string.
47    #[inline]
48    #[must_use]
49    pub fn new(scope: impl Into<Arc<str>>) -> Self {
50        Self(scope.into())
51    }
52
53    /// Create a scope derived from a GTS identifier.
54    ///
55    /// Internally we prefix the scope to avoid accidental collisions with other scope kinds.
56    #[must_use]
57    pub fn gts_id(gts_id: &str) -> Self {
58        let mut s = String::with_capacity("gts:".len() + gts_id.len());
59        s.push_str("gts:");
60        s.push_str(gts_id);
61        Self(Arc::<str>::from(s))
62    }
63
64    #[inline]
65    #[must_use]
66    pub fn as_str(&self) -> &str {
67        &self.0
68    }
69}
70
71impl fmt::Debug for ClientScope {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        f.write_str(self.as_str())
74    }
75}
76
77#[derive(Clone, Eq, PartialEq, Hash)]
78struct ScopedKey {
79    type_key: TypeKey,
80    scope: ClientScope,
81}
82
83impl fmt::Debug for ScopedKey {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        f.debug_struct("ScopedKey")
86            .field("type_key", &self.type_key)
87            .field("scope", &self.scope)
88            .finish()
89    }
90}
91
92#[derive(Debug, thiserror::Error)]
93pub enum ClientHubError {
94    #[error("client not found: type={type_key:?}")]
95    NotFound { type_key: TypeKey },
96
97    #[error("type mismatch in hub for type={type_key:?}")]
98    TypeMismatch { type_key: TypeKey },
99
100    #[error("scoped client not found: type={type_key:?} scope={scope:?}")]
101    ScopedNotFound {
102        type_key: TypeKey,
103        scope: ClientScope,
104    },
105
106    #[error("type mismatch in hub for type={type_key:?} scope={scope:?}")]
107    ScopedTypeMismatch {
108        type_key: TypeKey,
109        scope: ClientScope,
110    },
111}
112
113type Boxed = Box<dyn Any + Send + Sync>;
114
115/// Internal map type for the client hub.
116type ClientMap = HashMap<TypeKey, Boxed>;
117
118/// Internal map type for the scoped client hub.
119type ScopedClientMap = HashMap<ScopedKey, Boxed>;
120
121/// Type-safe registry of clients keyed by interface type.
122#[derive(Default)]
123pub struct ClientHub {
124    map: RwLock<ClientMap>,
125    scoped_map: RwLock<ScopedClientMap>,
126    /// Types registered by consumer wiring as a discovery-resolving remote
127    /// proxy rather than a genuine in-process implementation.
128    ///
129    /// The hub is keyed by type alone, so a proxy and a local impl are
130    /// indistinguishable once stored. Consumer wiring needs to tell them apart:
131    /// finding a *proxy* under a contract does not mean the dependency is
132    /// satisfied locally, and treating it as such marks the dependency
133    /// readiness-resolved while the provider is still absent.
134    remote_proxies: RwLock<std::collections::HashSet<TypeKey>>,
135}
136
137impl ClientHub {
138    #[inline]
139    #[must_use]
140    pub fn new() -> Self {
141        Self {
142            map: RwLock::new(HashMap::new()),
143            scoped_map: RwLock::new(HashMap::new()),
144            remote_proxies: RwLock::new(std::collections::HashSet::new()),
145        }
146    }
147}
148
149impl ClientHub {
150    /// Register a client under the interface type `T`.
151    /// `T` can be a trait object like `dyn my_gear::api::MyClient`.
152    ///
153    /// Clears any remote-proxy mark for `T`, so a local impl registered after a
154    /// directory proxy wins in [`Self::try_get_local`] rather than being shadowed.
155    pub fn register<T>(&self, client: Arc<T>)
156    where
157        T: ?Sized + Send + Sync + 'static,
158    {
159        let type_key = TypeKey::of::<T>();
160        self.remote_proxies.write().remove(&type_key);
161        let mut w = self.map.write();
162        w.insert(type_key, Box::new(client));
163    }
164
165    /// Register a scoped client under the interface type `T`.
166    ///
167    /// This enables multiple implementations of the same interface to coexist,
168    /// distinguished by a caller-defined `ClientScope` (e.g., a GTS instance ID).
169    pub fn register_scoped<T>(&self, scope: ClientScope, client: Arc<T>)
170    where
171        T: ?Sized + Send + Sync + 'static,
172    {
173        let key = ScopedKey {
174            type_key: TypeKey::of::<T>(),
175            scope,
176        };
177        let mut w = self.scoped_map.write();
178        w.insert(key, Box::new(client));
179    }
180
181    /// Fetch a client by interface type `T`.
182    ///
183    /// # Errors
184    /// Returns `ClientHubError::NotFound` if no client is registered for the type.
185    /// Returns `ClientHubError::TypeMismatch` if the stored type doesn't match.
186    pub fn get<T>(&self) -> Result<Arc<T>, ClientHubError>
187    where
188        T: ?Sized + Send + Sync + 'static,
189    {
190        let type_key = TypeKey::of::<T>();
191        let r = self.map.read();
192
193        let boxed = r.get(&type_key).ok_or(ClientHubError::NotFound {
194            type_key: type_key.clone(),
195        })?;
196
197        // Stored value is exactly `Arc<T>`; downcast is safe and cheap.
198        if let Some(arc_t) = boxed.downcast_ref::<Arc<T>>() {
199            return Ok(arc_t.clone());
200        }
201        Err(ClientHubError::TypeMismatch { type_key })
202    }
203
204    /// Try to fetch a client by interface type `T`.
205    ///
206    /// Returns `None` if no client is registered for the type or if the stored
207    /// type doesn't match. This sees every registration, proxies included — use
208    /// [`Self::try_get_local`] when the answer needs to mean "satisfied
209    /// in-process".
210    pub fn try_get<T>(&self) -> Option<Arc<T>>
211    where
212        T: ?Sized + Send + Sync + 'static,
213    {
214        let type_key = TypeKey::of::<T>();
215        let r = self.map.read();
216        let boxed = r.get(&type_key)?;
217        boxed.downcast_ref::<Arc<T>>().cloned()
218    }
219
220    /// Register a client that is a discovery-resolving proxy to a *remote*
221    /// provider, not an in-process implementation.
222    ///
223    /// Stored exactly like [`Self::register`]; the difference is that the type
224    /// is remembered as remote so [`Self::try_get_local`] will not report it as
225    /// a local implementation.
226    pub fn register_remote_proxy<T>(&self, client: Arc<T>)
227    where
228        T: ?Sized + Send + Sync + 'static,
229    {
230        let type_key = TypeKey::of::<T>();
231        self.remote_proxies.write().insert(type_key.clone());
232        self.map.write().insert(type_key, Box::new(client));
233    }
234
235    /// Try to fetch a client by interface type `T`, ignoring remote proxies.
236    ///
237    /// This is the short-circuit probe for consumer wiring: a compile-time
238    /// (local) registration takes priority over a discovery-based proxy, so the
239    /// wiring closure asks whether a *local* implementation exists before
240    /// registering one of its own.
241    ///
242    /// Using plain [`Self::try_get`] here would be wrong when two consumers in
243    /// one process depend on the same contract: whichever wires second would
244    /// find the first one's proxy, conclude the dependency is local, and mark
245    /// it readiness-resolved — turning `/readyz` green before the provider
246    /// exists.
247    pub fn try_get_local<T>(&self) -> Option<Arc<T>>
248    where
249        T: ?Sized + Send + Sync + 'static,
250    {
251        let type_key = TypeKey::of::<T>();
252        if self.remote_proxies.read().contains(&type_key) {
253            return None;
254        }
255        let r = self.map.read();
256        let boxed = r.get(&type_key)?;
257        boxed.downcast_ref::<Arc<T>>().cloned()
258    }
259
260    /// Whether a remote proxy is already registered for interface type `T`.
261    ///
262    /// Lets consumer wiring reuse an existing proxy instead of building and
263    /// registering a second identical one for the same contract.
264    #[must_use]
265    pub fn has_remote_proxy<T>(&self) -> bool
266    where
267        T: ?Sized + Send + Sync + 'static,
268    {
269        self.remote_proxies.read().contains(&TypeKey::of::<T>())
270    }
271
272    /// Fetch a scoped client by interface type `T` and scope.
273    ///
274    /// # Errors
275    /// Returns `ClientHubError::ScopedNotFound` if no client is registered for the `(type, scope)` pair.
276    /// Returns `ClientHubError::ScopedTypeMismatch` if the stored type doesn't match.
277    pub fn get_scoped<T>(&self, scope: &ClientScope) -> Result<Arc<T>, ClientHubError>
278    where
279        T: ?Sized + Send + Sync + 'static,
280    {
281        let key = ScopedKey {
282            type_key: TypeKey::of::<T>(),
283            scope: scope.clone(),
284        };
285        let r = self.scoped_map.read();
286
287        let boxed = r.get(&key).ok_or_else(|| ClientHubError::ScopedNotFound {
288            type_key: key.type_key.clone(),
289            scope: key.scope.clone(),
290        })?;
291
292        if let Some(arc_t) = boxed.downcast_ref::<Arc<T>>() {
293            return Ok(arc_t.clone());
294        }
295        Err(ClientHubError::ScopedTypeMismatch {
296            type_key: key.type_key,
297            scope: key.scope,
298        })
299    }
300
301    /// Try to fetch a scoped client by interface type `T` and scope.
302    ///
303    /// Returns `None` if not found or if the stored type doesn't match.
304    pub fn try_get_scoped<T>(&self, scope: &ClientScope) -> Option<Arc<T>>
305    where
306        T: ?Sized + Send + Sync + 'static,
307    {
308        let key = ScopedKey {
309            type_key: TypeKey::of::<T>(),
310            scope: scope.clone(),
311        };
312        let r = self.scoped_map.read();
313        let boxed = r.get(&key)?;
314
315        boxed.downcast_ref::<Arc<T>>().cloned()
316    }
317
318    /// Remove a client by interface type; returns the removed client if it was present.
319    pub fn remove<T>(&self) -> Option<Arc<T>>
320    where
321        T: ?Sized + Send + Sync + 'static,
322    {
323        let type_key = TypeKey::of::<T>();
324        let mut w = self.map.write();
325        let boxed = w.remove(&type_key)?;
326        boxed.downcast::<Arc<T>>().ok().map(|b| *b)
327    }
328
329    /// Remove a scoped client by interface type + scope; returns the removed client if it was present.
330    pub fn remove_scoped<T>(&self, scope: &ClientScope) -> Option<Arc<T>>
331    where
332        T: ?Sized + Send + Sync + 'static,
333    {
334        let key = ScopedKey {
335            type_key: TypeKey::of::<T>(),
336            scope: scope.clone(),
337        };
338        let mut w = self.scoped_map.write();
339        let boxed = w.remove(&key)?;
340        boxed.downcast::<Arc<T>>().ok().map(|b| *b)
341    }
342
343    /// Clear everything (useful in tests).
344    pub fn clear(&self) {
345        self.map.write().clear();
346        self.scoped_map.write().clear();
347    }
348
349    /// Introspection: (total entries).
350    pub fn len(&self) -> usize {
351        self.map.read().len() + self.scoped_map.read().len()
352    }
353
354    /// Check if the hub is empty.
355    pub fn is_empty(&self) -> bool {
356        self.map.read().is_empty() && self.scoped_map.read().is_empty()
357    }
358}
359
360#[cfg(test)]
361#[cfg_attr(coverage_nightly, coverage(off))]
362mod tests {
363    use super::*;
364    use toolkit_gts::gts_id;
365
366    #[async_trait::async_trait]
367    trait TestApi: Send + Sync {
368        async fn id(&self) -> usize;
369    }
370
371    struct ImplA(usize);
372    #[async_trait::async_trait]
373    impl TestApi for ImplA {
374        async fn id(&self) -> usize {
375            self.0
376        }
377    }
378
379    #[tokio::test]
380    async fn register_and_get_dyn_trait() {
381        let hub = ClientHub::new();
382        let api: Arc<dyn TestApi> = Arc::new(ImplA(7));
383        hub.register::<dyn TestApi>(api.clone());
384
385        let got = hub.get::<dyn TestApi>().unwrap();
386        assert_eq!(got.id().await, 7);
387        assert_eq!(Arc::as_ptr(&api), Arc::as_ptr(&got));
388    }
389
390    #[tokio::test]
391    async fn remove_works() {
392        let hub = ClientHub::new();
393        let api: Arc<dyn TestApi> = Arc::new(ImplA(42));
394        hub.register::<dyn TestApi>(api);
395
396        assert!(hub.get::<dyn TestApi>().is_ok());
397
398        let removed = hub.remove::<dyn TestApi>();
399        assert!(removed.is_some());
400        assert!(hub.get::<dyn TestApi>().is_err());
401    }
402
403    #[tokio::test]
404    async fn try_get_local_ignores_a_remote_proxy() {
405        let hub = ClientHub::new();
406        let proxy: Arc<dyn TestApi> = Arc::new(ImplA(1));
407        hub.register_remote_proxy::<dyn TestApi>(proxy);
408
409        // Reachable for business calls...
410        assert!(hub.try_get::<dyn TestApi>().is_some());
411        assert!(hub.get::<dyn TestApi>().is_ok());
412        // ...but not a local implementation. This is what stops a second
413        // consumer of the same contract from reporting `WireOutcome::Local`
414        // and marking the dependency readiness-resolved while the provider is
415        // still absent.
416        assert!(hub.try_get_local::<dyn TestApi>().is_none());
417        assert!(hub.has_remote_proxy::<dyn TestApi>());
418    }
419
420    #[tokio::test]
421    async fn try_get_local_sees_a_plain_registration() {
422        let hub = ClientHub::new();
423        let local: Arc<dyn TestApi> = Arc::new(ImplA(2));
424        hub.register::<dyn TestApi>(local);
425
426        assert!(hub.try_get_local::<dyn TestApi>().is_some());
427        assert!(!hub.has_remote_proxy::<dyn TestApi>());
428    }
429
430    #[tokio::test]
431    async fn a_late_local_registration_clears_the_remote_mark_and_wins() {
432        // A consumer wires a directory proxy under the contract first...
433        let hub = ClientHub::new();
434        hub.register_remote_proxy::<dyn TestApi>(Arc::new(ImplA(1)));
435        assert!(hub.has_remote_proxy::<dyn TestApi>());
436        assert!(hub.try_get_local::<dyn TestApi>().is_none());
437
438        // ...then the real in-process provider registers its local impl. It must
439        // win: the remote mark is cleared and `try_get_local` now sees the impl.
440        hub.register::<dyn TestApi>(Arc::new(ImplA(2)));
441        assert!(!hub.has_remote_proxy::<dyn TestApi>());
442        let local = hub
443            .try_get_local::<dyn TestApi>()
444            .expect("the late local registration must be visible as local");
445        assert_eq!(local.id().await, 2);
446    }
447
448    #[tokio::test]
449    async fn overwrite_replaces_atomically() {
450        let hub = ClientHub::new();
451        hub.register::<dyn TestApi>(Arc::new(ImplA(1)));
452
453        let old = hub.get::<dyn TestApi>().unwrap();
454        assert_eq!(old.id().await, 1);
455
456        hub.register::<dyn TestApi>(Arc::new(ImplA(2)));
457
458        let new = hub.get::<dyn TestApi>().unwrap();
459        assert_eq!(new.id().await, 2);
460
461        // Old Arc is still valid
462        assert_eq!(old.id().await, 1);
463    }
464
465    #[tokio::test]
466    async fn scoped_register_and_get_dyn_trait() {
467        let hub = ClientHub::new();
468        let scope_a = ClientScope::gts_id(gts_id!(
469            "cf.core.toolkit.plugins.v1~cf.core.tenant_resolver.plugin.v1~contoso.app._.plugin.v1.0"
470        ));
471        let scope_b = ClientScope::gts_id(gts_id!(
472            "cf.core.toolkit.plugins.v1~cf.core.tenant_resolver.plugin.v1~fabrikam.app._.plugin.v1.0"
473        ));
474
475        let api_a: Arc<dyn TestApi> = Arc::new(ImplA(1));
476        let api_b: Arc<dyn TestApi> = Arc::new(ImplA(2));
477
478        hub.register_scoped::<dyn TestApi>(scope_a.clone(), api_a.clone());
479        hub.register_scoped::<dyn TestApi>(scope_b.clone(), api_b.clone());
480
481        assert_eq!(
482            hub.get_scoped::<dyn TestApi>(&scope_a).unwrap().id().await,
483            1
484        );
485        assert_eq!(
486            hub.get_scoped::<dyn TestApi>(&scope_b).unwrap().id().await,
487            2
488        );
489    }
490
491    #[test]
492    fn scoped_get_is_independent_from_global_get() {
493        let hub = ClientHub::new();
494        let scope = ClientScope::gts_id(gts_id!(
495            "cf.core.toolkit.plugins.v1~cf.core.tenant_resolver.plugin.v1~fabrikam.app._.plugin.v1.0"
496        ));
497        hub.register::<str>(Arc::from("global"));
498        hub.register_scoped::<str>(scope.clone(), Arc::from("scoped"));
499
500        assert_eq!(&*hub.get::<str>().unwrap(), "global");
501        assert_eq!(&*hub.get_scoped::<str>(&scope).unwrap(), "scoped");
502    }
503
504    #[test]
505    fn try_get_scoped_returns_some_on_hit() {
506        let hub = ClientHub::new();
507        let scope = ClientScope::gts_id(gts_id!(
508            "cf.core.toolkit.plugins.v1~cf.core.tenant_resolver.plugin.v1~contoso.app._.plugin.v1.0"
509        ));
510        hub.register_scoped::<str>(scope.clone(), Arc::from("scoped"));
511
512        let got = hub.try_get_scoped::<str>(&scope);
513        assert_eq!(got.as_deref(), Some("scoped"));
514    }
515
516    #[test]
517    fn try_get_scoped_returns_none_on_miss() {
518        let hub = ClientHub::new();
519        let scope = ClientScope::gts_id(gts_id!(
520            "cf.core.toolkit.plugins.v1~cf.core.tenant_resolver.plugin.v1~fabrikam.app._.plugin.v1.0"
521        ));
522
523        let got = hub.try_get_scoped::<str>(&scope);
524        assert!(got.is_none());
525    }
526}