Skip to main content

webx_core/route/
ext.rs

1//! IServiceCollectionExt — Extension trait for rust_dix ServiceCollection.
2
3// The legacy type-erased pipeline is deprecated but exercised on purpose here.
4#![allow(deprecated)]
5
6use crate::handler::IHostedService;
7use crate::mediator::Mediator;
8use crate::middleware::IMiddleware;
9use crate::pipeline::{IErasedPipelineBehavior, IPipelineBehavior};
10use rust_dix::{IServiceResolver, ServiceCollection, ServiceLifetime};
11use std::sync::Arc;
12
13/// Extension methods for `rust_dix::ServiceCollection` to enable
14/// framework-level service registration patterns.
15pub trait IServiceCollectionExt: Sized {
16    /// Register the mediator in the DI container.
17    ///
18    /// The actual `IRequestHandler` and `IEventHandler` implementations
19    /// are registered via `#[rust_dix::module]` blocks (re-exported as `webx::module`).
20    fn add_mediator(self) -> Self;
21
22    /// Signal that endpoints should be auto-discovered from `#[get]` / `#[endpoint]`
23    /// annotations at compile time.  The actual route table is built during
24    /// `Host::build()` by scanning the inventory for `RouteEntry` items.
25    fn add_request_endpoints(self) -> Self;
26
27    /// Register controller types scoped per request.
28    fn add_controllers(self) -> Self;
29
30    /// Register a middleware type in the DI container as a singleton.
31    fn add_middleware<T>(self) -> Self
32    where
33        T: IMiddleware + Default + Send + Sync + 'static;
34
35    /// Register a **typed** pipeline behavior in the DI container as a singleton.
36    ///
37    /// `B` declares the request/response types by implementing
38    /// [`IPipelineBehavior<Req, Rsp>`]; register those same types here:
39    ///
40    /// ```ignore
41    /// #[derive(Default)]
42    /// struct AuditBehavior;
43    ///
44    /// #[async_trait::async_trait]
45    /// impl IPipelineBehavior<CreateOrderRequest, OrderView> for AuditBehavior {
46    ///     async fn handle(&self, req: CreateOrderRequest, next: Next<CreateOrderRequest, OrderView>)
47    ///         -> Result<OrderView> { next.run(req).await }
48    /// }
49    ///
50    /// svc.add_typed_pipeline::<AuditBehavior, CreateOrderRequest, OrderView>();
51    /// ```
52    ///
53    /// The behavior only runs for that request/response pair. Short-circuiting is
54    /// type-checked: return `Rsp` without calling [`Next::run`].
55    fn add_typed_pipeline<B, Req, Rsp>(self) -> Self
56    where
57        B: IPipelineBehavior<Req, Rsp> + Default + 'static,
58        Req: Send + 'static,
59        Rsp: Send + 'static;
60
61    /// Register a typed pipeline behavior built from a factory (singleton).
62    ///
63    /// The factory receives the DI resolver at construction time, so the
64    /// behavior can take constructor dependencies. It runs once per provider;
65    /// the resolver is **not** available inside `handle`.
66    fn add_typed_pipeline_with<F, Req, Rsp>(self, factory: F) -> Self
67    where
68        F: Fn(&dyn IServiceResolver) -> Arc<dyn IPipelineBehavior<Req, Rsp>>
69            + Send
70            + Sync
71            + 'static,
72        Req: Send + 'static,
73        Rsp: Send + 'static;
74
75    /// Register a legacy **type-erased** pipeline behavior (deprecated).
76    ///
77    /// Requires `Default`, so it is only usable for behaviors without
78    /// dependencies. Use [`IServiceCollectionExt::add_typed_pipeline`] for new
79    /// code; the erased chain runs outside the typed chain and is removed in 1.0.
80    #[deprecated(
81        since = "0.10.0",
82        note = "use add_typed_pipeline::<B, Req, Rsp>() with the typed IPipelineBehavior"
83    )]
84    fn add_pipeline<T>(self) -> Self
85    where
86        T: IErasedPipelineBehavior + Default + Send + Sync + 'static;
87
88    /// Register a legacy type-erased behavior built from a factory (deprecated).
89    #[deprecated(
90        since = "0.10.0",
91        note = "use add_typed_pipeline_with(...) with the typed IPipelineBehavior"
92    )]
93    fn add_pipeline_with<F>(self, factory: F) -> Self
94    where
95        F: Fn(&dyn IServiceResolver) -> Arc<dyn IErasedPipelineBehavior> + Send + Sync + 'static;
96
97    /// Register a hosted service in the DI container as a singleton.
98    ///
99    /// Hosted services are started when the host starts (before accepting
100    /// requests) and stopped during graceful shutdown.
101    ///
102    /// Analogous to ASP.NET Core's `AddHostedService<T>()`.
103    ///
104    /// # Example
105    ///
106    /// ```ignore
107    /// Host::builder()
108    ///     .register(|svc| {
109    ///         svc.add_hosted_service::<DbInitService>()
110    ///     })
111    ///     .build()
112    ///     .run()
113    ///     .await
114    /// ```
115    fn add_hosted_service<T>(self) -> Self
116    where
117        T: IHostedService + Default + Send + Sync + 'static;
118}
119
120impl IServiceCollectionExt for ServiceCollection {
121    fn add_mediator(self) -> Self {
122        // Transient (not singleton) so build-time validation does not require a
123        // fully wired ServiceProvider. Each resolution shares the root provider Arc.
124        MEDIATOR_ACTIVE.store(true, std::sync::atomic::Ordering::SeqCst);
125        self.transient::<Mediator>(|resolver| {
126            let provider = resolver
127                .provider_arc()
128                .expect("ServiceProvider not available from DI resolver");
129            Arc::new(Mediator::new(provider))
130        })
131    }
132
133    fn add_request_endpoints(self) -> Self {
134        // Routes are collected from inventory at Host::build() time.
135        // This call just signals that auto-scanning is desired.
136        ENDPOINT_SCAN.store(true, std::sync::atomic::Ordering::SeqCst);
137        self
138    }
139
140    fn add_controllers(self) -> Self {
141        self
142    }
143
144    fn add_middleware<T>(self) -> Self
145    where
146        T: IMiddleware + Default + Send + Sync + 'static,
147    {
148        self.singleton::<dyn IMiddleware>(|_| Arc::new(T::default()))
149    }
150
151    fn add_typed_pipeline<B, Req, Rsp>(self) -> Self
152    where
153        B: IPipelineBehavior<Req, Rsp> + Default + 'static,
154        Req: Send + 'static,
155        Rsp: Send + 'static,
156    {
157        self.add_typed_pipeline_with::<_, Req, Rsp>(|_| {
158            Arc::new(B::default()) as Arc<dyn IPipelineBehavior<Req, Rsp>>
159        })
160    }
161
162    fn add_typed_pipeline_with<F, Req, Rsp>(self, factory: F) -> Self
163    where
164        F: Fn(&dyn IServiceResolver) -> Arc<dyn IPipelineBehavior<Req, Rsp>>
165            + Send
166            + Sync
167            + 'static,
168        Req: Send + 'static,
169        Rsp: Send + 'static,
170    {
171        // Singleton per (Req, Rsp) pair: the factory (and therefore constructor
172        // injection) runs once per provider; behaviors are stateless wrappers.
173        self.add(ServiceLifetime::Singleton, factory)
174    }
175
176    #[allow(deprecated)]
177    fn add_pipeline<T>(self) -> Self
178    where
179        T: IErasedPipelineBehavior + Default + Send + Sync + 'static,
180    {
181        self.add_pipeline_with(|_| Arc::new(T::default()) as Arc<dyn IErasedPipelineBehavior>)
182    }
183
184    #[allow(deprecated)]
185    fn add_pipeline_with<F>(self, factory: F) -> Self
186    where
187        F: Fn(&dyn IServiceResolver) -> Arc<dyn IErasedPipelineBehavior> + Send + Sync + 'static,
188    {
189        // Singleton: the factory (and therefore constructor injection) runs once
190        // per provider; behaviors are stateless wrappers over the request chain.
191        self.add(ServiceLifetime::Singleton, factory)
192    }
193    fn add_hosted_service<T>(self) -> Self
194    where
195        T: IHostedService + Default + Send + Sync + 'static,
196    {
197        self.singleton::<dyn IHostedService>(|_| Arc::new(T::default()))
198    }
199}
200
201// Global flags so HostBuilder knows what to scan at build time.
202use std::sync::atomic::AtomicBool;
203static ENDPOINT_SCAN: AtomicBool = AtomicBool::new(false);
204static MEDIATOR_ACTIVE: AtomicBool = AtomicBool::new(false);
205
206pub fn should_scan_endpoints() -> bool {
207    ENDPOINT_SCAN.load(std::sync::atomic::Ordering::SeqCst)
208}
209
210pub fn is_mediator_active() -> bool {
211    MEDIATOR_ACTIVE.load(std::sync::atomic::Ordering::SeqCst)
212}
213
214#[cfg(test)]
215mod tests {
216    #![allow(deprecated)]
217
218    use super::*;
219    use crate::pipeline::{
220        BoxedNextFn, BoxedPipelineFuture, IErasedPipelineBehavior, IPipelineBehavior, Next,
221    };
222    use std::any::Any;
223    use std::sync::atomic::{AtomicU32, Ordering};
224
225    // ----- legacy type-erased behaviors (kept working for one release) -----
226
227    /// Behavior whose dependency (a counter) comes from the DI container.
228    struct TickingBehavior {
229        ticks: Arc<AtomicU32>,
230    }
231
232    #[async_trait::async_trait]
233    impl IErasedPipelineBehavior for TickingBehavior {
234        async fn handle(
235            &self,
236            req: Box<dyn Any + Send>,
237            next: BoxedNextFn,
238        ) -> crate::error::Result<Box<dyn Any + Send>> {
239            self.ticks.fetch_add(1, Ordering::SeqCst);
240            next(req).await
241        }
242    }
243
244    /// A terminal that plays the handler's role.
245    fn terminal() -> BoxedNextFn {
246        Box::new(|_req: Box<dyn Any + Send>| -> BoxedPipelineFuture {
247            Box::pin(async { Ok(Box::new("handler-response") as Box<dyn Any + Send>) })
248        })
249    }
250
251    /// Resolve a registered singleton from the type-erased resolver the
252    /// registration factories receive.
253    fn resolve<T: Send + Sync + 'static>(resolver: &dyn IServiceResolver) -> Arc<T> {
254        resolver
255            .get_any(std::any::type_name::<T>())
256            .and_then(|service| service.downcast::<Arc<T>>().ok())
257            .map(|service| Arc::clone(&*service))
258            .expect("the dependency must be registered before the pipeline")
259    }
260
261    #[tokio::test]
262    async fn legacy_add_pipeline_with_constructor_injects_from_the_resolver() {
263        let ticks = Arc::new(AtomicU32::new(0));
264
265        let provider = ServiceCollection::new()
266            .instance(Arc::clone(&ticks))
267            .add_pipeline_with(|resolver| {
268                let ticks: Arc<AtomicU32> = resolve::<AtomicU32>(resolver);
269                Arc::new(TickingBehavior { ticks }) as Arc<dyn IErasedPipelineBehavior>
270            })
271            .build()
272            .expect("provider must build");
273
274        let behavior = provider
275            .get::<dyn IErasedPipelineBehavior>()
276            .expect("the behavior must be registered as a singleton");
277        let response = behavior
278            .handle(Box::new("request"), terminal())
279            .await
280            .expect("behavior must pass through to the next step");
281        assert_eq!(
282            *response.downcast::<&str>().expect("handler response"),
283            "handler-response"
284        );
285        assert_eq!(ticks.load(Ordering::SeqCst), 1);
286
287        provider
288            .get::<dyn IErasedPipelineBehavior>()
289            .expect("the behavior must be registered as a singleton")
290            .handle(Box::new("request"), terminal())
291            .await
292            .expect("behavior must pass through to the next step");
293        assert_eq!(ticks.load(Ordering::SeqCst), 2);
294    }
295
296    #[tokio::test]
297    async fn legacy_add_pipeline_keeps_registering_default_behaviors() {
298        #[derive(Default)]
299        struct NoopBehavior;
300
301        #[async_trait::async_trait]
302        impl IErasedPipelineBehavior for NoopBehavior {
303            async fn handle(
304                &self,
305                req: Box<dyn Any + Send>,
306                next: BoxedNextFn,
307            ) -> crate::error::Result<Box<dyn Any + Send>> {
308                next(req).await
309            }
310        }
311
312        let provider = ServiceCollection::new()
313            .add_pipeline::<NoopBehavior>()
314            .build()
315            .expect("provider must build");
316
317        let response = provider
318            .get::<dyn IErasedPipelineBehavior>()
319            .expect("`add_pipeline` must still register a zero-dependency behavior")
320            .handle(Box::new("request"), terminal())
321            .await
322            .expect("behavior must pass through to the next step");
323        assert_eq!(
324            *response.downcast::<&str>().expect("handler response"),
325            "handler-response"
326        );
327    }
328
329    // ----- typed behaviors (0.10) -----
330
331    #[derive(Default)]
332    struct Uppercase;
333
334    #[async_trait::async_trait]
335    impl IPipelineBehavior<u32, String> for Uppercase {
336        async fn handle(&self, req: u32, next: Next<u32, String>) -> crate::error::Result<String> {
337            let inner = next.run(req + 1).await?;
338            Ok(inner.to_uppercase())
339        }
340    }
341
342    fn string_terminal() -> Next<u32, String> {
343        Next::new(|n: u32| async move { Ok::<String, crate::error::Error>(format!("n={n}")) })
344    }
345
346    #[tokio::test]
347    async fn typed_behavior_registers_only_for_its_request_response_pair() {
348        let provider = ServiceCollection::new()
349            .add_typed_pipeline::<Uppercase, u32, String>()
350            .build()
351            .expect("provider must build");
352
353        let behaviors = provider.get_all::<dyn IPipelineBehavior<u32, String>>();
354        assert_eq!(behaviors.len(), 1);
355        let out = behaviors[0]
356            .handle(7, string_terminal())
357            .await
358            .expect("typed behavior must run");
359        assert_eq!(out, "N=8");
360
361        assert!(
362            provider
363                .get_all::<dyn IPipelineBehavior<u64, String>>()
364                .is_empty(),
365            "a typed behavior must only attach to its own request/response pair"
366        );
367    }
368
369    struct TickBehavior {
370        ticks: Arc<AtomicU32>,
371    }
372
373    #[async_trait::async_trait]
374    impl IPipelineBehavior<u32, u32> for TickBehavior {
375        async fn handle(&self, req: u32, next: Next<u32, u32>) -> crate::error::Result<u32> {
376            self.ticks.fetch_add(1, Ordering::SeqCst);
377            next.run(req).await
378        }
379    }
380
381    #[tokio::test]
382    async fn typed_add_pipeline_with_constructor_injects_from_the_resolver() {
383        let ticks = Arc::new(AtomicU32::new(0));
384
385        let provider = ServiceCollection::new()
386            .instance(Arc::clone(&ticks))
387            .add_typed_pipeline_with(|resolver| {
388                let ticks: Arc<AtomicU32> = resolve::<AtomicU32>(resolver);
389                Arc::new(TickBehavior { ticks }) as Arc<dyn IPipelineBehavior<u32, u32>>
390            })
391            .build()
392            .expect("provider must build");
393
394        let behavior = &provider.get_all::<dyn IPipelineBehavior<u32, u32>>()[0];
395        let out = behavior
396            .handle(5, Next::new(|n: u32| async move { Ok(n * 2) }))
397            .await
398            .expect("typed behavior must run");
399        assert_eq!(out, 10);
400        assert_eq!(ticks.load(Ordering::SeqCst), 1);
401    }
402
403    struct ShortCircuit;
404
405    #[async_trait::async_trait]
406    impl IPipelineBehavior<u32, String> for ShortCircuit {
407        async fn handle(
408            &self,
409            _req: u32,
410            _next: Next<u32, String>,
411        ) -> crate::error::Result<String> {
412            Ok("short".to_string())
413        }
414    }
415
416    #[tokio::test]
417    async fn typed_behavior_may_short_circuit_with_its_response_type() {
418        let out = ShortCircuit
419            .handle(1, string_terminal())
420            .await
421            .expect("short-circuit must return the typed response");
422        assert_eq!(out, "short");
423    }
424}