Skip to main content

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) = &current_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}