rust_webx_core/route/scan.rs
1//! Type scanning and automatic service registration logic.
2//!
3//! In the rust-webx framework, "scanning" is achieved at compile time via:
4//!
5//! 1. `#[rust_dix::module]` + `rust_dix::inject!` — declare handlers in a module group
6//! 2. `#[endpoint]` (or shortcuts `#[get]`, `#[post]`, `#[put]`, `#[delete]`) — register route metadata via `inventory::submit!`
7//!
8//! This module provides the `RouteEntry` type that connects compile-time
9//! macro output to runtime routing.
10
11use crate::http::ResponseBody;
12use crate::routing::HttpMethod;
13use std::any::Any;
14use std::collections::HashMap;
15use std::sync::{Arc, OnceLock, RwLock};
16
17// --- Deprecated process-wide fallback (prefer [`DispatchRuntime`] on `Host`) ---
18
19static GLOBAL_PROVIDER: RwLock<Option<Arc<rust_dix::ServiceProvider>>> = RwLock::new(None);
20
21/// Deprecated shim: set a process-wide `ServiceProvider` fallback.
22///
23/// `Host::build()` no longer calls this. Prefer `Host::provider()` or run work
24/// inside `Host::dispatch_runtime().run()` so `dispatch_provider()` resolves
25/// from the active runtime.
26#[deprecated(
27 since = "0.2.0",
28 note = "Use Host::provider() or dispatch_provider() within an active DispatchRuntime"
29)]
30pub fn set_global_provider(provider: Arc<rust_dix::ServiceProvider>) {
31 let mut guard = GLOBAL_PROVIDER
32 .write()
33 .expect("global provider lock poisoned");
34 if guard.is_some() {
35 tracing::warn!(
36 "Replacing deprecated global ServiceProvider. \
37 Prefer Host::dispatch_runtime() for per-host isolation."
38 );
39 }
40 *guard = Some(provider);
41}
42
43/// Deprecated shim: read the process-wide `ServiceProvider` fallback.
44///
45/// Panics when no shim was set and no [`DispatchRuntime`](crate::DispatchRuntime)
46/// is active on this task.
47#[deprecated(
48 since = "0.2.0",
49 note = "Use Host::provider() or dispatch_provider() within an active DispatchRuntime"
50)]
51pub fn global_provider() -> Arc<rust_dix::ServiceProvider> {
52 try_global_provider().unwrap_or_else(|| {
53 panic!(
54 "global_provider() is deprecated and not set. \
55 Use host.provider() for the host instance, or dispatch_provider() \
56 inside Host::dispatch_runtime().run() (HTTP and hosted services are scoped automatically)."
57 )
58 })
59}
60
61/// Internal fallback for `dispatch_provider()` when no task-local runtime is active.
62pub(crate) fn try_global_provider() -> Option<Arc<rust_dix::ServiceProvider>> {
63 GLOBAL_PROVIDER
64 .read()
65 .expect("global provider lock poisoned")
66 .clone()
67}
68
69/// Metadata about a request parameter for OpenAPI generation.
70#[derive(Debug, Clone)]
71pub struct ParamMeta {
72 /// Field name (e.g., "id", "body").
73 pub name: &'static str,
74
75 /// Parameter location: "path", "query", or "body".
76 pub source: &'static str,
77
78 /// Type hint for OpenAPI schema (e.g., "string", "integer", "object").
79 pub type_hint: &'static str,
80}
81
82/// A route entry registered at compile time via `#[endpoint]` (or its shortcuts `#[get]`/`#[post]`/...).
83///
84/// Collected by the `inventory` crate and read at application startup.
85#[derive(Debug, Clone)]
86pub struct RouteEntry {
87 /// HTTP method for this route.
88 pub method: HttpMethod,
89
90 /// Route path pattern (e.g., "/users/{id}").
91 pub path: &'static str,
92
93 /// Type name of the request handler.
94 /// Used to dispatch to the correct handler at runtime.
95 pub handler_type: &'static str,
96
97 /// OpenAPI response type name (e.g., `"UserModel"`, `"Vec<UserModel>"`, `"String"`).
98 pub rsp_type: &'static str,
99
100 /// Human-readable summary for OpenAPI docs (e.g., "Get user by ID").
101 pub summary: &'static str,
102
103 /// Optional long-form description from `///` doc comments on the impl block.
104 pub description: &'static str,
105
106 /// OpenAPI parameter metadata: path params, body params, etc.
107 pub params: &'static [ParamMeta],
108
109 /// "" = public, "authenticated" = any valid JWT, otherwise specific role name.
110 pub required_role: &'static str,
111
112 /// Non-empty when `#[authorize(permission = "...")]` is declared.
113 pub required_permission: &'static str,
114}
115
116// Collect RouteEntry instances at compile time using `inventory`.
117inventory::collect!(RouteEntry);
118
119/// Optional query/path/body metadata for a request type, collected via
120/// `#[derive(WebxRequestMeta)]` on the request struct.
121#[derive(Debug, Clone)]
122pub struct RequestParamEntry {
123 pub request_type: &'static str,
124 pub params: &'static [ParamMeta],
125}
126
127inventory::collect!(RequestParamEntry);
128
129/// Handler registration collected at compile time.
130/// Each `#[handler]` annotation submits one of these to inventory.
131///
132/// The factory is called **per request** with the request-scoped `IServiceResolver`
133/// so it can resolve Scoped dependencies (e.g. `DbContext`) via `get_owned`.
134/// The result is an owned, type-erased handler — no `Arc<dyn Trait>` caching,
135/// so `handle(&mut self)` works without interior mutability.
136pub struct HandlerRegistration {
137 /// Compile-time type id of the request type — primary dispatch key.
138 pub req_type_id: std::any::TypeId,
139 /// Request type name (e.g., "HelloRequest") — used for diagnostics and route matching.
140 pub req_type_name: &'static str,
141 /// Constructs a fresh owned handler, boxed as `Box<dyn Any + Send>`.
142 /// Called per-request with the per-request scope as resolver.
143 pub factory:
144 fn(&dyn rust_dix::IServiceResolver) -> crate::error::Result<Box<dyn std::any::Any + Send>>,
145 /// Type-erased call bridge: receives the owned handler and the boxed request,
146 /// invokes `handle(&mut self, req)`, and returns the boxed response.
147 #[allow(clippy::type_complexity)]
148 pub call: fn(
149 handler: Box<dyn std::any::Any + Send>,
150 request: Box<dyn std::any::Any + Send>,
151 ) -> std::pin::Pin<
152 Box<
153 dyn std::future::Future<Output = crate::error::Result<Box<dyn std::any::Any + Send>>>
154 + Send,
155 >,
156 >,
157}
158
159inventory::collect!(HandlerRegistration);
160
161/// The signature every route dispatch function implements.
162///
163/// It receives the live request context — headers, route parameters, claims and
164/// the body stream — and returns the response to write.
165pub type RouteDispatchFn = fn(
166 ctx: &mut dyn crate::http::IHttpContext,
167) -> std::pin::Pin<
168 Box<dyn std::future::Future<Output = crate::error::Result<ResponseData>> + Send + '_>,
169>;
170
171/// A dispatch function registered at compile time via the endpoint macros.
172///
173/// The function receives the live request context, so it can inspect headers,
174/// stream the body as `multipart/form-data`, and produce any
175/// [`ResponseData`] — including streamed file downloads.
176pub struct RouteDispatch {
177 pub handler_type: &'static str,
178 pub dispatch: RouteDispatchFn,
179}
180
181/// The HTTP response produced by an endpoint.
182///
183/// Most endpoints never build one directly: the endpoint macro turns a
184/// serializable response value into `application/json`. Return `ResponseData`
185/// from a handler when you need full control over the status, headers and body
186/// — for example to stream a file:
187///
188/// ```ignore
189/// async fn handle(&mut self, req: DownloadReportRequest) -> Result<ResponseData> {
190/// Ok(ResponseData::file(self.reports.path_for(&req.id)?)
191/// .content_type("text/csv")
192/// .download_name("report.csv"))
193/// }
194/// ```
195#[derive(Debug)]
196pub struct ResponseData {
197 /// HTTP status code.
198 pub status: u16,
199 /// Media type. Applied before `headers`, so a header can override it.
200 pub content_type: String,
201 /// Extra response headers, applied in order.
202 pub headers: Vec<(String, String)>,
203 /// The response body.
204 pub body: ResponseBody,
205}
206
207impl ResponseData {
208 /// An empty `200` response.
209 pub fn new(status: u16) -> Self {
210 Self {
211 status,
212 content_type: String::new(),
213 headers: Vec::new(),
214 body: ResponseBody::Bytes(Vec::new()),
215 }
216 }
217
218 /// Serialize `value` as an `application/json` response.
219 pub fn json<T: serde::Serialize + ?Sized>(value: &T) -> crate::error::Result<Self> {
220 Ok(Self {
221 status: 200,
222 content_type: "application/json".to_string(),
223 headers: Vec::new(),
224 body: ResponseBody::Bytes(serde_json::to_vec(value)?),
225 })
226 }
227
228 /// `204 No Content`.
229 pub fn no_content() -> Self {
230 Self {
231 status: 204,
232 content_type: "application/json".to_string(),
233 headers: Vec::new(),
234 body: ResponseBody::Bytes(Vec::new()),
235 }
236 }
237
238 /// A `text/plain; charset=utf-8` response.
239 pub fn text(body: impl Into<String>) -> Self {
240 Self {
241 status: 200,
242 content_type: "text/plain; charset=utf-8".to_string(),
243 headers: Vec::new(),
244 body: ResponseBody::Bytes(body.into().into_bytes()),
245 }
246 }
247
248 /// A `text/html; charset=utf-8` response.
249 pub fn html(body: impl Into<String>) -> Self {
250 Self {
251 status: 200,
252 content_type: "text/html; charset=utf-8".to_string(),
253 headers: Vec::new(),
254 body: ResponseBody::Bytes(body.into().into_bytes()),
255 }
256 }
257
258 /// A binary response. Set [`ResponseData::content_type`] for anything other
259 /// than `application/octet-stream`.
260 pub fn bytes(body: impl Into<Vec<u8>>) -> Self {
261 Self {
262 status: 200,
263 content_type: "application/octet-stream".to_string(),
264 headers: Vec::new(),
265 body: ResponseBody::Bytes(body.into()),
266 }
267 }
268
269 /// Send the file at `path` — ASP.NET Core's `PhysicalFile(...)`.
270 ///
271 /// The host opens the file once, sends `Content-Length`, `Last-Modified` and
272 /// an `ETag`, honours conditional requests, and serves `Range` requests so
273 /// downloads can be resumed. The media type is inferred from the extension
274 /// unless overridden with [`ResponseData::content_type`].
275 pub fn file(path: impl Into<std::path::PathBuf>) -> Self {
276 Self::with_file(crate::http::FileBody::path(path))
277 }
278
279 /// Send an async reader of unknown length — ASP.NET Core's `File(Stream, ...)`.
280 ///
281 /// Nothing is buffered: bytes flow from the reader to the socket. The
282 /// response uses chunked transfer encoding, so there is no `Content-Length`
283 /// and no `Range` support; use [`ResponseData::file_seekable_stream`] when
284 /// the reader knows its length and can seek.
285 ///
286 /// ```ignore
287 /// // Stream a zip being built on the fly.
288 /// let (reader, writer) = tokio::io::duplex(64 * 1024);
289 /// tokio::spawn(async move { zip_into(writer).await });
290 /// Ok(ResponseData::file_stream(reader, "application/zip")
291 /// .download_name("export.zip"))
292 /// ```
293 pub fn file_stream(
294 reader: impl tokio::io::AsyncRead + Send + Unpin + 'static,
295 content_type: impl Into<String>,
296 ) -> Self {
297 Self::with_file(crate::http::FileBody::stream(reader).content_type(content_type))
298 }
299
300 /// Send an async reader of known length that supports seeking.
301 ///
302 /// Keeps exact `Content-Length` and `Range`/`206` support, so clients can
303 /// resume. Pass an [`crate::http::FileBody::entity_tag`] to also get
304 /// conditional requests.
305 pub fn file_seekable_stream(
306 reader: impl tokio::io::AsyncRead + tokio::io::AsyncSeek + Send + Unpin + 'static,
307 len: u64,
308 content_type: impl Into<String>,
309 ) -> Self {
310 Self::with_file(
311 crate::http::FileBody::seekable_stream(reader, len).content_type(content_type),
312 )
313 }
314
315 /// Send a fully specified [`crate::http::FileBody`].
316 ///
317 /// Use this to set the file-specific HTTP knobs: `enable_range_processing`,
318 /// `last_modified` and `entity_tag`.
319 pub fn with_file(file: crate::http::FileBody) -> Self {
320 Self {
321 status: 200,
322 content_type: String::new(),
323 headers: Vec::new(),
324 body: ResponseBody::File(file),
325 }
326 }
327
328 /// Override the status code.
329 pub fn status(mut self, status: u16) -> Self {
330 self.status = status;
331 self
332 }
333
334 /// Override the media type.
335 pub fn content_type(mut self, content_type: impl Into<String>) -> Self {
336 self.content_type = content_type.into();
337 self
338 }
339
340 /// Add a response header.
341 pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
342 self.headers.push((key.into(), value.into()));
343 self
344 }
345
346 /// Send the body as a download named `file_name`.
347 ///
348 /// For file bodies the host emits `Content-Disposition` while streaming;
349 /// for in-memory bodies the header is set here.
350 pub fn download_name(mut self, file_name: impl Into<String>) -> Self {
351 self.set_disposition(crate::http::Disposition::Attachment, file_name.into());
352 self
353 }
354
355 /// Present the body inline under `file_name`.
356 pub fn inline_name(mut self, file_name: impl Into<String>) -> Self {
357 self.set_disposition(crate::http::Disposition::Inline, file_name.into());
358 self
359 }
360
361 /// Enable or disable `Range` processing for a file body.
362 ///
363 /// Has no effect on in-memory bodies, which are always sent whole.
364 pub fn enable_range_processing(mut self, enabled: bool) -> Self {
365 if let ResponseBody::File(file) = &mut self.body {
366 file.enable_range_processing = enabled;
367 }
368 self
369 }
370
371 /// Advertise an explicit `Last-Modified`.
372 ///
373 /// For a file body this becomes the response validator; otherwise it is
374 /// written as a plain header.
375 pub fn last_modified(mut self, last_modified: std::time::SystemTime) -> Self {
376 if let ResponseBody::File(file) = &mut self.body {
377 file.last_modified = Some(last_modified);
378 } else {
379 let value = httpdate::fmt_http_date(last_modified);
380 self.headers.push(("last-modified".to_string(), value));
381 }
382 self
383 }
384
385 /// Advertise an explicit `ETag` (include the quotes).
386 ///
387 /// For a file body this becomes the response validator; otherwise it is
388 /// written as a plain header.
389 pub fn entity_tag(mut self, entity_tag: impl Into<String>) -> Self {
390 let entity_tag = entity_tag.into();
391 if let ResponseBody::File(file) = &mut self.body {
392 file.entity_tag = Some(entity_tag);
393 } else {
394 self.headers.push(("etag".to_string(), entity_tag));
395 }
396 self
397 }
398
399 fn set_disposition(&mut self, disposition: crate::http::Disposition, file_name: String) {
400 if let ResponseBody::File(file) = &mut self.body {
401 file.file_name = Some(file_name);
402 file.disposition = disposition;
403 } else {
404 let value = crate::http::content_disposition_value(disposition, &file_name);
405 self.headers
406 .push(("content-disposition".to_string(), value));
407 }
408 }
409}
410
411inventory::collect!(RouteDispatch);
412
413impl RouteEntry {
414 /// Create a new route entry.
415 #[allow(clippy::too_many_arguments)]
416 pub const fn new(
417 method: HttpMethod,
418 path: &'static str,
419 handler_type: &'static str,
420 rsp_type: &'static str,
421 summary: &'static str,
422 description: &'static str,
423 params: &'static [ParamMeta],
424 required_role: &'static str,
425 required_permission: &'static str,
426 ) -> Self {
427 Self {
428 method,
429 path,
430 handler_type,
431 rsp_type,
432 summary,
433 description,
434 params,
435 required_role,
436 required_permission,
437 }
438 }
439}
440
441// --- Handler Cache ---
442
443/// Runtime registry of compiled handler registrations.
444///
445/// Built at startup from `HandlerRegistration` inventory items.
446/// Maps request type name to handler entry (factory + call bridge).
447///
448/// Unlike the previous design, entries do **not** cache a handler instance:
449/// the factory is invoked per request with the request-scoped resolver so
450/// Scoped dependencies (DbContext) are freshly owned each time.
451pub struct HandlerCache {
452 pub entries: HashMap<&'static str, HandlerEntry>,
453 pub entries_by_id: HashMap<std::any::TypeId, HandlerEntry>,
454}
455
456/// Deprecated process-wide handler registry fallback (inventory is process-wide).
457static HANDLER_CACHE: OnceLock<Arc<HandlerCache>> = OnceLock::new();
458
459impl HandlerCache {
460 /// Build the cache from all `HandlerRegistration` inventory items.
461 pub fn build() -> Self {
462 let mut entries = HashMap::new();
463 let mut entries_by_id = HashMap::new();
464 for reg in inventory::iter::<HandlerRegistration> {
465 let entry = HandlerEntry {
466 factory: reg.factory,
467 call: reg.call,
468 };
469 entries.insert(reg.req_type_name, entry.clone());
470 entries_by_id.insert(reg.req_type_id, entry);
471 }
472 Self {
473 entries,
474 entries_by_id,
475 }
476 }
477
478 /// Deprecated shim: lazy-build the process-wide handler registry.
479 ///
480 /// `Host::build()` uses `HandlerCache::build()` per instance. This remains only
481 /// as a fallback for in-process `Mediator::send` without an active runtime.
482 #[deprecated(
483 since = "0.2.0",
484 note = "Prefer dispatch_handler_cache() within an active DispatchRuntime"
485 )]
486 pub fn get_or_init() -> Arc<Self> {
487 Arc::clone(HANDLER_CACHE.get_or_init(|| Arc::new(Self::build())))
488 }
489
490 /// Look up a handler entry by request type name (route / OpenAPI diagnostics).
491 pub fn get(&self, req_type_name: &str) -> Option<&HandlerEntry> {
492 self.entries.get(req_type_name)
493 }
494
495 /// Look up a handler entry by request type id (Mediator / in-process dispatch).
496 pub fn get_by_type_id(&self, type_id: std::any::TypeId) -> Option<&HandlerEntry> {
497 self.entries_by_id.get(&type_id)
498 }
499}
500
501/// Preferred name for [`HandlerCache`] — emphasizes registry semantics over caching.
502pub type HandlerRegistry = HandlerCache;
503
504/// A single compiled handler entry in the registry.
505///
506/// Stores only the factory and call bridge — no cached instance.
507/// The factory is called per request to produce a fresh owned handler.
508#[derive(Clone)]
509pub struct HandlerEntry {
510 /// Per-request factory: receives the scoped resolver, returns an owned handler.
511 pub factory: fn(&dyn rust_dix::IServiceResolver) -> crate::error::Result<Box<dyn Any + Send>>,
512 /// Type-erased call bridge: invokes `handle(&mut self, req)` on the owned handler.
513 #[allow(clippy::type_complexity)]
514 pub call: fn(
515 handler: Box<dyn Any + Send>,
516 request: Box<dyn Any + Send>,
517 ) -> std::pin::Pin<
518 Box<dyn std::future::Future<Output = crate::error::Result<Box<dyn Any + Send>>> + Send>,
519 >,
520}