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    pub authorization: &'static [AuthorizeRule],
142}
143
144// Collect RouteEntry instances at compile time using `inventory`.
145inventory::collect!(RouteEntry);
146
147/// Optional query/path/body metadata for a request type, collected via
148/// `#[derive(WebxRequestMeta)]` on the request struct.
149#[derive(Debug, Clone)]
150pub struct RequestParamEntry {
151    /// Request type name, kept for diagnostics and for the legacy name lookup.
152    ///
153    /// The bare name is not unique (qualified paths, same-named types in
154    /// different modules), so consumers must prefer [`Self::req_type_id`].
155    pub request_type: &'static str,
156
157    /// Compile-time identity of the request type — the authoritative key.
158    ///
159    /// The derive is the only producer of real values. Hand-written entries can
160    /// use `TypeId::of::<()>()` as the "unknown" sentinel and fall back to the
161    /// name.
162    pub req_type_id: std::any::TypeId,
163
164    pub params: &'static [ParamMeta],
165}
166
167inventory::collect!(RequestParamEntry);
168
169/// Handler registration collected at compile time.
170/// Each `#[handler]` annotation submits one of these to inventory.
171///
172/// The factory is called **per request** with the request-scoped `IServiceResolver`
173/// so it can resolve Scoped dependencies (e.g. `DbContext`) via `get_owned`.
174/// The result is an owned, type-erased handler — no `Arc<dyn Trait>` caching,
175/// so `handle(&mut self)` works without interior mutability.
176pub struct HandlerRegistration {
177    /// Compile-time type id of the request type — primary dispatch key.
178    pub req_type_id: std::any::TypeId,
179    /// Request type name (e.g., "HelloRequest") — used for diagnostics and route matching.
180    pub req_type_name: &'static str,
181    /// Constructs a fresh owned handler, boxed as `Box<dyn Any + Send>`.
182    /// Called per-request with the per-request scope as resolver.
183    pub factory:
184        fn(&dyn rust_dix::IServiceResolver) -> crate::error::Result<Box<dyn std::any::Any + Send>>,
185    /// Type-erased call bridge: receives the owned handler and the boxed request,
186    /// invokes `handle(&mut self, req)`, and returns the boxed response.
187    #[allow(clippy::type_complexity)]
188    pub call: fn(
189        handler: Box<dyn std::any::Any + Send>,
190        request: Box<dyn std::any::Any + Send>,
191    ) -> std::pin::Pin<
192        Box<
193            dyn std::future::Future<Output = crate::error::Result<Box<dyn std::any::Any + Send>>>
194                + Send,
195        >,
196    >,
197}
198
199inventory::collect!(HandlerRegistration);
200
201/// The signature every route dispatch function implements.
202///
203/// It receives the live request context — headers, route parameters, claims and
204/// the body stream — and returns the response to write.
205pub type RouteDispatchFn = fn(
206    ctx: &mut dyn crate::http::IHttpContext,
207) -> std::pin::Pin<
208    Box<
209        dyn std::future::Future<Output = crate::error::Result<crate::action::Response>> + Send + '_,
210    >,
211>;
212
213/// A dispatch function registered at compile time via the endpoint macros.
214///
215/// The function receives the live request context, so it can inspect headers,
216/// stream the body as `multipart/form-data`, and produce any
217/// [`Response`](crate::action::Response) — including streamed file downloads.
218pub struct RouteDispatch {
219    pub handler_type: &'static str,
220    pub dispatch: RouteDispatchFn,
221}
222
223inventory::collect!(RouteDispatch);
224
225impl RouteEntry {
226    /// Create a new route entry without a request type identity.
227    ///
228    /// Kept for hand-written entries and fixtures; [`RouteEntry::with_type_id`]
229    /// records the request type's `TypeId` so OpenAPI can index its parameter
230    /// metadata unambiguously.
231    #[allow(clippy::too_many_arguments)]
232    pub const fn new(
233        method: HttpMethod,
234        path: &'static str,
235        handler_type: &'static str,
236        rsp_type: &'static str,
237        name: TextRef,
238        description: TextRef,
239        category: TextRef,
240        params: &'static [ParamMeta],
241        authorization: &'static [AuthorizeRule],
242    ) -> Self {
243        Self {
244            method,
245            path,
246            req_type_id: std::any::TypeId::of::<()>(),
247            handler_type,
248            rsp_type,
249            name,
250            description,
251            category,
252            params,
253            authorization,
254        }
255    }
256
257    /// Create a new route entry carrying its request type's `TypeId`.
258    ///
259    /// The endpoint macros use this so OpenAPI resolves parameter metadata by
260    /// type instead of by a name string.
261    #[allow(clippy::too_many_arguments)]
262    pub const fn with_type_id(
263        method: HttpMethod,
264        path: &'static str,
265        req_type_id: std::any::TypeId,
266        handler_type: &'static str,
267        rsp_type: &'static str,
268        name: TextRef,
269        description: TextRef,
270        category: TextRef,
271        params: &'static [ParamMeta],
272        authorization: &'static [AuthorizeRule],
273    ) -> Self {
274        Self {
275            method,
276            path,
277            req_type_id,
278            handler_type,
279            rsp_type,
280            name,
281            description,
282            category,
283            params,
284            authorization,
285        }
286    }
287}
288
289// --- Handler Cache ---
290
291/// Boxed future returned by a type-erased handler dispatch.
292pub type BoxedDispatchFuture = std::pin::Pin<
293    Box<
294        dyn std::future::Future<Output = crate::error::Result<Box<dyn std::any::Any + Send>>>
295            + Send,
296    >,
297>;
298
299/// Type-erased dispatch function for one request type.
300///
301/// `#[handler]` generates one per `impl IRequestHandler<T, R>`: it downcasts the
302/// boxed request to `T`, runs the typed dispatch (pipeline + handler) and boxes
303/// the `R` response. This is what lets `IMediator::send_boxed` work through
304/// `Arc<dyn IMediator>` without knowing `T`/`R` at the call site.
305pub type BoxedDispatchFn = fn(
306    std::sync::Arc<rust_dix::ServiceProvider>,
307    std::sync::Arc<HandlerCache>,
308    Box<dyn std::any::Any + Send>,
309) -> BoxedDispatchFuture;
310
311/// Inventory item tying a request type to its type-erased dispatch function.
312pub struct BoxedDispatch {
313    /// The request type's `TypeId`.
314    pub req_type_id: std::any::TypeId,
315    /// Dispatch for that request type.
316    pub dispatch: BoxedDispatchFn,
317}
318
319inventory::collect!(BoxedDispatch);
320
321/// Runtime registry of compiled handler registrations.
322///
323/// Built at startup from `HandlerRegistration` inventory items.
324/// Maps request type name to handler entry (factory + call bridge).
325///
326/// Unlike the previous design, entries do **not** cache a handler instance:
327/// the factory is invoked per request with the request-scoped resolver so
328/// Scoped dependencies (DbContext) are freshly owned each time.
329pub struct HandlerCache {
330    pub entries: HashMap<&'static str, HandlerEntry>,
331    pub entries_by_id: HashMap<std::any::TypeId, HandlerEntry>,
332    boxed_dispatch: HashMap<std::any::TypeId, BoxedDispatchFn>,
333}
334
335impl HandlerCache {
336    /// Build the cache from all `HandlerRegistration` inventory items.
337    pub fn build() -> Self {
338        let mut entries = HashMap::new();
339        let mut entries_by_id = HashMap::new();
340        for reg in inventory::iter::<HandlerRegistration> {
341            let entry = HandlerEntry {
342                factory: reg.factory,
343                call: reg.call,
344            };
345            entries.insert(reg.req_type_name, entry.clone());
346            entries_by_id.insert(reg.req_type_id, entry);
347        }
348        let mut boxed_dispatch = HashMap::new();
349        for reg in inventory::iter::<BoxedDispatch> {
350            boxed_dispatch.insert(reg.req_type_id, reg.dispatch);
351        }
352        Self {
353            entries,
354            entries_by_id,
355            boxed_dispatch,
356        }
357    }
358
359    /// Look up a handler entry by request type name (route / OpenAPI diagnostics).
360    pub fn get(&self, req_type_name: &str) -> Option<&HandlerEntry> {
361        self.entries.get(req_type_name)
362    }
363
364    /// Look up a handler entry by request type id (Mediator / in-process dispatch).
365    pub fn get_by_type_id(&self, type_id: std::any::TypeId) -> Option<&HandlerEntry> {
366        self.entries_by_id.get(&type_id)
367    }
368
369    /// Type-erased dispatch for `IMediator::send_boxed`.
370    ///
371    /// `None` when the request type has no `#[handler]` (or a hand-written
372    /// handler registration without the boxed bridge).
373    pub fn boxed_dispatch(&self, type_id: std::any::TypeId) -> Option<BoxedDispatchFn> {
374        self.boxed_dispatch.get(&type_id).copied()
375    }
376}
377
378/// Preferred name for [`HandlerCache`] — emphasizes registry semantics over caching.
379pub type HandlerRegistry = HandlerCache;
380
381/// A single compiled handler entry in the registry.
382///
383/// Stores only the factory and call bridge — no cached instance.
384/// The factory is called per request to produce a fresh owned handler.
385#[derive(Clone)]
386pub struct HandlerEntry {
387    /// Per-request factory: receives the scoped resolver, returns an owned handler.
388    pub factory: fn(&dyn rust_dix::IServiceResolver) -> crate::error::Result<Box<dyn Any + Send>>,
389    /// Type-erased call bridge: invokes `handle(&mut self, req)` on the owned handler.
390    #[allow(clippy::type_complexity)]
391    pub call: fn(
392        handler: Box<dyn Any + Send>,
393        request: Box<dyn Any + Send>,
394    ) -> std::pin::Pin<
395        Box<dyn std::future::Future<Output = crate::error::Result<Box<dyn Any + Send>>> + Send>,
396    >,
397}
398
399#[cfg(test)]
400mod tests {
401    /// A response DTO that cannot be represented in JSON — the stand-in for the
402    /// server-side defect this mapping exists to catch.
403    struct AlwaysFails;
404
405    impl serde::Serialize for AlwaysFails {
406        fn serialize<S: serde::Serializer>(
407            &self,
408            _serializer: S,
409        ) -> std::result::Result<S::Ok, S::Error> {
410            Err(serde::ser::Error::custom("DTO cannot be serialized"))
411        }
412    }
413
414    /// A response that discards everything: enough for the serialization path.
415    struct DiscardResponse;
416
417    #[async_trait::async_trait]
418    impl crate::http::IHttpResponse for DiscardResponse {
419        fn status(&self) -> u16 {
420            200
421        }
422        fn set_status(&mut self, _code: u16) {}
423        fn set_header(&mut self, _key: &str, _value: &str) {}
424        async fn write_bytes(&mut self, _data: Vec<u8>) -> crate::error::Result<()> {
425            Ok(())
426        }
427    }
428
429    #[tokio::test]
430    async fn json_response_serialization_failure_is_a_server_error() {
431        let err = crate::http::write_json_response(&mut DiscardResponse, &AlwaysFails)
432            .await
433            .unwrap_err();
434        assert!(
435            matches!(err, crate::error::Error::ResponseSerialization(_)),
436            "response serialization must not reuse the request-parse variant: {err:?}"
437        );
438        assert_eq!(err.status_code(), 500);
439        assert_eq!(err.public_detail(), None);
440    }
441}