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::localization::TextRef;
12use crate::routing::HttpMethod;
13use std::any::Any;
14use std::collections::HashMap;
15
16/// Metadata about a request parameter for OpenAPI generation.
17#[derive(Debug, Clone)]
18pub struct ParamMeta {
19    /// Field name (e.g., "id", "body").
20    pub name: &'static str,
21
22    /// Parameter location: "path", "query", "header", "form", "body",
23    /// "claim", or "" for an unmarked field that the route method resolves.
24    pub source: &'static str,
25
26    /// Type hint for OpenAPI schema (e.g., "string", "integer", "object").
27    pub type_hint: &'static str,
28
29    /// Whether the parameter must be present (`Option<T>` and `Vec<T>` are
30    /// not required; path parameters always are).
31    pub required: bool,
32
33    /// The JSON object key this parameter binds from, when it can bind from a
34    /// JSON body: an explicit `#[frombody]` field, a composite field, or an
35    /// unmarked field on a write method (the route method decides; `source` is
36    /// `""` for those).
37    ///
38    /// `None` for parameters bound from the path, query, header, form or
39    /// claims, and for a body field with no JSON key of its own — a
40    /// `#[serde(flatten)]` field spreads its inner properties into the body
41    /// object instead of owning one key.
42    pub json_key: Option<&'static str>,
43}
44
45/// One `#[authorize]` declaration on an endpoint.
46///
47/// Every rule on an endpoint must be satisfied (AND). Within a rule, a non-empty
48/// `roles` list means "the user has any of these roles" and a non-empty
49/// `permissions` list means "the user has any of these permissions"; the two
50/// dimensions are ANDed. Empty lists leave a dimension unconstrained, so a rule
51/// with both empty means "any authenticated user".
52///
53/// Multiple values in one declaration
54/// (`#[authorize(roles = ["admin", "auditor"])]`) express alternatives; multiple
55/// `#[authorize]` attributes express requirements that all have to hold.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
57pub struct AuthorizeRule {
58    /// Any-of role names; empty = no role constraint.
59    pub roles: &'static [&'static str],
60
61    /// Any-of permission names; empty = no permission constraint.
62    pub permissions: &'static [&'static str],
63}
64
65impl AuthorizeRule {
66    /// A rule that only requires a valid authenticated principal.
67    pub const fn authenticated() -> Self {
68        Self {
69            roles: &[],
70            permissions: &[],
71        }
72    }
73
74    /// Whether this rule is satisfied by the given claims.
75    ///
76    /// The `"authenticated"` sentinel is accepted in `roles` for hand-written
77    /// metadata; a rule with no constraints has the same meaning.
78    pub fn matches(&self, roles: &[String], permissions: &[String]) -> bool {
79        let role_ok = self.roles.is_empty()
80            || self.roles.iter().any(|required| {
81                *required == "authenticated" || roles.iter().any(|actual| actual == required)
82            });
83        let permission_ok = self.permissions.is_empty()
84            || self
85                .permissions
86                .iter()
87                .any(|required| permissions.iter().any(|actual| actual == required));
88        role_ok && permission_ok
89    }
90}
91
92/// A route entry registered at compile time via `#[endpoint]` (or its shortcuts `#[get]`/`#[post]`/...).
93///
94/// Collected by the `inventory` crate and read at application startup.
95#[derive(Debug, Clone)]
96pub struct RouteEntry {
97    /// HTTP method for this route.
98    pub method: HttpMethod,
99
100    /// Route path pattern (e.g., "/users/{id}").
101    pub path: &'static str,
102
103    /// Compile-time identity of the request type this route dispatches to.
104    ///
105    /// OpenAPI looks the request type's parameter metadata up by this identity,
106    /// so a qualified `self_ty` (`contracts::ListOrdersRequest`) and two
107    /// same-named request types in different modules/crates stay distinct.
108    /// `TypeId::of::<()>()` is the "not provided" sentinel written by
109    /// [`RouteEntry::new`]; [`RouteEntry::with_type_id`] fills it in.
110    pub req_type_id: std::any::TypeId,
111
112    /// Type name of the request handler.
113    /// Used to dispatch to the correct handler at runtime.
114    pub handler_type: &'static str,
115
116    /// OpenAPI response type name (e.g., `"UserModel"`, `"Vec<UserModel>"`, `"String"`).
117    pub rsp_type: &'static str,
118
119    /// Human-readable endpoint name, as a resource reference.
120    ///
121    /// From `#[display]`, else the first line of the impl block's `///` doc
122    /// comment, else a name generated from the request type.
123    pub name: TextRef,
124
125    /// Long-form description, as a resource reference.
126    ///
127    /// From `#[description]`, else the impl block's `///` doc comment.
128    pub description: TextRef,
129
130    /// Optional grouping label, as a resource reference.
131    ///
132    /// From `#[category]`; empty when the endpoint is uncategorized. Categories
133    /// are for display grouping only — they do not affect routing or
134    /// authorization.
135    pub category: TextRef,
136
137    /// OpenAPI parameter metadata: path params, body params, etc.
138    pub params: &'static [ParamMeta],
139
140    /// Authorization rules from `#[authorize]`; empty = public endpoint.
141    ///
142    /// When [`Self::authorization_dynamic`] is `true`, these rules are the
143    /// requirement handed to the dynamic authorizers rather than a gate the
144    /// endpoint enforces itself.
145    pub authorization: &'static [AuthorizeRule],
146
147    /// `true` when the endpoint declared `#[authorize(dynamic)]`.
148    ///
149    /// In that mode the static gate only requires an authenticated principal
150    /// (no claims → 401), and the rules in [`Self::authorization`] are handed to
151    /// the registered [`IDynamicAuthorizer`](crate::auth::IDynamicAuthorizer)s,
152    /// which decide. This is what lets "the database is the source of truth for
153    /// permissions" work without a login-time claims snapshot.
154    ///
155    /// `false` for every other endpoint, including public ones: the rules are
156    /// then enforced statically, exactly as before 1.3.0.
157    pub authorization_dynamic: bool,
158}
159
160// Collect RouteEntry instances at compile time using `inventory`.
161inventory::collect!(RouteEntry);
162
163/// Optional query/path/body metadata for a request type, collected via
164/// `#[derive(WebxRequestMeta)]` on the request struct.
165#[derive(Debug, Clone)]
166pub struct RequestParamEntry {
167    /// Request type name, kept for diagnostics and for the legacy name lookup.
168    ///
169    /// The bare name is not unique (qualified paths, same-named types in
170    /// different modules), so consumers must prefer [`Self::req_type_id`].
171    pub request_type: &'static str,
172
173    /// Compile-time identity of the request type — the authoritative key.
174    ///
175    /// The derive is the only producer of real values. Hand-written entries can
176    /// use `TypeId::of::<()>()` as the "unknown" sentinel and fall back to the
177    /// name.
178    pub req_type_id: std::any::TypeId,
179
180    pub params: &'static [ParamMeta],
181}
182
183inventory::collect!(RequestParamEntry);
184
185/// Handler registration collected at compile time.
186/// Each `#[handler]` annotation submits one of these to inventory.
187///
188/// The factory is called **per request** with the request-scoped `IServiceResolver`
189/// so it can resolve Scoped dependencies (e.g. `DbContext`) via `get_owned`.
190/// The result is an owned, type-erased handler — no `Arc<dyn Trait>` caching,
191/// so `handle(&mut self)` works without interior mutability.
192pub struct HandlerRegistration {
193    /// Compile-time type id of the request type — primary dispatch key.
194    pub req_type_id: std::any::TypeId,
195    /// Request type name (e.g., "HelloRequest") — used for diagnostics and route matching.
196    pub req_type_name: &'static str,
197    /// Constructs a fresh owned handler, boxed as `Box<dyn Any + Send>`.
198    /// Called per-request with the per-request scope as resolver.
199    pub factory:
200        fn(&dyn rust_dix::IServiceResolver) -> crate::error::Result<Box<dyn std::any::Any + Send>>,
201    /// Type-erased call bridge: receives the owned handler and the boxed request,
202    /// invokes `handle(&mut self, req)`, and returns the boxed response.
203    #[allow(clippy::type_complexity)]
204    pub call: fn(
205        handler: Box<dyn std::any::Any + Send>,
206        request: Box<dyn std::any::Any + Send>,
207    ) -> std::pin::Pin<
208        Box<
209            dyn std::future::Future<Output = crate::error::Result<Box<dyn std::any::Any + Send>>>
210                + Send,
211        >,
212    >,
213}
214
215inventory::collect!(HandlerRegistration);
216
217/// The signature every route dispatch function implements.
218///
219/// It receives the live request context — headers, route parameters, claims and
220/// the body stream — and returns the response to write.
221pub type RouteDispatchFn = fn(
222    ctx: &mut dyn crate::http::IHttpContext,
223) -> std::pin::Pin<
224    Box<
225        dyn std::future::Future<Output = crate::error::Result<crate::action::Response>> + Send + '_,
226    >,
227>;
228
229/// A dispatch function registered at compile time via the endpoint macros.
230///
231/// The function receives the live request context, so it can inspect headers,
232/// stream the body as `multipart/form-data`, and produce any
233/// [`Response`](crate::action::Response) — including streamed file downloads.
234pub struct RouteDispatch {
235    pub handler_type: &'static str,
236    pub dispatch: RouteDispatchFn,
237}
238
239inventory::collect!(RouteDispatch);
240
241impl RouteEntry {
242    /// Create a new route entry without a request type identity.
243    ///
244    /// Kept for hand-written entries and fixtures; [`RouteEntry::with_type_id`]
245    /// records the request type's `TypeId` so OpenAPI can index its parameter
246    /// metadata unambiguously.
247    #[allow(clippy::too_many_arguments)]
248    pub const fn new(
249        method: HttpMethod,
250        path: &'static str,
251        handler_type: &'static str,
252        rsp_type: &'static str,
253        name: TextRef,
254        description: TextRef,
255        category: TextRef,
256        params: &'static [ParamMeta],
257        authorization: &'static [AuthorizeRule],
258    ) -> Self {
259        Self {
260            method,
261            path,
262            req_type_id: std::any::TypeId::of::<()>(),
263            handler_type,
264            rsp_type,
265            name,
266            description,
267            category,
268            params,
269            authorization,
270            authorization_dynamic: false,
271        }
272    }
273
274    /// Create a new route entry carrying its request type's `TypeId`.
275    ///
276    /// The endpoint macros use this so OpenAPI resolves parameter metadata by
277    /// type instead of by a name string.
278    #[allow(clippy::too_many_arguments)]
279    pub const fn with_type_id(
280        method: HttpMethod,
281        path: &'static str,
282        req_type_id: std::any::TypeId,
283        handler_type: &'static str,
284        rsp_type: &'static str,
285        name: TextRef,
286        description: TextRef,
287        category: TextRef,
288        params: &'static [ParamMeta],
289        authorization: &'static [AuthorizeRule],
290    ) -> Self {
291        Self {
292            method,
293            path,
294            req_type_id,
295            handler_type,
296            rsp_type,
297            name,
298            description,
299            category,
300            params,
301            authorization,
302            authorization_dynamic: false,
303        }
304    }
305
306    /// Switch this entry to the `#[authorize(dynamic)]` mode.
307    ///
308    /// The endpoint macros emit this only when the declaration contains
309    /// `dynamic`; hand-written fixtures can chain it the same way:
310    /// `RouteEntry::new(....).with_dynamic_authorization()`.
311    ///
312    /// Const so it stays usable in `inventory::submit!`.
313    pub const fn with_dynamic_authorization(mut self) -> Self {
314        self.authorization_dynamic = true;
315        self
316    }
317}
318
319// --- Handler Cache ---
320
321/// Boxed future returned by a type-erased handler dispatch.
322pub type BoxedDispatchFuture = std::pin::Pin<
323    Box<
324        dyn std::future::Future<Output = crate::error::Result<Box<dyn std::any::Any + Send>>>
325            + Send,
326    >,
327>;
328
329/// Type-erased dispatch function for one request type.
330///
331/// `#[handler]` generates one per `impl IRequestHandler<T, R>`: it downcasts the
332/// boxed request to `T`, runs the typed dispatch (pipeline + handler) and boxes
333/// the `R` response. This is what lets `IMediator::send_boxed` work through
334/// `Arc<dyn IMediator>` without knowing `T`/`R` at the call site.
335pub type BoxedDispatchFn = fn(
336    std::sync::Arc<rust_dix::ServiceProvider>,
337    std::sync::Arc<HandlerCache>,
338    Box<dyn std::any::Any + Send>,
339) -> BoxedDispatchFuture;
340
341/// Inventory item tying a request type to its type-erased dispatch function.
342pub struct BoxedDispatch {
343    /// The request type's `TypeId`.
344    pub req_type_id: std::any::TypeId,
345    /// Dispatch for that request type.
346    pub dispatch: BoxedDispatchFn,
347}
348
349inventory::collect!(BoxedDispatch);
350
351/// Runtime registry of compiled handler registrations.
352///
353/// Built at startup from `HandlerRegistration` inventory items.
354/// Maps request type name to handler entry (factory + call bridge).
355///
356/// Unlike the previous design, entries do **not** cache a handler instance:
357/// the factory is invoked per request with the request-scoped resolver so
358/// Scoped dependencies (DbContext) are freshly owned each time.
359pub struct HandlerCache {
360    pub entries: HashMap<&'static str, HandlerEntry>,
361    pub entries_by_id: HashMap<std::any::TypeId, HandlerEntry>,
362    boxed_dispatch: HashMap<std::any::TypeId, BoxedDispatchFn>,
363}
364
365impl HandlerCache {
366    /// Build the cache from all `HandlerRegistration` inventory items.
367    pub fn build() -> Self {
368        let mut entries = HashMap::new();
369        let mut entries_by_id = HashMap::new();
370        for reg in inventory::iter::<HandlerRegistration> {
371            let entry = HandlerEntry {
372                factory: reg.factory,
373                call: reg.call,
374            };
375            entries.insert(reg.req_type_name, entry.clone());
376            entries_by_id.insert(reg.req_type_id, entry);
377        }
378        let mut boxed_dispatch = HashMap::new();
379        for reg in inventory::iter::<BoxedDispatch> {
380            boxed_dispatch.insert(reg.req_type_id, reg.dispatch);
381        }
382        Self {
383            entries,
384            entries_by_id,
385            boxed_dispatch,
386        }
387    }
388
389    /// Look up a handler entry by request type name (route / OpenAPI diagnostics).
390    pub fn get(&self, req_type_name: &str) -> Option<&HandlerEntry> {
391        self.entries.get(req_type_name)
392    }
393
394    /// Look up a handler entry by request type id (Mediator / in-process dispatch).
395    pub fn get_by_type_id(&self, type_id: std::any::TypeId) -> Option<&HandlerEntry> {
396        self.entries_by_id.get(&type_id)
397    }
398
399    /// Type-erased dispatch for `IMediator::send_boxed`.
400    ///
401    /// `None` when the request type has no `#[handler]` (or a hand-written
402    /// handler registration without the boxed bridge).
403    pub fn boxed_dispatch(&self, type_id: std::any::TypeId) -> Option<BoxedDispatchFn> {
404        self.boxed_dispatch.get(&type_id).copied()
405    }
406}
407
408/// Preferred name for [`HandlerCache`] — emphasizes registry semantics over caching.
409pub type HandlerRegistry = HandlerCache;
410
411/// A single compiled handler entry in the registry.
412///
413/// Stores only the factory and call bridge — no cached instance.
414/// The factory is called per request to produce a fresh owned handler.
415#[derive(Clone)]
416pub struct HandlerEntry {
417    /// Per-request factory: receives the scoped resolver, returns an owned handler.
418    pub factory: fn(&dyn rust_dix::IServiceResolver) -> crate::error::Result<Box<dyn Any + Send>>,
419    /// Type-erased call bridge: invokes `handle(&mut self, req)` on the owned handler.
420    #[allow(clippy::type_complexity)]
421    pub call: fn(
422        handler: Box<dyn Any + Send>,
423        request: Box<dyn Any + Send>,
424    ) -> std::pin::Pin<
425        Box<dyn std::future::Future<Output = crate::error::Result<Box<dyn Any + Send>>> + Send>,
426    >,
427}
428
429#[cfg(test)]
430mod tests {
431    use super::{AuthorizeRule, RouteEntry};
432    use crate::localization::TextRef;
433    use crate::routing::HttpMethod;
434
435    const AUTHENTICATED: &[AuthorizeRule] = &[AuthorizeRule::authenticated()];
436
437    const STATIC_ENTRY: RouteEntry = RouteEntry::new(
438        HttpMethod::Get,
439        "/static",
440        "StaticRequest",
441        "()",
442        TextRef::text("static"),
443        TextRef::text(""),
444        TextRef::text(""),
445        &[],
446        AUTHENTICATED,
447    );
448
449    const DYNAMIC_ENTRY: RouteEntry = STATIC_ENTRY.with_dynamic_authorization();
450
451    #[test]
452    fn route_entry_defaults_to_static_authorization() {
453        // Bind to a local so clippy keeps treating this as a runtime assertion
454        // (the value is a constant, but the intent is to test the default).
455        let entry: RouteEntry = STATIC_ENTRY;
456        assert!(
457            !entry.authorization_dynamic,
458            "hand-written entries and `RouteEntry::new` must stay static"
459        );
460        assert_eq!(entry.authorization, AUTHENTICATED);
461    }
462
463    #[test]
464    fn with_dynamic_authorization_flips_only_the_flag() {
465        let dynamic: RouteEntry = DYNAMIC_ENTRY;
466        let statik: RouteEntry = STATIC_ENTRY;
467        assert!(dynamic.authorization_dynamic);
468        assert_eq!(
469            dynamic.authorization, statik.authorization,
470            "the declared rules are untouched — they move to the dynamic authorizer"
471        );
472        assert_eq!(dynamic.path, "/static");
473    }
474
475    /// A response DTO that cannot be represented in JSON — the stand-in for the
476    /// server-side defect this mapping exists to catch.
477    struct AlwaysFails;
478
479    impl serde::Serialize for AlwaysFails {
480        fn serialize<S: serde::Serializer>(
481            &self,
482            _serializer: S,
483        ) -> std::result::Result<S::Ok, S::Error> {
484            Err(serde::ser::Error::custom("DTO cannot be serialized"))
485        }
486    }
487
488    /// A response that discards everything: enough for the serialization path.
489    struct DiscardResponse;
490
491    #[async_trait::async_trait]
492    impl crate::http::IHttpResponse for DiscardResponse {
493        fn status(&self) -> u16 {
494            200
495        }
496        fn set_status(&mut self, _code: u16) {}
497        fn set_header(&mut self, _key: &str, _value: &str) {}
498        async fn write_bytes(&mut self, _data: Vec<u8>) -> crate::error::Result<()> {
499            Ok(())
500        }
501    }
502
503    #[tokio::test]
504    async fn json_response_serialization_failure_is_a_server_error() {
505        let err = crate::http::write_json_response(&mut DiscardResponse, &AlwaysFails)
506            .await
507            .unwrap_err();
508        assert!(
509            matches!(err, crate::error::Error::ResponseSerialization(_)),
510            "response serialization must not reuse the request-parse variant: {err:?}"
511        );
512        assert_eq!(err.status_code(), 500);
513        assert_eq!(err.public_detail(), None);
514    }
515}