Skip to main content

webx_core/route/
ext.rs

1//! IServiceCollectionExt — Extension trait for rust_dix ServiceCollection.
2
3use crate::handler::IHostedService;
4use crate::mediator::Mediator;
5use crate::middleware::IMiddleware;
6use crate::pipeline::IPipelineBehavior;
7use rust_dix::{IServiceResolver, ServiceCollection, ServiceLifetime};
8use std::sync::Arc;
9
10/// Extension methods for `rust_dix::ServiceCollection` to enable
11/// framework-level service registration patterns.
12pub trait IServiceCollectionExt: Sized {
13    /// Register the mediator in the DI container.
14    ///
15    /// The actual `IRequestHandler` and `IEventHandler` implementations
16    /// are registered via `#[rust_dix::module]` blocks (re-exported as `webx::module`).
17    fn add_mediator(self) -> Self;
18
19    /// Signal that endpoints should be auto-discovered from `#[get]` / `#[endpoint]`
20    /// annotations at compile time.  The actual route table is built during
21    /// `Host::build()` by scanning the inventory for `RouteEntry` items.
22    fn add_request_endpoints(self) -> Self;
23
24    /// Register controller types scoped per request.
25    fn add_controllers(self) -> Self;
26
27    /// Register a middleware type in the DI container as a singleton.
28    fn add_middleware<T>(self) -> Self
29    where
30        T: IMiddleware + Default + Send + Sync + 'static;
31
32    /// Register a **typed** pipeline behavior in the DI container as a singleton.
33    ///
34    /// `B` declares the request/response types by implementing
35    /// [`IPipelineBehavior<Req, Rsp>`]; register those same types here:
36    ///
37    /// ```ignore
38    /// #[derive(Default)]
39    /// struct AuditBehavior;
40    ///
41    /// #[async_trait::async_trait]
42    /// impl IPipelineBehavior<CreateOrderRequest, OrderView> for AuditBehavior {
43    ///     async fn handle(&self, req: CreateOrderRequest, next: Next<CreateOrderRequest, OrderView>)
44    ///         -> Result<OrderView> { next.run(req).await }
45    /// }
46    ///
47    /// svc.add_typed_pipeline::<AuditBehavior, CreateOrderRequest, OrderView>();
48    /// ```
49    ///
50    /// The behavior only runs for that request/response pair. Short-circuiting is
51    /// type-checked: return `Rsp` without calling [`Next::run`].
52    fn add_typed_pipeline<B, Req, Rsp>(self) -> Self
53    where
54        B: IPipelineBehavior<Req, Rsp> + Default + 'static,
55        Req: Send + 'static,
56        Rsp: Send + 'static;
57
58    /// Register a typed pipeline behavior built from a factory (singleton).
59    ///
60    /// The factory receives the DI resolver at construction time, so the
61    /// behavior can take constructor dependencies. It runs once per provider;
62    /// the resolver is **not** available inside `handle`.
63    fn add_typed_pipeline_with<F, Req, Rsp>(self, factory: F) -> Self
64    where
65        F: Fn(&dyn IServiceResolver) -> Arc<dyn IPipelineBehavior<Req, Rsp>>
66            + Send
67            + Sync
68            + 'static,
69        Req: Send + 'static,
70        Rsp: Send + 'static;
71
72    /// Register a hosted service in the DI container as a singleton.
73    ///
74    /// Hosted services are started when the host starts (before accepting
75    /// requests) and stopped during graceful shutdown.
76    ///
77    /// Analogous to ASP.NET Core's `AddHostedService<T>()`.
78    ///
79    /// # Example
80    ///
81    /// ```ignore
82    /// Host::builder()
83    ///     .register(|svc| {
84    ///         svc.add_hosted_service::<DbInitService>()
85    ///     })
86    ///     .build()
87    ///     .run()
88    ///     .await
89    /// ```
90    fn add_hosted_service<T>(self) -> Self
91    where
92        T: IHostedService + Default + Send + Sync + 'static;
93}
94
95impl IServiceCollectionExt for ServiceCollection {
96    fn add_mediator(self) -> Self {
97        // Transient (not singleton) so build-time validation does not require a
98        // fully wired ServiceProvider. Each resolution shares the root provider
99        // Arc; the handler registry is registered by `Host::build` as an
100        // instance (or rebuilt when the mediator is used without a host).
101        MEDIATOR_ACTIVE.store(true, std::sync::atomic::Ordering::SeqCst);
102        self.transient::<Mediator>(|resolver| {
103            let provider = resolver
104                .provider_arc()
105                .expect("ServiceProvider not available from DI resolver");
106            let handler_cache = resolver
107                .get_any(std::any::type_name::<Arc<crate::route::scan::HandlerCache>>())
108                .and_then(|service| {
109                    service
110                        .downcast::<Arc<crate::route::scan::HandlerCache>>()
111                        .ok()
112                })
113                .map(|service| Arc::clone(&*service))
114                .unwrap_or_else(|| Arc::new(crate::route::scan::HandlerCache::build()));
115            Arc::new(Mediator::new(provider, handler_cache))
116        })
117    }
118
119    fn add_request_endpoints(self) -> Self {
120        // Routes are collected from inventory at Host::build() time.
121        // This call just signals that auto-scanning is desired.
122        ENDPOINT_SCAN.store(true, std::sync::atomic::Ordering::SeqCst);
123        self
124    }
125
126    fn add_controllers(self) -> Self {
127        self
128    }
129
130    fn add_middleware<T>(self) -> Self
131    where
132        T: IMiddleware + Default + Send + Sync + 'static,
133    {
134        self.singleton::<dyn IMiddleware>(|_| Arc::new(T::default()))
135    }
136
137    fn add_typed_pipeline<B, Req, Rsp>(self) -> Self
138    where
139        B: IPipelineBehavior<Req, Rsp> + Default + 'static,
140        Req: Send + 'static,
141        Rsp: Send + 'static,
142    {
143        self.add_typed_pipeline_with::<_, Req, Rsp>(|_| {
144            Arc::new(B::default()) as Arc<dyn IPipelineBehavior<Req, Rsp>>
145        })
146    }
147
148    fn add_typed_pipeline_with<F, Req, Rsp>(self, factory: F) -> Self
149    where
150        F: Fn(&dyn IServiceResolver) -> Arc<dyn IPipelineBehavior<Req, Rsp>>
151            + Send
152            + Sync
153            + 'static,
154        Req: Send + 'static,
155        Rsp: Send + 'static,
156    {
157        // Singleton per (Req, Rsp) pair: the factory (and therefore constructor
158        // injection) runs once per provider; behaviors are stateless wrappers.
159        self.add(ServiceLifetime::Singleton, factory)
160    }
161
162    fn add_hosted_service<T>(self) -> Self
163    where
164        T: IHostedService + Default + Send + Sync + 'static,
165    {
166        self.singleton::<dyn IHostedService>(|_| Arc::new(T::default()))
167    }
168}
169
170// Global flags so HostBuilder knows what to scan at build time.
171use std::sync::atomic::AtomicBool;
172static ENDPOINT_SCAN: AtomicBool = AtomicBool::new(false);
173static MEDIATOR_ACTIVE: AtomicBool = AtomicBool::new(false);
174
175pub fn should_scan_endpoints() -> bool {
176    ENDPOINT_SCAN.load(std::sync::atomic::Ordering::SeqCst)
177}
178
179pub fn is_mediator_active() -> bool {
180    MEDIATOR_ACTIVE.load(std::sync::atomic::Ordering::SeqCst)
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::pipeline::{IPipelineBehavior, Next};
187    use std::sync::atomic::{AtomicU32, Ordering};
188
189    /// Resolve a registered singleton from the type-erased resolver the
190    /// registration factories receive.
191    fn resolve<T: Send + Sync + 'static>(resolver: &dyn IServiceResolver) -> Arc<T> {
192        resolver
193            .get_any(std::any::type_name::<T>())
194            .and_then(|service| service.downcast::<Arc<T>>().ok())
195            .map(|service| Arc::clone(&*service))
196            .expect("the dependency must be registered before the pipeline")
197    }
198
199    // ----- typed behaviors (0.10) -----
200
201    #[derive(Default)]
202    struct Uppercase;
203
204    #[async_trait::async_trait]
205    impl IPipelineBehavior<u32, String> for Uppercase {
206        async fn handle(&self, req: u32, next: Next<u32, String>) -> crate::error::Result<String> {
207            let inner = next.run(req + 1).await?;
208            Ok(inner.to_uppercase())
209        }
210    }
211
212    fn string_terminal() -> Next<u32, String> {
213        Next::new(|n: u32| async move { Ok::<String, crate::error::Error>(format!("n={n}")) })
214    }
215
216    #[tokio::test]
217    async fn typed_behavior_registers_only_for_its_request_response_pair() {
218        let provider = ServiceCollection::new()
219            .add_typed_pipeline::<Uppercase, u32, String>()
220            .build()
221            .expect("provider must build");
222
223        let behaviors = provider.get_all::<dyn IPipelineBehavior<u32, String>>();
224        assert_eq!(behaviors.len(), 1);
225        let out = behaviors[0]
226            .handle(7, string_terminal())
227            .await
228            .expect("typed behavior must run");
229        assert_eq!(out, "N=8");
230
231        assert!(
232            provider
233                .get_all::<dyn IPipelineBehavior<u64, String>>()
234                .is_empty(),
235            "a typed behavior must only attach to its own request/response pair"
236        );
237    }
238
239    struct TickBehavior {
240        ticks: Arc<AtomicU32>,
241    }
242
243    #[async_trait::async_trait]
244    impl IPipelineBehavior<u32, u32> for TickBehavior {
245        async fn handle(&self, req: u32, next: Next<u32, u32>) -> crate::error::Result<u32> {
246            self.ticks.fetch_add(1, Ordering::SeqCst);
247            next.run(req).await
248        }
249    }
250
251    #[tokio::test]
252    async fn typed_add_pipeline_with_constructor_injects_from_the_resolver() {
253        let ticks = Arc::new(AtomicU32::new(0));
254
255        let provider = ServiceCollection::new()
256            .instance(Arc::clone(&ticks))
257            .add_typed_pipeline_with(|resolver| {
258                let ticks: Arc<AtomicU32> = resolve::<AtomicU32>(resolver);
259                Arc::new(TickBehavior { ticks }) as Arc<dyn IPipelineBehavior<u32, u32>>
260            })
261            .build()
262            .expect("provider must build");
263
264        let behavior = &provider.get_all::<dyn IPipelineBehavior<u32, u32>>()[0];
265        let out = behavior
266            .handle(5, Next::new(|n: u32| async move { Ok(n * 2) }))
267            .await
268            .expect("typed behavior must run");
269        assert_eq!(out, 10);
270        assert_eq!(ticks.load(Ordering::SeqCst), 1);
271    }
272
273    struct ShortCircuit;
274
275    #[async_trait::async_trait]
276    impl IPipelineBehavior<u32, String> for ShortCircuit {
277        async fn handle(
278            &self,
279            _req: u32,
280            _next: Next<u32, String>,
281        ) -> crate::error::Result<String> {
282            Ok("short".to_string())
283        }
284    }
285
286    #[tokio::test]
287    async fn try_add_keeps_the_existing_registration() {
288        let provider = ServiceCollection::new()
289            .instance(Arc::new(1u32))
290            .try_add(|_| Arc::new(2u32))
291            .build()
292            .expect("provider must build");
293
294        let value = provider.get::<u32>().expect("registered");
295        assert_eq!(
296            *value, 1,
297            "try_add must not replace an earlier registration (MEDI TryAdd); rust-dix 0.8+ resolves the last registration otherwise"
298        );
299    }
300
301    #[tokio::test]
302    async fn typed_behavior_may_short_circuit_with_its_response_type() {
303        let out = ShortCircuit
304            .handle(1, string_terminal())
305            .await
306            .expect("short-circuit must return the typed response");
307        assert_eq!(out, "short");
308    }
309}