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