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}