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