actus_server/router.rs
1//! [`Router`] and [`RouterBuilder`] — the longest-prefix route tree that maps
2//! a request path to the controller mounted at its deepest matching prefix.
3
4use crate::host::{AliasHit, HostAlias, HostAliasError, request_host};
5use actus_controller::{Controller, Params, RouteDef};
6use actus_reply::{Reply, WebError};
7use http::{HeaderMap, Uri};
8use std::collections::HashMap;
9use std::sync::Arc;
10use tracing::{debug, warn};
11
12/// A node in the routing tree, representing a segment of a URL path.
13#[derive(Default)]
14struct RouteNode {
15 /// Child nodes for sub-paths (e.g., "api" -> "v2").
16 children: HashMap<String, RouteNode>,
17 /// A controller mounted at this exact path. Because routing is
18 /// longest-prefix, a mounted controller also handles every path *below*
19 /// it that no deeper mount claims — it receives the unconsumed path as
20 /// its "action". So a single mount at `"foo"` serves `/foo`, `/foo/x`,
21 /// `/foo/x/y`, … unless something is mounted deeper.
22 controller: Option<Arc<dyn Controller>>,
23}
24
25/// The result of [`Router::match_controller`]: the matched controller and
26/// the leftover path segments joined as the controller's action. Returned
27/// from a path-only resolve (no verb / parameter checks; those happen
28/// inside the controller's dispatch).
29#[derive(Clone)]
30pub struct RouteMatch {
31 /// The controller mounted at the matched longest prefix.
32 pub controller: Arc<dyn Controller>,
33 /// The unconsumed path below that prefix, joined with `/` — the
34 /// controller's "action".
35 pub action: String,
36}
37
38/// One controller's declared rate-limit class, as returned by
39/// [`Router::rate_limit_classes`]: the controller's `mount` path and the
40/// `class` label it declared via `#[controller(rate_limit = "…")]`. Used by a
41/// startup coverage check that asserts every declared class has a policy.
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct RateLimitClass {
44 /// The controller's mount path (e.g. `"api/auth"`).
45 pub mount: String,
46 /// The rate-limit class label the controller declared.
47 pub class: &'static str,
48}
49
50/// One mounted controller's declarations — a row of [`Router::mounts`], the
51/// per-mount inventory.
52///
53/// Framework-populated: application code reads these rows, it never
54/// constructs them — which is why the struct is `#[non_exhaustive]` and can
55/// grow new fields in minor releases. Destructure with `..` or read fields
56/// directly.
57///
58/// Unlike [`Router::rate_limit_classes`], the inventory emits a row for
59/// **every** mounted controller: a controller that declared nothing appears
60/// with `expects: None` / `prepare: None`, because for route-family coverage
61/// the *omission* is the interesting case and has to be representable before
62/// it can be caught.
63#[non_exhaustive]
64#[derive(Clone, Debug, PartialEq, Eq)]
65pub struct Mount {
66 /// The controller's mount path — slash-joined segments, no leading or
67 /// trailing slash; `""` for a root (or `"*"`) mount. Same convention as
68 /// [`Router::routes`].
69 pub mount: String,
70 /// The controller's type name ([`Controller::__name`]).
71 pub controller: &'static str,
72 /// The declared caller expectation — the controller's *floor* — from
73 /// `#[controller(expects = "…")]`. `None` means the controller declared
74 /// nothing.
75 pub expects: Option<&'static str>,
76 /// The `prepare` hook's written path (e.g. `"Self::auth"`) from
77 /// `#[controller(prepare = …)]`, or `None` when there is no hook.
78 /// Presence is the payload; the string is for route dumps.
79 pub prepare: Option<&'static str>,
80 /// The declared rate-limit class from `#[controller(rate_limit = "…")]`.
81 pub rate_limit_class: Option<&'static str>,
82 /// The per-controller body cap from `#[controller(max_body_bytes = …)]`;
83 /// `None` means the controller defers to the server-wide cap.
84 pub max_body_bytes: Option<usize>,
85}
86
87/// The Actus router. Dispatches requests to controllers by longest-prefix
88/// match over the route tree at arbitrary depth.
89pub struct Router {
90 root: RouteNode,
91 /// Host aliases, in declaration order — see [`RouterBuilder::host_alias`].
92 aliases: Vec<HostAlias>,
93}
94
95impl Router {
96 /// Find which controller a path would hit and the leftover-segments
97 /// action, without invoking the controller. Returns `None` if no
98 /// controller matches (→ 404).
99 ///
100 /// The verb-level resolution (which `routes!` line the action will
101 /// dispatch to, or 405 if none match the verb) happens inside the
102 /// controller's `actus_dispatch`; this method only does the
103 /// path-tree walk.
104 ///
105 /// This is the server's own matcher: called with a request's
106 /// [`path_parts`](crate::Request::path_parts) — from a `Middleware`, say —
107 /// it returns the controller the server dispatches that request to. That
108 /// holds for a request a host alias routed, too, because the server puts
109 /// the aliased path in `path_parts` before anything else sees the request
110 /// (see [`RouterBuilder::host_alias`]).
111 pub fn match_controller(&self, path_parts: &[String]) -> Option<RouteMatch> {
112 let mut current_node = &self.root;
113 // (controller, prefix_len) — `prefix_len` is how many leading path
114 // segments the matched controller's mount point consumed.
115 let mut longest_match: Option<(Arc<dyn Controller>, usize)> = None;
116
117 // A controller mounted at the root ("" or "*") handles "/" and, as
118 // the loop below shows, anything not claimed by a deeper mount.
119 if let Some(ref controller) = current_node.controller {
120 longest_match = Some((controller.clone(), 0));
121 }
122
123 for (i, segment) in path_parts.iter().enumerate() {
124 match current_node.children.get(segment) {
125 Some(child_node) => {
126 current_node = child_node;
127 if let Some(ref controller) = current_node.controller {
128 longest_match = Some((controller.clone(), i + 1));
129 }
130 }
131 // The path diverges from the tree here; the deepest mount we
132 // passed through (held in `longest_match`) takes the rest.
133 None => break,
134 }
135 }
136
137 longest_match.map(|(controller, prefix_len)| {
138 // Action is the joined path remaining after the matched
139 // controller's mount point — all of it, so multi-segment
140 // routes like `"posts/{id}/comments"` (and `{...rest}` params)
141 // can match. Fully-consumed path → action `""`, which is how
142 // a controller registers a root handler via `routes! { "" => … }`.
143 let action: String = if prefix_len >= path_parts.len() {
144 String::new()
145 } else {
146 path_parts[prefix_len..].join("/")
147 };
148 RouteMatch { controller, action }
149 })
150 }
151
152 /// Routes a request to the appropriate controller and action: thin
153 /// wrapper around [`Router::match_controller`] that also invokes
154 /// `actus_dispatch`. Kept for callers (mostly tests) that don't need
155 /// to inspect the matched controller before dispatch — the server
156 /// uses the two-step shape so it can buffer the body with the right
157 /// cap *between* match and dispatch.
158 pub async fn route(&self, path_parts: &[String], params: Params) -> Reply {
159 match self.match_controller(path_parts) {
160 Some(rm) => {
161 debug!(action = %rm.action, "routing to controller");
162 rm.controller.actus_dispatch(&rm.action, params).await
163 }
164 None => {
165 debug!(?path_parts, "no route matched");
166 Err(WebError::NotFound)
167 }
168 }
169 }
170
171 /// Walk the route tree and return every `(mount_path, RouteDef)` pair.
172 ///
173 /// `mount_path` is the slash-joined path *segments* where the controller
174 /// was mounted (no leading or trailing slash) — `""` for a root mount,
175 /// `"api/users"` for `app_routes!{ "api/users" => UserController, ... }`,
176 /// etc. Each controller contributes one entry per `RouteDef` it
177 /// describes via [`Controller::actus_describe_routes`].
178 ///
179 /// Used by introspection tools (OpenAPI doc generators, route audit
180 /// scripts). Order matches a DFS over the route tree; within a single
181 /// controller, routes are emitted in the order
182 /// `Controller::actus_describe_routes` returns them (which is the order
183 /// they were declared in the `routes!` block).
184 pub fn routes(&self) -> Vec<(String, RouteDef)> {
185 let mut out = Vec::new();
186 let mut prefix: Vec<String> = Vec::new();
187 Self::walk(&self.root, &mut prefix, &mut out);
188 out
189 }
190
191 fn walk(node: &RouteNode, prefix: &mut Vec<String>, out: &mut Vec<(String, RouteDef)>) {
192 if let Some(controller) = &node.controller {
193 let mount = prefix.join("/");
194 for rd in controller.actus_describe_routes() {
195 out.push((mount.clone(), rd));
196 }
197 }
198 // Sorting the children gives a deterministic traversal order across
199 // runs (HashMap iteration is otherwise nondeterministic), which
200 // matters for stable OpenAPI output / test assertions.
201 let mut children: Vec<(&String, &RouteNode)> = node.children.iter().collect();
202 children.sort_by(|a, b| a.0.cmp(b.0));
203 for (seg, child) in children {
204 prefix.push(seg.clone());
205 Self::walk(child, prefix, out);
206 prefix.pop();
207 }
208 }
209
210 /// Walk the route tree and return `(mount_path, class)` for every mounted
211 /// controller that declared a rate-limit class via
212 /// `#[controller(rate_limit = "…")]`. Controllers with no class are
213 /// skipped. `mount_path` follows the same convention as [`Router::routes`]
214 /// (`""` for a root mount); order is a deterministic DFS (children sorted),
215 /// so diagnostics and tests get stable output.
216 ///
217 /// This exposes the *declared* half of the rate-limit picture — the half
218 /// only the router knows. An application's rate-limit middleware holds the
219 /// other half (the classes it has a *policy* for), so `main()` can diff the
220 /// two at startup and assert every declared class is covered. That turns a
221 /// typo'd class (`"ath"` for `"auth"`) into a boot failure instead of a
222 /// silently-unlimited controller. One tree walk; no per-request cost.
223 ///
224 /// ⚠️ **Emptiness asymmetry.** This method catches a *misspelling* and is
225 /// blind to an *omission*: a controller that declared no class produces no
226 /// row here, so nothing can notice its absence. For rate limiting that is
227 /// usually fine (an unlimited controller is usually intended). When the
228 /// omission itself is the failure you're checking for — route-family
229 /// coverage — use [`Router::mounts`], which emits a row for every mounted
230 /// controller, declared or not. (This method keeps its historical shape;
231 /// `mounts()` is the absence-inclusive inventory.)
232 pub fn rate_limit_classes(&self) -> Vec<RateLimitClass> {
233 let mut out = Vec::new();
234 let mut prefix: Vec<String> = Vec::new();
235 Self::walk_classes(&self.root, &mut prefix, &mut out);
236 out
237 }
238
239 fn walk_classes(node: &RouteNode, prefix: &mut Vec<String>, out: &mut Vec<RateLimitClass>) {
240 if let Some(controller) = &node.controller
241 && let Some(class) = controller.actus_rate_limit()
242 {
243 out.push(RateLimitClass {
244 mount: prefix.join("/"),
245 class,
246 });
247 }
248 let mut children: Vec<(&String, &RouteNode)> = node.children.iter().collect();
249 children.sort_by(|a, b| a.0.cmp(b.0));
250 for (seg, child) in children {
251 prefix.push(seg.clone());
252 Self::walk_classes(child, prefix, out);
253 prefix.pop();
254 }
255 }
256
257 /// Walk the route tree and return one [`Mount`] row per mounted
258 /// controller — the per-mount inventory: mount path, controller name,
259 /// declared caller expectation (*floor*), `prepare` hook presence,
260 /// rate-limit class, and body cap.
261 ///
262 /// **Absence is a row, not a skip.** A controller that declared nothing
263 /// appears with `expects: None`, which is what makes a route-family
264 /// coverage check possible: the check's job is precisely to catch the
265 /// controller that silently declared nothing under a prefix whose other
266 /// controllers all did. (Contrast [`Router::rate_limit_classes`], which
267 /// emits only declaring controllers.)
268 ///
269 /// Order is a deterministic DFS (children sorted), matching
270 /// [`Router::routes`]; `mount` follows the same convention (`""` for a
271 /// root mount). One tree walk; no per-request cost. Typical use: a
272 /// startup coverage check in `main()`, a route dump, or building a
273 /// `mount → floor` map for a declaration-keyed gate — see the README's
274 /// "Route families" section.
275 pub fn mounts(&self) -> Vec<Mount> {
276 let mut out = Vec::new();
277 let mut prefix: Vec<String> = Vec::new();
278 Self::walk_mounts(&self.root, &mut prefix, &mut out);
279 out
280 }
281
282 /// Whether any host alias is registered. When none is, the server does no
283 /// host work at all — it does not even read `Host`.
284 #[inline]
285 pub(crate) fn has_host_aliases(&self) -> bool {
286 !self.aliases.is_empty()
287 }
288
289 /// The first alias, in declaration order, that names the host this
290 /// request is addressed to — read from the hyper request's URI and
291 /// headers, because an absolute-form request's authority lives in the URI,
292 /// which the `Request` skeleton does not keep.
293 pub(crate) fn host_alias_hit(&self, uri: &Uri, headers: &HeaderMap) -> Option<AliasHit<'_>> {
294 let host = request_host(uri, headers)?;
295 self.aliases.iter().find_map(|alias| alias.hit(host))
296 }
297
298 fn walk_mounts(node: &RouteNode, prefix: &mut Vec<String>, out: &mut Vec<Mount>) {
299 if let Some(controller) = &node.controller {
300 out.push(Mount {
301 mount: prefix.join("/"),
302 controller: controller.__name(),
303 expects: controller.actus_expects(),
304 prepare: controller.actus_prepare(),
305 rate_limit_class: controller.actus_rate_limit(),
306 max_body_bytes: controller.actus_max_body_bytes(),
307 });
308 }
309 let mut children: Vec<(&String, &RouteNode)> = node.children.iter().collect();
310 children.sort_by(|a, b| a.0.cmp(b.0));
311 for (seg, child) in children {
312 prefix.push(seg.clone());
313 Self::walk_mounts(child, prefix, out);
314 prefix.pop();
315 }
316 }
317}
318
319/// A builder for constructing the router.
320#[derive(Default)]
321pub struct RouterBuilder {
322 root: RouteNode,
323 aliases: Vec<HostAlias>,
324}
325
326impl RouterBuilder {
327 /// Create an empty builder with no mounts.
328 pub fn new() -> Self {
329 Self::default()
330 }
331
332 /// Adds a route, creating the necessary nodes in the tree.
333 ///
334 /// A **trailing `*` segment is optional sugar** for mounting the
335 /// controller at the prefix before it: `"foo/*"` routes identically to
336 /// `"foo"`, and `"*"` identically to `""` (the root) — a mounted
337 /// controller already receives the entire unconsumed path as its action,
338 /// so a "catch-all" is just a mount. The `*` is there so a reader of
339 /// `app_routes!` can see "this controller is the catch-all here" at a
340 /// glance. A `*` anywhere but the last segment is meaningless; such a
341 /// route is dropped with a warning.
342 pub fn add_route(mut self, path: &str, controller: Arc<dyn Controller>) -> Self {
343 let path = path.trim_matches('/');
344 let parts: Vec<&str> = if path.is_empty() {
345 Vec::new()
346 } else {
347 path.split('/').collect()
348 };
349
350 // Drop a trailing `*` (it's sugar — see the doc comment).
351 let segments: &[&str] = match parts.split_last() {
352 Some((last, head)) if *last == "*" => head,
353 _ => parts.as_slice(),
354 };
355
356 if segments.contains(&"*") {
357 warn!(
358 route = path,
359 "'*' is only meaningful as the last segment of a route path; ignoring route"
360 );
361 return self;
362 }
363
364 let mut current_node = &mut self.root;
365 for part in segments {
366 current_node = current_node
367 .children
368 .entry((*part).to_string())
369 .or_default();
370 }
371 // Surface accidental double-mounts loudly instead of silently
372 // letting the later registration overwrite the earlier one. The
373 // most common cause is two `app_routes!` entries with the same
374 // path literal — easy to introduce when copy-pasting versioned
375 // mounts (`api/v1/x`, `api/v2/x`, …).
376 if let Some(prev) = ¤t_node.controller {
377 warn!(
378 route = path,
379 previous = prev.__name(),
380 new = controller.__name(),
381 "duplicate route mount: the later controller overwrites the earlier one",
382 );
383 }
384 current_node.controller = Some(controller);
385 self
386 }
387
388 /// Make a host a name for a mounted controller — a **host alias**.
389 ///
390 /// A request whose host `host` names is routed as the request for
391 /// `target` followed by the request's own path: on `acme.example.com`,
392 /// with `host` `"{tenant}.example.com"` and `target`
393 /// `"tenants/{tenant}"`, `GET /orders/7` is routed as
394 /// `GET /tenants/acme/orders/7`. The controller mounted at `tenants`
395 /// receives the action `acme/orders/7`, and its own routes, typed
396 /// parameters, verb rules, body cap, rate-limit class and floor apply as
397 /// for any request. Once it reaches the controller, it is regular routing.
398 ///
399 /// - **`host`** — dot-separated labels, each a literal hostname label or a
400 /// `{name}` capture. Matching ignores ASCII case, the port and a trailing
401 /// dot, and reads an absolute-form request's authority in preference to
402 /// `Host` (RFC 9112 §3.2.2). A capture matches exactly one label of
403 /// letters, digits and hyphens, and is filled in lowercase. `None` means
404 /// the alias does not exist in this deployment: the entry is still
405 /// checked, but nothing is registered.
406 /// - **`target`** — a mounted controller's path, then one `{name}` segment
407 /// per capture of `host`. It must name a mount exactly, so every aliased
408 /// path reaches that controller.
409 /// - **`shares`** — other mounts that keep their own paths on the aliased
410 /// host (`/assets/app.js` there is the app's `/assets/app.js`). Each must
411 /// be exactly a mount, and none may contain the aliased one.
412 ///
413 /// On an aliased host nothing else is reachable: every path outside the
414 /// shared mounts goes under `target`. Aliases are tried in the order they
415 /// were added and the first that names the host wins; a host that none
416 /// names is routed exactly as without aliases.
417 ///
418 /// **One address.** The server applies the alias before anything else
419 /// sees the request, so [`Request::path_parts`](crate::Request::path_parts)
420 /// holds the aliased path (`tenants/acme/orders/7`) for the router, for
421 /// every middleware and for the handler alike, and `Host` still names the
422 /// host. A handler learns that an alias routed its request, and under
423 /// which prefix, from `Params::alias_prefix`. An aliased request is
424 /// identical, apart from `Host`, to a request any client could send to the
425 /// aliased path directly: an alias adds addresses, never capabilities.
426 ///
427 /// Add the routes first: `target` and `shares` are checked against the
428 /// mounts present when this is called. The `hosts` block of `app_routes!`
429 /// generates these calls after its routes and returns the error from
430 /// `init()`.
431 pub fn host_alias(
432 mut self,
433 host: Option<&str>,
434 target: &str,
435 shares: &[&str],
436 ) -> Result<Self, HostAliasError> {
437 let root = &self.root;
438 let alias = HostAlias::new(host, target, shares, |segments| {
439 Self::is_mount(root, segments)
440 })?;
441 self.aliases.extend(alias);
442 Ok(self)
443 }
444
445 /// Whether a controller is mounted at exactly `segments`.
446 fn is_mount(root: &RouteNode, segments: &[String]) -> bool {
447 let mut node = root;
448 for segment in segments {
449 match node.children.get(segment) {
450 Some(child) => node = child,
451 None => return false,
452 }
453 }
454 node.controller.is_some()
455 }
456
457 /// Finalize the builder into an immutable [`Router`].
458 pub fn build(self) -> Router {
459 Router {
460 root: self.root,
461 aliases: self.aliases,
462 }
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use actus_controller::Verb;
470 use bytes::Bytes;
471 use std::sync::Mutex;
472
473 /// `(mounted path, action)` pairs recorded by [`Spy`] controllers.
474 type SpyLog = Arc<Mutex<Vec<(String, String)>>>;
475
476 /// A controller that records `(its registered path, the action it got)`
477 /// into a shared log instead of doing real work, so a test can assert
478 /// which controller a request reached and with what action.
479 struct Spy {
480 path: String,
481 log: SpyLog,
482 }
483
484 #[actus_controller::async_trait]
485 impl Controller for Spy {
486 async fn actus_dispatch(&self, action: &str, _params: Params) -> Reply {
487 self.log
488 .lock()
489 .unwrap()
490 .push((self.path.clone(), action.to_string()));
491 Err(WebError::NotFound) // return value is irrelevant to these tests
492 }
493 fn __name(&self) -> &'static str {
494 "spy"
495 }
496 }
497
498 fn build(routes: &[&str]) -> (Router, SpyLog) {
499 let log = Arc::new(Mutex::new(Vec::new()));
500 let mut b = RouterBuilder::new();
501 for r in routes {
502 b = b.add_route(
503 r,
504 Arc::new(Spy {
505 path: r.to_string(),
506 log: log.clone(),
507 }),
508 );
509 }
510 (b.build(), log)
511 }
512
513 fn empty_params() -> Params {
514 Params::new(
515 Verb::GET,
516 HashMap::new(),
517 None,
518 Bytes::new(),
519 HashMap::new(),
520 )
521 }
522
523 /// Returns `Some((mounted_path, action))` for the controller the request
524 /// reached, or `None` if nothing matched (→ 404).
525 async fn hit(router: &Router, log: &SpyLog, path: &str) -> Option<(String, String)> {
526 log.lock().unwrap().clear();
527 let pp: Vec<String> = path
528 .trim_matches('/')
529 .split('/')
530 .filter(|s| !s.is_empty())
531 .map(String::from)
532 .collect();
533 let _ = router.route(&pp, empty_params()).await;
534 log.lock().unwrap().last().cloned()
535 }
536
537 fn at(path: &str, action: &str) -> Option<(String, String)> {
538 Some((path.to_string(), action.to_string()))
539 }
540
541 #[tokio::test]
542 async fn longest_prefix_wins_and_passes_the_remainder() {
543 let (r, log) = build(&["api/users", "api/users/admin"]);
544 assert_eq!(hit(&r, &log, "/api/users").await, at("api/users", ""));
545 assert_eq!(hit(&r, &log, "/api/users/42").await, at("api/users", "42"));
546 assert_eq!(
547 hit(&r, &log, "/api/users/42/posts").await,
548 at("api/users", "42/posts")
549 );
550 // a deeper mount takes precedence over a shallower one
551 assert_eq!(
552 hit(&r, &log, "/api/users/admin").await,
553 at("api/users/admin", "")
554 );
555 assert_eq!(
556 hit(&r, &log, "/api/users/admin/x").await,
557 at("api/users/admin", "x")
558 );
559 // nothing mounted at or above `/api` alone
560 assert_eq!(hit(&r, &log, "/api").await, None);
561 assert_eq!(hit(&r, &log, "/nope").await, None);
562 assert_eq!(hit(&r, &log, "/").await, None);
563 }
564
565 #[tokio::test]
566 async fn trailing_star_is_sugar_for_mounting_at_the_prefix() {
567 let (star, log_s) = build(&["api/folder/*"]);
568 let (plain, log_p) = build(&["api/folder"]);
569 for path in ["/api/folder", "/api/folder/x", "/api/folder/x/y/z"] {
570 assert_eq!(
571 hit(&star, &log_s, path).await.map(|(_, a)| a),
572 hit(&plain, &log_p, path).await.map(|(_, a)| a),
573 "`api/folder/*` and `api/folder` must route `{path}` identically"
574 );
575 }
576 // and in particular the bare prefix is matched (not a 404)
577 assert_eq!(hit(&star, &log_s, "/api/folder").await.unwrap().1, "");
578 assert_eq!(
579 hit(&star, &log_s, "/api/folder/deep/path").await.unwrap().1,
580 "deep/path"
581 );
582 }
583
584 #[tokio::test]
585 async fn root_star_is_the_global_fallback() {
586 let (r, log) = build(&["*", "api/users"]);
587 // specific routes still win
588 assert_eq!(hit(&r, &log, "/api/users").await, at("api/users", ""));
589 assert_eq!(hit(&r, &log, "/api/users/9").await, at("api/users", "9"));
590 // everything else falls to the root catch-all, with the full path
591 assert_eq!(hit(&r, &log, "/").await, at("*", ""));
592 assert_eq!(
593 hit(&r, &log, "/anything/here").await,
594 at("*", "anything/here")
595 );
596 // including a divergence partway down a known prefix
597 assert_eq!(hit(&r, &log, "/api/missing").await, at("*", "api/missing"));
598 // `""` is equivalent to `"*"`
599 let (r2, log2) = build(&["", "api/users"]);
600 assert_eq!(hit(&r2, &log2, "/whatever").await, at("", "whatever"));
601 assert_eq!(hit(&r2, &log2, "/api/users").await, at("api/users", ""));
602 }
603
604 #[test]
605 fn match_controller_returns_some_for_matched_path_and_none_for_404() {
606 // Build a router with two mounts. match_controller walks the tree
607 // and returns the deepest mounted controller's match — or None
608 // if the path doesn't reach any mount.
609 let log = Arc::new(Mutex::new(Vec::new()));
610 let router = RouterBuilder::new()
611 .add_route(
612 "api/users",
613 Arc::new(Spy {
614 path: "users".to_string(),
615 log: log.clone(),
616 }),
617 )
618 .add_route(
619 "api/users/admin",
620 Arc::new(Spy {
621 path: "admin".to_string(),
622 log: log.clone(),
623 }),
624 )
625 .build();
626
627 // Deeper mount wins for the deeper path.
628 let pp: Vec<String> = vec!["api".into(), "users".into(), "admin".into()];
629 let rm = router.match_controller(&pp).expect("matches");
630 assert_eq!(rm.action, "");
631 assert_eq!(rm.controller.__name(), "spy");
632
633 // Shallower mount catches the unconsumed remainder.
634 let pp: Vec<String> = vec!["api".into(), "users".into(), "42".into()];
635 let rm = router.match_controller(&pp).expect("matches");
636 assert_eq!(rm.action, "42");
637
638 // Diverged from the tree → None (404 territory).
639 let pp: Vec<String> = vec!["api".into(), "missing".into()];
640 assert!(router.match_controller(&pp).is_none());
641
642 // Empty path with nothing mounted at root → None.
643 let pp: Vec<String> = vec![];
644 assert!(router.match_controller(&pp).is_none());
645 }
646
647 #[tokio::test]
648 async fn duplicate_mount_overwrites_earlier_one() {
649 // Two controllers registered at the same path — the second wins.
650 // (A `tracing::warn!` fires too; we don't assert on it here, but
651 // the override behavior is what callers actually observe.)
652 let log = Arc::new(Mutex::new(Vec::new()));
653 let first = Arc::new(Spy {
654 path: "first".to_string(),
655 log: log.clone(),
656 });
657 let second = Arc::new(Spy {
658 path: "second".to_string(),
659 log: log.clone(),
660 });
661 let router = RouterBuilder::new()
662 .add_route("api/users", first)
663 .add_route("api/users", second)
664 .build();
665 let pp: Vec<String> = vec!["api".into(), "users".into()];
666 let _ = router.route(&pp, empty_params()).await;
667 assert_eq!(
668 log.lock().unwrap().last().map(|(p, _)| p.as_str()),
669 Some("second"),
670 );
671 }
672
673 /// A `Controller` that exposes a fixed slice of `RouteDef`s via
674 /// `actus_describe_routes()` — used to exercise `Router::routes()`
675 /// without depending on the `#[controller]` macro.
676 struct Described {
677 routes: &'static [RouteDef],
678 }
679
680 #[actus_controller::async_trait]
681 impl Controller for Described {
682 async fn actus_dispatch(&self, _action: &str, _params: Params) -> Reply {
683 Err(WebError::NotFound)
684 }
685 fn __name(&self) -> &'static str {
686 "described"
687 }
688 fn actus_describe_routes(&self) -> Vec<RouteDef> {
689 self.routes.to_vec()
690 }
691 }
692
693 #[tokio::test]
694 async fn routes_introspection_returns_mount_paths_and_routedefs() {
695 static USERS_ROUTES: &[RouteDef] = &[
696 RouteDef {
697 pattern: "",
698 handler_id: "handler_0",
699 handler: "list",
700 verb: &[Verb::GET],
701 params: &[],
702 doc: None,
703 },
704 RouteDef {
705 pattern: "{id}",
706 handler_id: "handler_1",
707 handler: "get",
708 verb: &[Verb::GET],
709 params: &[],
710 doc: None,
711 },
712 ];
713 static HEALTH_ROUTES: &[RouteDef] = &[RouteDef {
714 pattern: "",
715 handler_id: "handler_0",
716 handler: "ping",
717 verb: actus_controller::DEFAULT_VERBS,
718 params: &[],
719 doc: None,
720 }];
721
722 let router = RouterBuilder::new()
723 .add_route(
724 "api/users",
725 Arc::new(Described {
726 routes: USERS_ROUTES,
727 }),
728 )
729 .add_route(
730 "health",
731 Arc::new(Described {
732 routes: HEALTH_ROUTES,
733 }),
734 )
735 .build();
736
737 let pairs = router.routes();
738 // 2 routes from api/users + 1 from health = 3 total.
739 let mounts: Vec<&str> = pairs.iter().map(|(m, _)| m.as_str()).collect();
740 let handlers: Vec<&str> = pairs.iter().map(|(_, r)| r.handler).collect();
741 assert_eq!(
742 mounts,
743 vec!["api/users", "api/users", "health"],
744 "DFS sorts child segments alphabetically — 'api' before 'health'",
745 );
746 assert_eq!(handlers, vec!["list", "get", "ping"]);
747 }
748
749 /// A controller that declares a rate-limit class — overrides
750 /// `actus_rate_limit` the way the `#[controller(rate_limit = …)]` macro
751 /// would, without depending on the macro in this crate.
752 struct Classed(&'static str);
753
754 #[actus_controller::async_trait]
755 impl Controller for Classed {
756 async fn actus_dispatch(&self, _action: &str, _params: Params) -> Reply {
757 Err(WebError::NotFound)
758 }
759 fn __name(&self) -> &'static str {
760 "classed"
761 }
762 fn actus_rate_limit(&self) -> Option<&'static str> {
763 Some(self.0)
764 }
765 }
766
767 #[test]
768 fn rate_limit_classes_lists_only_classed_controllers_with_mounts() {
769 // Two classed controllers and one unclassed (`Spy`, default `None`).
770 // The walk returns the classed ones with their mounts, in sorted-DFS
771 // order ('auth' < 'health' < 'tasks'), and omits the unclassed one.
772 let log = Arc::new(Mutex::new(Vec::new()));
773 let router = RouterBuilder::new()
774 .add_route("api/auth", Arc::new(Classed("auth")))
775 .add_route(
776 "api/health",
777 Arc::new(Spy {
778 path: "health".into(),
779 log: log.clone(),
780 }),
781 )
782 .add_route("api/tasks", Arc::new(Classed("tasks")))
783 .build();
784
785 assert_eq!(
786 router.rate_limit_classes(),
787 vec![
788 RateLimitClass {
789 mount: "api/auth".to_string(),
790 class: "auth",
791 },
792 RateLimitClass {
793 mount: "api/tasks".to_string(),
794 class: "tasks",
795 },
796 ],
797 );
798 // The unclassed controller contributes nothing.
799 assert!(
800 router
801 .rate_limit_classes()
802 .iter()
803 .all(|rlc| rlc.mount != "api/health"),
804 );
805 }
806
807 /// A controller that declares everything — overrides the metadata
808 /// methods the way the `#[controller(...)]` macro would, without
809 /// depending on the macro in this crate.
810 struct Declared;
811
812 #[actus_controller::async_trait]
813 impl Controller for Declared {
814 async fn actus_dispatch(&self, _action: &str, _params: Params) -> Reply {
815 Err(WebError::NotFound)
816 }
817 fn __name(&self) -> &'static str {
818 "declared"
819 }
820 fn actus_expects(&self) -> Option<&'static str> {
821 Some("credential")
822 }
823 fn actus_prepare(&self) -> Option<&'static str> {
824 Some("Self::auth")
825 }
826 fn actus_rate_limit(&self) -> Option<&'static str> {
827 Some("auth")
828 }
829 fn actus_max_body_bytes(&self) -> Option<usize> {
830 Some(4096)
831 }
832 }
833
834 #[test]
835 fn mounts_emits_a_row_for_every_controller_including_undeclared() {
836 // THE regression test for the blind spot the inventory exists to fix:
837 // `rate_limit_classes()` skips controllers that declared nothing, so
838 // an omission is invisible there. `mounts()` must emit a row for the
839 // undeclared controller (`Spy` overrides no metadata method), with
840 // every declaration field `None` — absence as data, not as a skip.
841 let log = Arc::new(Mutex::new(Vec::new()));
842 let router = RouterBuilder::new()
843 .add_route("api/things", Arc::new(Declared))
844 .add_route(
845 "api/undeclared",
846 Arc::new(Spy {
847 path: "undeclared".into(),
848 log: log.clone(),
849 }),
850 )
851 .build();
852
853 assert_eq!(
854 router.mounts(),
855 vec![
856 Mount {
857 mount: "api/things".to_string(),
858 controller: "declared",
859 expects: Some("credential"),
860 prepare: Some("Self::auth"),
861 rate_limit_class: Some("auth"),
862 max_body_bytes: Some(4096),
863 },
864 Mount {
865 mount: "api/undeclared".to_string(),
866 controller: "spy",
867 expects: None,
868 prepare: None,
869 rate_limit_class: None,
870 max_body_bytes: None,
871 },
872 ],
873 "one row per mounted controller, sorted-DFS order, absences included",
874 );
875 }
876
877 #[test]
878 fn mounts_reports_a_root_mount_as_the_empty_string() {
879 // Same mount-path convention as `routes()`: a `"*"` (or `""`) mount
880 // is the root, reported as `""` — the coverage-check convention
881 // `mount.split('/').next()` then yields `""`, which no family
882 // prefix matches, so a root catch-all is naturally unconstrained.
883 let log = Arc::new(Mutex::new(Vec::new()));
884 let router = RouterBuilder::new()
885 .add_route(
886 "*",
887 Arc::new(Spy {
888 path: "spa".into(),
889 log: log.clone(),
890 }),
891 )
892 .build();
893 let rows = router.mounts();
894 assert_eq!(rows.len(), 1);
895 assert_eq!(rows[0].mount, "");
896 assert_eq!(rows[0].expects, None);
897 }
898
899 #[tokio::test]
900 async fn star_only_in_last_position() {
901 // A `*` that isn't the final segment is meaningless; the route is
902 // dropped (with a warning to stderr) rather than treating `*` as a
903 // literal segment.
904 let (r, log) = build(&["a/*/b"]);
905 assert_eq!(hit(&r, &log, "/a/x/b").await, None);
906 assert_eq!(hit(&r, &log, "/a/*/b").await, None);
907 }
908}