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