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}