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}