Skip to main content

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