Skip to main content

larust_http/
route.rs

1use crate::path::to_axum_path;
2use crate::session::{session_layer, SessionBackend};
3use axum::extract::Request;
4use axum::handler::Handler;
5use axum::routing::{delete, get, patch, post, put, MethodRouter};
6use larust_core::AppError;
7use sqlx::AnyPool;
8use std::collections::HashMap;
9use std::sync::OnceLock;
10use tower_sessions::SessionManagerLayer;
11
12/// A type-erased middleware layer, stored so it can be applied to more than
13/// one route entry (once for every entry it covers) - unlike a `FnOnce`
14/// closure, `Fn` lets the same registered layer be reapplied per entry,
15/// which is what both global (`Router::middleware`) and group-scoped
16/// (`Router::group`) application need. `L`'s `Clone` bound (already
17/// required by `Router::middleware`) is what makes calling this more than
18/// once possible: each call clones the captured layer before handing it to
19/// `MethodRouter::layer`, which consumes its argument.
20type BoxedMiddleware = Box<dyn Fn(MethodRouter) -> MethodRouter>;
21
22/// Metadata about a single registered route, independent of the underlying
23/// axum machinery - used by `Router::routes()` for introspection (e.g.
24/// `xr route:list`).
25#[derive(Debug, Clone)]
26pub struct RouteInfo {
27    pub method: &'static str,
28    /// The Laravel-shaped path as declared (`{param}`, not `:param`).
29    pub path: String,
30    pub name: Option<String>,
31}
32
33/// A bundle of routes a crate contributes to an app's router - Larust's
34/// answer to "package/plugin system," the last unshipped item from the
35/// project's own original v0.3 roadmap. Not a runtime registry (there's no
36/// way to justify one for a compiled language with no dynamic loading, and
37/// this codebase already repeatedly rejects that shape elsewhere - see
38/// `Policy<U>`/`authorize()`'s own stance against a `Gate`-style registry,
39/// and `larust-socialite`'s doc comment explicitly disclaiming an
40/// `extend()`-style provider registry). Any crate, first-party or
41/// third-party, implements this for its own type and hands it to
42/// [`Router::plugin`]; the compiler verifies the whole thing at the call
43/// site.
44///
45/// The default `routes()` returns an empty [`Router`] rather than being a
46/// required method - same defaulting precedent `WireComponent::call` sets
47/// (see its own doc comment in `larust-live`): a plugin type with nothing
48/// to contribute yet (middleware- or config-only, if those ever land)
49/// shouldn't need a no-op override just to satisfy this trait.
50///
51/// v1 is deliberately routes-only - no middleware, config, migration, or
52/// scheduled-task contribution. Migrations in particular aren't a gap:
53/// every existing crate that needs a table already self-bootstraps it lazily
54/// (`larust-permissions`'s `ensure_tables()`, `larust-sanctum`'s
55/// `ensure_table()`) with no boot-time call needed at all. See
56/// `docs/ARCHITECTURE.md`'s "Plugins" section for the full design and the
57/// stated v1 boundaries.
58pub trait Plugin {
59    fn routes(&self) -> Router {
60        Router::new()
61    }
62}
63
64struct Entry {
65    info: RouteInfo,
66    method_router: MethodRouter,
67    /// `true` only for an entry that arrived via [`Router::merge`] - set so
68    /// `into_axum_router` can skip applying `self.middlewares` to it.
69    /// `self.middlewares` is otherwise applied uniformly to every entry in
70    /// `self.entries` with no other way to distinguish "belongs to this
71    /// router's own top-level middleware stack" from "was merged in from a
72    /// router whose middleware stack must stay independent" - see
73    /// `Router::merge`'s own doc comment for why that independence matters.
74    /// Every other entry (`.push`'s own calls, and `.group`'s merged-in
75    /// ones - which *should* inherit `self.middlewares`, deliberately) is
76    /// `false`.
77    immune_to_parent_middleware: bool,
78}
79
80/// Static entry points matching Laravel's `Route::get(...)` call sites.
81/// Each returns a [`Router`] so further routes/names can be chained the
82/// same way Laravel chains `->name(...)` off a single registration.
83pub struct Route;
84
85impl Route {
86    pub fn get<H, T>(path: &str, handler: H) -> Router
87    where
88        H: Handler<T, ()>,
89        T: 'static,
90    {
91        Router::new().get(path, handler)
92    }
93
94    pub fn post<H, T>(path: &str, handler: H) -> Router
95    where
96        H: Handler<T, ()>,
97        T: 'static,
98    {
99        Router::new().post(path, handler)
100    }
101
102    pub fn put<H, T>(path: &str, handler: H) -> Router
103    where
104        H: Handler<T, ()>,
105        T: 'static,
106    {
107        Router::new().put(path, handler)
108    }
109
110    pub fn patch<H, T>(path: &str, handler: H) -> Router
111    where
112        H: Handler<T, ()>,
113        T: 'static,
114    {
115        Router::new().patch(path, handler)
116    }
117
118    pub fn delete<H, T>(path: &str, handler: H) -> Router
119    where
120        H: Handler<T, ()>,
121        T: 'static,
122    {
123        Router::new().delete(path, handler)
124    }
125
126    /// Registers a group of routes under a shared path prefix.
127    pub fn group<F>(prefix: &str, build: F) -> Router
128    where
129        F: FnOnce(Router) -> Router,
130    {
131        Router::new().group(prefix, build)
132    }
133
134    /// Registers all 7 RESTful routes for a resource in one call - Laravel's
135    /// `Route::resource('posts', PostController::class)`. See
136    /// [`Router::resource`] for the full argument/naming breakdown.
137    #[allow(clippy::too_many_arguments)] // mirrors Laravel's own resource(): one call, all 7 actions
138    pub fn resource<
139        HIndex,
140        TIndex,
141        HCreate,
142        TCreate,
143        HStore,
144        TStore,
145        HShow,
146        TShow,
147        HEdit,
148        TEdit,
149        HUpdate,
150        TUpdate,
151        HDestroy,
152        TDestroy,
153    >(
154        prefix: &str,
155        param: &str,
156        index: HIndex,
157        create: HCreate,
158        store: HStore,
159        show: HShow,
160        edit: HEdit,
161        update: HUpdate,
162        destroy: HDestroy,
163    ) -> Router
164    where
165        HIndex: Handler<TIndex, ()>,
166        TIndex: 'static,
167        HCreate: Handler<TCreate, ()>,
168        TCreate: 'static,
169        HStore: Handler<TStore, ()>,
170        TStore: 'static,
171        HShow: Handler<TShow, ()>,
172        TShow: 'static,
173        HEdit: Handler<TEdit, ()>,
174        TEdit: 'static,
175        HUpdate: Handler<TUpdate, ()>,
176        TUpdate: 'static,
177        HDestroy: Handler<TDestroy, ()>,
178        TDestroy: 'static,
179    {
180        Router::new().resource(
181            prefix, param, index, create, store, show, edit, update, destroy,
182        )
183    }
184}
185
186/// The route builder. Accumulates entries by value through a fluent chain
187/// and converts to a real `axum::Router` via [`Router::into_axum_router`].
188///
189/// Every method here consumes `self` and returns a new value rather than
190/// mutating in place - a call left unchained (`Router::new().get(...);`)
191/// silently drops the route, hence `#[must_use]`.
192#[derive(Default)]
193#[must_use]
194pub struct Router {
195    entries: Vec<Entry>,
196    middlewares: Vec<BoxedMiddleware>,
197    session_layer: Option<SessionManagerLayer<SessionBackend>>,
198}
199
200impl Router {
201    pub fn new() -> Self {
202        Self::default()
203    }
204
205    pub fn get<H, T>(self, path: &str, handler: H) -> Self
206    where
207        H: Handler<T, ()>,
208        T: 'static,
209    {
210        self.push("GET", path, get(handler))
211    }
212
213    pub fn post<H, T>(self, path: &str, handler: H) -> Self
214    where
215        H: Handler<T, ()>,
216        T: 'static,
217    {
218        self.push("POST", path, post(handler))
219    }
220
221    pub fn put<H, T>(self, path: &str, handler: H) -> Self
222    where
223        H: Handler<T, ()>,
224        T: 'static,
225    {
226        self.push("PUT", path, put(handler))
227    }
228
229    pub fn patch<H, T>(self, path: &str, handler: H) -> Self
230    where
231        H: Handler<T, ()>,
232        T: 'static,
233    {
234        self.push("PATCH", path, patch(handler))
235    }
236
237    pub fn delete<H, T>(self, path: &str, handler: H) -> Self
238    where
239        H: Handler<T, ()>,
240        T: 'static,
241    {
242        self.push("DELETE", path, delete(handler))
243    }
244
245    /// Names the most recently added route (mirrors Laravel's
246    /// `Route::get(...)->name(...)`, which also applies to the immediately
247    /// preceding registration).
248    ///
249    /// Unlike Laravel's `Route::name('prefix.')->group(...)`, chaining
250    /// `.name(...)` directly after `.group(...)` does **not** prefix every
251    /// route inside the group - it only names the group's last route, same
252    /// as anywhere else. Name routes inside the `build` closure instead.
253    /// A no-op (not an error) if no route has been added yet.
254    pub fn name(mut self, name: &str) -> Self {
255        if let Some(last) = self.entries.last_mut() {
256            last.info.name = Some(name.to_string());
257        }
258        self
259    }
260
261    /// Nests a group of routes under a shared path prefix. `build` receives
262    /// a fresh `Router` and returns it with its own routes added - plain
263    /// value-passing, no implicit/global registration.
264    ///
265    /// Any `.middleware(...)` calls made *inside* `build`'s closure are
266    /// scoped to just this group's routes (Laravel's
267    /// `Route::middleware(...)->group(...)`) - baked into each entry's
268    /// `MethodRouter` before it's merged into `self`, so they can't leak
269    /// out and affect sibling routes registered outside the group. This is
270    /// a different mechanism from top-level `.middleware()` (which applies
271    /// to every entry in `self`, including ones added by `.group()`) -
272    /// the two compose: a route inside a group is wrapped by the group's
273    /// own middleware first, then by the parent router's global middleware.
274    /// Calling `.with_sessions(...)` inside `build`'s closure isn't
275    /// meaningful anyway: sessions are a single layer for the whole app,
276    /// applied only by the outermost `Router::into_axum_router()` call -
277    /// and since `with_sessions` is `async` while `build: FnOnce(Router) ->
278    /// Router` isn't, `r.with_sessions(pool, secure)` inside the closure
279    /// produces a `Future`, not a `Router`, so it doesn't even type-check
280    /// as a chain continuation there.
281    pub fn group<F>(mut self, prefix: &str, build: F) -> Self
282    where
283        F: FnOnce(Router) -> Router,
284    {
285        let sub = build(Router::new());
286        for entry in sub.entries {
287            let info = RouteInfo {
288                method: entry.info.method,
289                path: format!("{prefix}{}", entry.info.path),
290                name: entry.info.name,
291            };
292            // Reversed for the same reason `into_axum_router` reverses its
293            // own list: applying back-to-front makes the *first*-registered
294            // middleware in `build`'s closure end up outermost, so call
295            // order is execution order here too.
296            let method_router = sub
297                .middlewares
298                .iter()
299                .rev()
300                .fold(entry.method_router, |router, middleware| middleware(router));
301            self.entries.push(Entry {
302                info,
303                method_router,
304                immune_to_parent_middleware: false,
305            });
306        }
307        self
308    }
309
310    /// Merges `other`'s routes into `self` under `prefix`, keeping each
311    /// router's own top-level `.middleware(...)` stack fully **independent** -
312    /// unlike [`Router::group`] (which deliberately shares `self`'s
313    /// top-level middleware with whatever its closure registers, see that
314    /// method's own doc comment), neither router's global middleware leaks
315    /// onto the other's routes. Laravel's own `routes/web.php`/
316    /// `routes/api.php` split works this way implicitly (the framework's
317    /// own bootstrap puts each file under its own middleware *group* -
318    /// `web`/`api` - that never shares state with the other); this is the
319    /// explicit equivalent for two `Router` values built independently and
320    /// combined by hand, e.g. a `web`-style router's own CSRF middleware
321    /// must never apply to an `api`-style router's routes, and the `api`
322    /// router's own rate-limiting middleware must never apply back to the
323    /// `web` router's routes either. Use `.group(...)` when nested routes
324    /// *should* inherit the parent's top-level middleware (the common
325    /// case - an auth-gated section of the same route file); use `.merge`
326    /// only when combining two genuinely separate route trees whose
327    /// middleware stacks must stay isolated.
328    ///
329    /// `other`'s own top-level middleware is baked into its entries
330    /// immediately here - the same "wrap now, not deferred to
331    /// `into_axum_router()`" mechanism `.group(...)` already uses for a
332    /// closure's sub-router - so calling `.middleware(...)` on `other`
333    /// *after* passing it to this method has no effect; register all of
334    /// `other`'s own middleware before merging it in. `other`'s own
335    /// `session_layer` (if `.with_sessions(...)` was ever called on it) is
336    /// discarded, not merged - same stance `.group(...)`'s own doc comment
337    /// already takes: sessions are a single layer for the whole app,
338    /// applied only by the outermost `Router::into_axum_router()` call, so
339    /// call `.with_sessions(...)` only on the final, fully-merged router.
340    pub fn merge(mut self, prefix: &str, other: Router) -> Self {
341        for entry in other.entries {
342            let info = RouteInfo {
343                method: entry.info.method,
344                path: format!("{prefix}{}", entry.info.path),
345                name: entry.info.name,
346            };
347            // Reversed for the same reason `.group()`/`into_axum_router`
348            // reverse their own lists - see either's doc comment.
349            let method_router = other
350                .middlewares
351                .iter()
352                .rev()
353                .fold(entry.method_router, |router, middleware| middleware(router));
354            self.entries.push(Entry {
355                info,
356                method_router,
357                immune_to_parent_middleware: true,
358            });
359        }
360        self
361    }
362
363    /// Merges `plugin`'s [`Plugin::routes()`] into `self`, unprefixed - a
364    /// plugin's routes are already complete, absolute paths
365    /// (`/__larust_wire/runtime.js`), unlike a real `.merge(prefix, ...)`
366    /// call (e.g. mounting `routes/api.rs` under `app.config().api_prefix`)
367    /// - nothing here should be textually prepended.
368    ///
369    /// **Deliberately NOT `self.merge("", plugin.routes())`** - that was
370    /// this method's original implementation, and it was a real bug, not
371    /// just a missed optimization: `.merge()` marks every incoming entry
372    /// `immune_to_parent_middleware`, correct for its own purpose
373    /// (isolating `routes/api.rs` from `routes/web.rs`'s own middleware
374    /// stack) but wrong here - a plugin's routes are ordinary, first-class
375    /// routes on *this* app, contributed by a crate instead of hand-
376    /// written, and should inherit whatever this router's own
377    /// `.middleware(...)` calls apply, exactly as a hand-written
378    /// `.post(...)` call in their place would. The bug was concrete, not
379    /// theoretical: `WirePlugin`'s `/__larust_wire/{component_id}` route
380    /// silently stopped being covered by the app's own trailing
381    /// `.middleware(csrf::verify)` call the moment `demo`/`scaffold.rs`
382    /// switched from a hand-written `.post(...)` registration to
383    /// `.plugin(WirePlugin)` - a real CSRF-protection regression, caught
384    /// only by re-reading `merge`'s own body afterward, not by any test
385    /// (nothing exercised cross-origin request rejection specifically).
386    /// This method now pushes each entry with `immune_to_parent_middleware:
387    /// false` instead, so it composes the same way manual registration
388    /// always did. A plugin's own `routes()`-internal `.middleware(...)`
389    /// calls (if it ever registers any) are unaffected either way - those
390    /// are baked into each entry's `method_router` immediately, the same
391    /// `other.middlewares` fold `.merge()` itself still uses.
392    ///
393    /// Call this only at the top level of a `Router` chain, never inside a
394    /// [`Router::group`] closure - `.group()` prefixes and re-wraps every
395    /// entry in its closure's sub-router uniformly, with no awareness that
396    /// some of them arrived via a nested `.plugin()`/`.merge()` call, so
397    /// nesting it would double-prefix a plugin's already-absolute paths
398    /// and wrongly apply the group's own middleware to them (this part is
399    /// unrelated to `immune_to_parent_middleware` - `.group()`'s own fold
400    /// never checks that flag at all). See `docs/ARCHITECTURE.md`'s
401    /// "Plugins" section.
402    pub fn plugin<P: Plugin>(mut self, plugin: P) -> Self {
403        let other = plugin.routes();
404        for entry in other.entries {
405            let method_router = other
406                .middlewares
407                .iter()
408                .rev()
409                .fold(entry.method_router, |router, middleware| middleware(router));
410            self.entries.push(Entry {
411                info: entry.info,
412                method_router,
413                immune_to_parent_middleware: false,
414            });
415        }
416        self
417    }
418
419    /// Registers all 7 RESTful routes for a resource in one call - Laravel's
420    /// `Route::resource('posts', PostController::class)`. `prefix` is the
421    /// resource's bare name, matching Laravel's own calling convention -
422    /// **no leading slash** (`"posts"`, not `"/posts"`; the path gets one
423    /// prepended automatically, the route *names* must not have one at
424    /// all, and `prefix` is used for both). `param` is the path-parameter
425    /// name used for `show`/`edit`/`update`/`destroy` (`"post"`), matching
426    /// whatever `#[derive(Model)]`'s route model binding expects for the
427    /// bound type (its snake_case name by default, or `#[route_key(...)]`'s
428    /// override) - `param` is taken explicitly rather than singularized
429    /// from `prefix` (`"categories"` → `"category"`, not `"categorie"`),
430    /// matching this codebase's existing "explicit string, never inferred"
431    /// stance for anything with this shape (see `#[belongs_to_many(...)]`'s
432    /// `related_pivot_key`).
433    ///
434    /// Generates, in Laravel's own order and naming convention:
435    ///
436    /// | Method | Path | Name |
437    /// |---|---|---|
438    /// | GET | `/{prefix}` | `{prefix}.index` |
439    /// | GET | `/{prefix}/create` | `{prefix}.create` |
440    /// | POST | `/{prefix}` | `{prefix}.store` |
441    /// | GET | `/{prefix}/{{param}}` | `{prefix}.show` |
442    /// | GET | `/{prefix}/{{param}}/edit` | `{prefix}.edit` |
443    /// | PUT | `/{prefix}/{{param}}` | `{prefix}.update` |
444    /// | DELETE | `/{prefix}/{{param}}` | `{prefix}.destroy` |
445    ///
446    /// Built from this struct's own `.get`/`.post`/`.put`/`.delete`/`.name` -
447    /// sugar over them, not a separate registration path - so it composes
448    /// with `.middleware(...)`/`.group(...)` exactly like a hand-written
449    /// sequence of those calls would (e.g. wrapping a whole resource behind
450    /// `require_auth`).
451    #[allow(clippy::too_many_arguments)] // mirrors Laravel's own resource(): one call, all 7 actions
452    pub fn resource<
453        HIndex,
454        TIndex,
455        HCreate,
456        TCreate,
457        HStore,
458        TStore,
459        HShow,
460        TShow,
461        HEdit,
462        TEdit,
463        HUpdate,
464        TUpdate,
465        HDestroy,
466        TDestroy,
467    >(
468        self,
469        prefix: &str,
470        param: &str,
471        index: HIndex,
472        create: HCreate,
473        store: HStore,
474        show: HShow,
475        edit: HEdit,
476        update: HUpdate,
477        destroy: HDestroy,
478    ) -> Self
479    where
480        HIndex: Handler<TIndex, ()>,
481        TIndex: 'static,
482        HCreate: Handler<TCreate, ()>,
483        TCreate: 'static,
484        HStore: Handler<TStore, ()>,
485        TStore: 'static,
486        HShow: Handler<TShow, ()>,
487        TShow: 'static,
488        HEdit: Handler<TEdit, ()>,
489        TEdit: 'static,
490        HUpdate: Handler<TUpdate, ()>,
491        TUpdate: 'static,
492        HDestroy: Handler<TDestroy, ()>,
493        TDestroy: 'static,
494    {
495        let index_path = format!("/{prefix}");
496        let create_path = format!("/{prefix}/create");
497        let item_path = format!("/{prefix}/{{{param}}}");
498        let edit_path = format!("{item_path}/edit");
499
500        self.get(&index_path, index)
501            .name(&format!("{prefix}.index"))
502            .get(&create_path, create)
503            .name(&format!("{prefix}.create"))
504            .post(&index_path, store)
505            .name(&format!("{prefix}.store"))
506            .get(&item_path, show)
507            .name(&format!("{prefix}.show"))
508            .get(&edit_path, edit)
509            .name(&format!("{prefix}.edit"))
510            .put(&item_path, update)
511            .name(&format!("{prefix}.update"))
512            .delete(&item_path, destroy)
513            .name(&format!("{prefix}.destroy"))
514    }
515
516    /// Snapshot of currently registered routes, for introspection (e.g.
517    /// `xr route:list`).
518    pub fn routes(&self) -> Vec<RouteInfo> {
519        self.entries
520            .iter()
521            .map(|entry| entry.info.clone())
522            .collect()
523    }
524
525    /// Applies middleware to every route registered on `self` (Laravel's
526    /// global middleware stack) - including routes added via `.group(...)`,
527    /// since a group's entries are merged into `self.entries` before this
528    /// router is converted. For middleware scoped to only *some* routes,
529    /// call `.middleware(...)` inside a `.group(...)` closure instead - see
530    /// that method's doc comment. Accepts any `tower::Layer`, matching
531    /// `axum::Router::layer` exactly - wrap an extractor-based handler with
532    /// `axum::middleware::from_fn(handler)` first (needed because
533    /// `from_fn`'s own generic dispatch over arbitrary leading extractor
534    /// arguments can't be re-expressed through a narrower wrapper signature
535    /// here).
536    ///
537    /// The first middleware registered runs first on an incoming request -
538    /// call order is execution order, matching Laravel's middleware-array
539    /// semantics (not axum's own raw `.layer()` semantics, where the last
540    /// `.layer()` call ends up outermost/runs-first; `into_axum_router()`
541    /// applies these in reverse specifically to invert that so callers of
542    /// *this* method don't have to think about it).
543    ///
544    /// Session data (from `.with_sessions()`) is available to every
545    /// middleware registered here regardless of call order relative to
546    /// `.with_sessions()` - that layer is always outermost.
547    pub fn middleware<L>(mut self, layer: L) -> Self
548    where
549        L: tower::Layer<axum::routing::Route> + Clone + Send + 'static,
550        L::Service: tower::Service<Request> + Clone + Send + 'static,
551        <L::Service as tower::Service<Request>>::Response: axum::response::IntoResponse + 'static,
552        <L::Service as tower::Service<Request>>::Error: Into<std::convert::Infallible> + 'static,
553        <L::Service as tower::Service<Request>>::Future: Send + 'static,
554    {
555        self.middlewares.push(Box::new(move |router: MethodRouter| {
556            router.layer(layer.clone())
557        }));
558        self
559    }
560
561    /// Enables cookie-based sessions, backed by either an `AnySessionStore`
562    /// over `pool` or a `FileSessionStore` (see `larust_http::session`'s
563    /// own doc comment - `Config::session_driver` picks which one; session
564    /// data survives a process restart either way, unlike an in-memory
565    /// store). Always applied outermost, so session data is available to
566    /// every other middleware registered via `.middleware(...)` regardless
567    /// of call order.
568    ///
569    /// Async because preparing the store does real I/O (the database
570    /// backend's idempotent `CREATE TABLE IF NOT EXISTS`; the file
571    /// backend's `create_dir_all`) - call this only once a database
572    /// connection actually exists (even when the file backend ends up
573    /// being the one actually used - which backend is active isn't known
574    /// until `session_layer` itself resolves `Config::session_driver`),
575    /// and prefer calling it *after* checking for a `route:list`-style
576    /// early exit, since introspecting registered routes doesn't need a
577    /// working database at all.
578    ///
579    /// `secure` sets the session cookie's `Secure` attribute - pass
580    /// `app.config().session_secure_cookie` (`true` unless a
581    /// `SESSION_SECURE_COOKIE=false` override says otherwise) rather than a
582    /// literal, so local dev on a custom hostname (e.g. a `.test` domain)
583    /// can opt out without a code change. See
584    /// `larust_http::session::session_layer`'s doc comment for why this
585    /// matters.
586    pub async fn with_sessions(mut self, pool: &AnyPool, secure: bool) -> Result<Self, AppError> {
587        self.session_layer = Some(session_layer(pool, secure).await?);
588        Ok(self)
589    }
590
591    /// Converts to a real `axum::Router` and publishes named routes to the
592    /// process-wide registry that [`resolve_route_name`] reads from.
593    pub fn into_axum_router(self) -> axum::Router {
594        let mut names = HashMap::new();
595        let mut router = axum::Router::new();
596
597        for entry in self.entries {
598            // Applied per-entry (via `MethodRouter::layer`) rather than once
599            // over the whole `axum::Router` - the same mechanism
600            // `Router::group` uses for group-scoped middleware, so a
601            // top-level `.middleware()` call and a group-scoped one compose
602            // predictably (see `Router::group`'s doc comment). Reversed for
603            // the same reason as there: axum's `.layer()` makes the *last*
604            // call outermost, so applying back-to-front makes the
605            // *first*-registered middleware end up outermost - call order
606            // becomes execution order, per `Router::middleware`'s doc
607            // comment. Skipped entirely for an entry `Router::merge`
608            // brought in - see `Entry::immune_to_parent_middleware`'s own
609            // doc comment for why `self.middlewares` must never reach it.
610            let method_router = if entry.immune_to_parent_middleware {
611                entry.method_router
612            } else {
613                self.middlewares
614                    .iter()
615                    .rev()
616                    .fold(entry.method_router, |router, middleware| middleware(router))
617            };
618
619            let axum_path = to_axum_path(&entry.info.path);
620            router = router.route(&axum_path, method_router);
621            if let Some(name) = entry.info.name {
622                names.insert(name, entry.info.path);
623            }
624        }
625
626        if let Some(layer) = self.session_layer {
627            router = router.layer(layer);
628        }
629
630        // Always on, outermost - see `crate::push_registry_scope::scope`'s
631        // own doc comment for why this isn't an opt-in `.middleware(...)`
632        // call the way CSRF is.
633        router = router.layer(axum::middleware::from_fn(crate::push_registry_scope::scope));
634
635        publish_route_names(names);
636        router
637    }
638
639    fn push(mut self, method: &'static str, path: &str, method_router: MethodRouter) -> Self {
640        self.entries.push(Entry {
641            info: RouteInfo {
642                method,
643                path: path.to_string(),
644                name: None,
645            },
646            method_router,
647            immune_to_parent_middleware: false,
648        });
649        self
650    }
651}
652
653static ROUTE_NAMES: OnceLock<HashMap<String, String>> = OnceLock::new();
654
655fn publish_route_names(names: HashMap<String, String>) {
656    // `OnceLock` can only be set once. A second `into_axum_router()` call in
657    // the same process doesn't panic, but it does mean `resolve_route_name`
658    // keeps resolving against the *first* router's names - silently wrong,
659    // not just silently ignored, so this is worth surfacing rather than
660    // swallowing outright.
661    if ROUTE_NAMES.set(names).is_err() {
662        tracing::warn!(
663            "into_axum_router() called more than once in this process; \
664             route name resolution still uses the first router's names"
665        );
666    }
667}
668
669/// Resolves a named route to its declared path (Laravel-style `{param}`
670/// placeholders are left unsubstituted - parameter binding lands in a
671/// later milestone).
672pub fn resolve_route_name(name: &str) -> Option<String> {
673    ROUTE_NAMES.get().and_then(|names| names.get(name).cloned())
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679
680    async fn index() -> &'static str {
681        "index"
682    }
683    async fn show() -> &'static str {
684        "show"
685    }
686
687    #[test]
688    fn name_applies_to_most_recently_added_route() {
689        let router = Router::new()
690            .get("/", index)
691            .get("/posts", index)
692            .name("posts.index")
693            .get("/posts/{post}", show)
694            .name("posts.show");
695
696        let routes = router.routes();
697        assert_eq!(routes[0].name, None);
698        assert_eq!(routes[1].name.as_deref(), Some("posts.index"));
699        assert_eq!(routes[2].name.as_deref(), Some("posts.show"));
700    }
701
702    #[test]
703    fn group_prefixes_nested_route_paths() {
704        let router = Route::group("/admin", |r| {
705            r.get("/dashboard", index).name("admin.dashboard")
706        });
707
708        let routes = router.routes();
709        assert_eq!(routes.len(), 1);
710        assert_eq!(routes[0].path, "/admin/dashboard");
711        assert_eq!(routes[0].name.as_deref(), Some("admin.dashboard"));
712    }
713
714    #[test]
715    fn route_static_entry_points_match_router_instance_methods() {
716        let via_route = Route::get("/posts", index).routes();
717        let via_router = Router::new().get("/posts", index).routes();
718
719        assert_eq!(via_route[0].method, via_router[0].method);
720        assert_eq!(via_route[0].path, via_router[0].path);
721    }
722
723    struct TestPlugin;
724    impl Plugin for TestPlugin {
725        fn routes(&self) -> Router {
726            Router::new()
727                .get("/__test_plugin/runtime.js", index)
728                .post("/__test_plugin/{id}", show)
729        }
730    }
731    struct EmptyPlugin;
732    impl Plugin for EmptyPlugin {}
733
734    #[test]
735    fn plugin_merges_routes_unprefixed() {
736        let routes = Router::new().get("/", index).plugin(TestPlugin).routes();
737
738        assert_eq!(routes.len(), 3);
739        assert_eq!(routes[1].method, "GET");
740        assert_eq!(routes[1].path, "/__test_plugin/runtime.js");
741        assert_eq!(routes[2].method, "POST");
742        assert_eq!(routes[2].path, "/__test_plugin/{id}");
743    }
744
745    #[test]
746    fn plugin_default_routes_method_is_a_no_op() {
747        let routes = Router::new().get("/", index).plugin(EmptyPlugin).routes();
748        assert_eq!(routes.len(), 1);
749    }
750
751    // Pins a known landmine, not a "correct" behavior - see `Plugin`'s own
752    // doc comment and docs/ARCHITECTURE.md's "Plugins" section: `.group()`
753    // doesn't special-case a nested `.plugin()`'s already-absolute paths,
754    // so it double-prefixes them just like it would a raw `.merge()` call.
755    #[test]
756    fn group_double_prefixes_a_nested_plugin_call() {
757        let routes = Route::group("/admin", |r| r.plugin(TestPlugin)).routes();
758
759        assert_eq!(routes[0].path, "/admin/__test_plugin/runtime.js");
760        assert_eq!(routes[1].path, "/admin/__test_plugin/{id}");
761    }
762}