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