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}