Skip to main content

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}