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