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 pipeline behavior in the DI container as a singleton.
33    ///
34    /// Requires `Default`, so it is only usable for behaviors without
35    /// dependencies. Use [`IServiceCollectionExt::add_pipeline_with`] when the
36    /// behavior needs anything from the container.
37    fn add_pipeline<T>(self) -> Self
38    where
39        T: IPipelineBehavior + Default + Send + Sync + 'static;
40
41    /// Register a pipeline behavior built from a factory as a singleton.
42    ///
43    /// Unlike [`IServiceCollectionExt::add_pipeline`], the factory receives the
44    /// DI resolver, so the behavior can take constructor dependencies — exactly
45    /// what the MediatR-style "constructor injection" for pipeline behaviors
46    /// means here. The factory runs once (singleton), on first resolution /
47    /// build-time validation; the resolver is **not** available inside
48    /// [`IPipelineBehavior::handle`].
49    ///
50    /// The factory is also executed during `build()` (cycle and captive-dependency
51    /// validation), so it must resolve dependencies through the resolver it is
52    /// given — `resolver.provider_arc()` is `None` there. `&dyn IServiceResolver`
53    /// is object-safe, so `get`/`get_owned` (which require a sized resolver) are
54    /// unavailable; resolve type-erased instead:
55    ///
56    /// ```ignore
57    /// use std::sync::Arc;
58    /// use webx::{IServiceCollectionExt, IPipelineBehavior};
59    ///
60    /// svc.add_pipeline_with(|resolver| {
61    ///     let clock: Arc<Clock> = resolver
62    ///         .get_any(std::any::type_name::<Clock>())
63    ///         .and_then(|service| service.downcast::<Arc<Clock>>().ok())
64    ///         .map(|service| Arc::clone(&*service))
65    ///         .expect("register Clock before the pipeline");
66    ///     Arc::new(TimingBehavior { clock }) as Arc<dyn IPipelineBehavior>
67    /// })
68    /// ```
69    ///
70    /// Capturing the dependency when registering is equally valid (and shorter)
71    /// when the value already exists outside the container:
72    ///
73    /// ```ignore
74    /// let clock = Arc::clone(&clock);
75    /// svc.add_pipeline_with(move |_| Arc::new(TimingBehavior { clock }))
76    /// ```
77    fn add_pipeline_with<F>(self, factory: F) -> Self
78    where
79        F: Fn(&dyn IServiceResolver) -> Arc<dyn IPipelineBehavior> + Send + Sync + 'static;
80
81    /// Register a hosted service in the DI container as a singleton.
82    ///
83    /// Hosted services are started when the host starts (before accepting
84    /// requests) and stopped during graceful shutdown.
85    ///
86    /// Analogous to ASP.NET Core's `AddHostedService<T>()`.
87    ///
88    /// # Example
89    ///
90    /// ```ignore
91    /// Host::builder()
92    ///     .register(|svc| {
93    ///         svc.add_hosted_service::<DbInitService>()
94    ///     })
95    ///     .build()
96    ///     .run()
97    ///     .await
98    /// ```
99    fn add_hosted_service<T>(self) -> Self
100    where
101        T: IHostedService + Default + Send + Sync + 'static;
102}
103
104impl IServiceCollectionExt for ServiceCollection {
105    fn add_mediator(self) -> Self {
106        // Transient (not singleton) so build-time validation does not require a
107        // fully wired ServiceProvider. Each resolution shares the root provider Arc.
108        MEDIATOR_ACTIVE.store(true, std::sync::atomic::Ordering::SeqCst);
109        self.transient::<Mediator>(|resolver| {
110            let provider = resolver
111                .provider_arc()
112                .expect("ServiceProvider not available from DI resolver");
113            Arc::new(Mediator::new(provider))
114        })
115    }
116
117    fn add_request_endpoints(self) -> Self {
118        // Routes are collected from inventory at Host::build() time.
119        // This call just signals that auto-scanning is desired.
120        ENDPOINT_SCAN.store(true, std::sync::atomic::Ordering::SeqCst);
121        self
122    }
123
124    fn add_controllers(self) -> Self {
125        self
126    }
127
128    fn add_middleware<T>(self) -> Self
129    where
130        T: IMiddleware + Default + Send + Sync + 'static,
131    {
132        self.singleton::<dyn IMiddleware>(|_| Arc::new(T::default()))
133    }
134
135    fn add_pipeline<T>(self) -> Self
136    where
137        T: IPipelineBehavior + Default + Send + Sync + 'static,
138    {
139        self.add_pipeline_with(|_| Arc::new(T::default()) as Arc<dyn IPipelineBehavior>)
140    }
141
142    fn add_pipeline_with<F>(self, factory: F) -> Self
143    where
144        F: Fn(&dyn IServiceResolver) -> Arc<dyn IPipelineBehavior> + Send + Sync + 'static,
145    {
146        // Singleton: the factory (and therefore constructor injection) runs once
147        // per provider; behaviors are stateless wrappers over the request chain.
148        self.add(ServiceLifetime::Singleton, factory)
149    }
150    fn add_hosted_service<T>(self) -> Self
151    where
152        T: IHostedService + Default + Send + Sync + 'static,
153    {
154        self.singleton::<dyn IHostedService>(|_| Arc::new(T::default()))
155    }
156}
157
158// Global flags so HostBuilder knows what to scan at build time.
159use std::sync::atomic::AtomicBool;
160static ENDPOINT_SCAN: AtomicBool = AtomicBool::new(false);
161static MEDIATOR_ACTIVE: AtomicBool = AtomicBool::new(false);
162
163pub fn should_scan_endpoints() -> bool {
164    ENDPOINT_SCAN.load(std::sync::atomic::Ordering::SeqCst)
165}
166
167pub fn is_mediator_active() -> bool {
168    MEDIATOR_ACTIVE.load(std::sync::atomic::Ordering::SeqCst)
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::pipeline::{BoxedNextFn, BoxedPipelineFuture};
175    use std::any::Any;
176    use std::sync::atomic::{AtomicU32, Ordering};
177
178    /// Behavior whose dependency (a counter) comes from the DI container — the
179    /// reason `add_pipeline_with` exists.
180    struct TickingBehavior {
181        ticks: Arc<AtomicU32>,
182    }
183
184    #[async_trait::async_trait]
185    impl IPipelineBehavior for TickingBehavior {
186        async fn handle(
187            &self,
188            req: Box<dyn Any + Send>,
189            next: BoxedNextFn,
190        ) -> crate::error::Result<Box<dyn Any + Send>> {
191            self.ticks.fetch_add(1, Ordering::SeqCst);
192            next(req).await
193        }
194    }
195
196    /// A terminal that plays the handler's role.
197    fn terminal() -> BoxedNextFn {
198        Box::new(|_req: Box<dyn Any + Send>| -> BoxedPipelineFuture {
199            Box::pin(async { Ok(Box::new("handler-response") as Box<dyn Any + Send>) })
200        })
201    }
202
203    /// Resolve a registered singleton from the type-erased resolver the
204    /// `add_pipeline_with` factory receives.
205    ///
206    /// `&dyn IServiceResolver` is object-safe, so `get`/`get_owned` (which
207    /// require `Self: Sized`) are unavailable there; `get_any` + downcast is the
208    /// portable path (it also works during build-time validation).
209    fn resolve<T: Send + Sync + 'static>(resolver: &dyn IServiceResolver) -> Arc<T> {
210        resolver
211            .get_any(std::any::type_name::<T>())
212            .and_then(|service| service.downcast::<Arc<T>>().ok())
213            .map(|service| Arc::clone(&*service))
214            .expect("the dependency must be registered before the pipeline")
215    }
216
217    #[tokio::test]
218    async fn add_pipeline_with_constructor_injects_from_the_resolver() {
219        let ticks = Arc::new(AtomicU32::new(0));
220
221        let provider = ServiceCollection::new()
222            .instance(Arc::clone(&ticks))
223            .add_pipeline_with(|resolver| {
224                // The factory receives the DI resolver, so the behavior can take
225                // constructor dependencies.
226                let ticks: Arc<AtomicU32> = resolve::<AtomicU32>(resolver);
227                Arc::new(TickingBehavior { ticks }) as Arc<dyn IPipelineBehavior>
228            })
229            .build()
230            .expect("provider must build");
231
232        let behavior = provider
233            .get::<dyn IPipelineBehavior>()
234            .expect("the behavior must be registered as a singleton");
235        let response = behavior
236            .handle(Box::new("request"), terminal())
237            .await
238            .expect("behavior must pass through to the next step");
239        assert_eq!(
240            *response.downcast::<&str>().expect("handler response"),
241            "handler-response"
242        );
243        assert_eq!(
244            ticks.load(Ordering::SeqCst),
245            1,
246            "the behavior must use the dependency injected through the resolver"
247        );
248
249        // Singleton: resolving again reuses the instance, the factory ran once.
250        provider
251            .get::<dyn IPipelineBehavior>()
252            .expect("the behavior must be registered as a singleton")
253            .handle(Box::new("request"), terminal())
254            .await
255            .expect("behavior must pass through to the next step");
256        assert_eq!(ticks.load(Ordering::SeqCst), 2);
257    }
258
259    #[tokio::test]
260    async fn add_pipeline_keeps_registering_default_behaviors() {
261        #[derive(Default)]
262        struct NoopBehavior;
263
264        #[async_trait::async_trait]
265        impl IPipelineBehavior for NoopBehavior {
266            async fn handle(
267                &self,
268                req: Box<dyn Any + Send>,
269                next: BoxedNextFn,
270            ) -> crate::error::Result<Box<dyn Any + Send>> {
271                next(req).await
272            }
273        }
274
275        let provider = ServiceCollection::new()
276            .add_pipeline::<NoopBehavior>()
277            .build()
278            .expect("provider must build");
279
280        let response = provider
281            .get::<dyn IPipelineBehavior>()
282            .expect("`add_pipeline` must still register a zero-dependency behavior")
283            .handle(Box::new("request"), terminal())
284            .await
285            .expect("behavior must pass through to the next step");
286        assert_eq!(
287            *response.downcast::<&str>().expect("handler response"),
288            "handler-response"
289        );
290    }
291}