larust-http 0.6.0

A Laravel-shaped web framework for Rust.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
use crate::path::to_axum_path;
use crate::session::{session_layer, SessionBackend};
use axum::extract::Request;
use axum::handler::Handler;
use axum::routing::{delete, get, patch, post, put, MethodRouter};
use larust_core::AppError;
use sqlx::AnyPool;
use std::collections::HashMap;
use std::sync::OnceLock;
use tower_sessions::SessionManagerLayer;

/// A type-erased middleware layer, stored so it can be applied to more than
/// one route entry (once for every entry it covers) - unlike a `FnOnce`
/// closure, `Fn` lets the same registered layer be reapplied per entry,
/// which is what both global (`Router::middleware`) and group-scoped
/// (`Router::group`) application need. `L`'s `Clone` bound (already
/// required by `Router::middleware`) is what makes calling this more than
/// once possible: each call clones the captured layer before handing it to
/// `MethodRouter::layer`, which consumes its argument.
type BoxedMiddleware = Box<dyn Fn(MethodRouter) -> MethodRouter>;

/// Metadata about a single registered route, independent of the underlying
/// axum machinery - used by `Router::routes()` for introspection (e.g.
/// `xr route:list`).
#[derive(Debug, Clone)]
pub struct RouteInfo {
    pub method: &'static str,
    /// The Laravel-shaped path as declared (`{param}`, not `:param`).
    pub path: String,
    pub name: Option<String>,
}

/// A bundle of routes a crate contributes to an app's router - Larust's
/// answer to "package/plugin system," the last unshipped item from the
/// project's own original v0.3 roadmap. Not a runtime registry (there's no
/// way to justify one for a compiled language with no dynamic loading, and
/// this codebase already repeatedly rejects that shape elsewhere - see
/// `Policy<U>`/`authorize()`'s own stance against a `Gate`-style registry,
/// and `larust-socialite`'s doc comment explicitly disclaiming an
/// `extend()`-style provider registry). Any crate, first-party or
/// third-party, implements this for its own type and hands it to
/// [`Router::plugin`]; the compiler verifies the whole thing at the call
/// site.
///
/// The default `routes()` returns an empty [`Router`] rather than being a
/// required method - same defaulting precedent `WireComponent::call` sets
/// (see its own doc comment in `larust-live`): a plugin type with nothing
/// to contribute yet (middleware- or config-only, if those ever land)
/// shouldn't need a no-op override just to satisfy this trait.
///
/// v1 is deliberately routes-only - no middleware, config, migration, or
/// scheduled-task contribution. Migrations in particular aren't a gap:
/// every existing crate that needs a table already self-bootstraps it lazily
/// (`larust-permissions`'s `ensure_tables()`, `larust-sanctum`'s
/// `ensure_table()`) with no boot-time call needed at all. See
/// `docs/ARCHITECTURE.md`'s "Plugins" section for the full design and the
/// stated v1 boundaries.
pub trait Plugin {
    fn routes(&self) -> Router {
        Router::new()
    }
}

struct Entry {
    info: RouteInfo,
    method_router: MethodRouter,
    /// `true` only for an entry that arrived via [`Router::merge`] - set so
    /// `into_axum_router` can skip applying `self.middlewares` to it.
    /// `self.middlewares` is otherwise applied uniformly to every entry in
    /// `self.entries` with no other way to distinguish "belongs to this
    /// router's own top-level middleware stack" from "was merged in from a
    /// router whose middleware stack must stay independent" - see
    /// `Router::merge`'s own doc comment for why that independence matters.
    /// Every other entry (`.push`'s own calls, and `.group`'s merged-in
    /// ones - which *should* inherit `self.middlewares`, deliberately) is
    /// `false`.
    immune_to_parent_middleware: bool,
}

/// Static entry points matching Laravel's `Route::get(...)` call sites.
/// Each returns a [`Router`] so further routes/names can be chained the
/// same way Laravel chains `->name(...)` off a single registration.
pub struct Route;

impl Route {
    pub fn get<H, T>(path: &str, handler: H) -> Router
    where
        H: Handler<T, ()>,
        T: 'static,
    {
        Router::new().get(path, handler)
    }

    pub fn post<H, T>(path: &str, handler: H) -> Router
    where
        H: Handler<T, ()>,
        T: 'static,
    {
        Router::new().post(path, handler)
    }

    pub fn put<H, T>(path: &str, handler: H) -> Router
    where
        H: Handler<T, ()>,
        T: 'static,
    {
        Router::new().put(path, handler)
    }

    pub fn patch<H, T>(path: &str, handler: H) -> Router
    where
        H: Handler<T, ()>,
        T: 'static,
    {
        Router::new().patch(path, handler)
    }

    pub fn delete<H, T>(path: &str, handler: H) -> Router
    where
        H: Handler<T, ()>,
        T: 'static,
    {
        Router::new().delete(path, handler)
    }

    /// Registers a group of routes under a shared path prefix.
    pub fn group<F>(prefix: &str, build: F) -> Router
    where
        F: FnOnce(Router) -> Router,
    {
        Router::new().group(prefix, build)
    }

    /// Registers all 7 RESTful routes for a resource in one call - Laravel's
    /// `Route::resource('posts', PostController::class)`. See
    /// [`Router::resource`] for the full argument/naming breakdown.
    #[allow(clippy::too_many_arguments)] // mirrors Laravel's own resource(): one call, all 7 actions
    pub fn resource<
        HIndex,
        TIndex,
        HCreate,
        TCreate,
        HStore,
        TStore,
        HShow,
        TShow,
        HEdit,
        TEdit,
        HUpdate,
        TUpdate,
        HDestroy,
        TDestroy,
    >(
        prefix: &str,
        param: &str,
        index: HIndex,
        create: HCreate,
        store: HStore,
        show: HShow,
        edit: HEdit,
        update: HUpdate,
        destroy: HDestroy,
    ) -> Router
    where
        HIndex: Handler<TIndex, ()>,
        TIndex: 'static,
        HCreate: Handler<TCreate, ()>,
        TCreate: 'static,
        HStore: Handler<TStore, ()>,
        TStore: 'static,
        HShow: Handler<TShow, ()>,
        TShow: 'static,
        HEdit: Handler<TEdit, ()>,
        TEdit: 'static,
        HUpdate: Handler<TUpdate, ()>,
        TUpdate: 'static,
        HDestroy: Handler<TDestroy, ()>,
        TDestroy: 'static,
    {
        Router::new().resource(
            prefix, param, index, create, store, show, edit, update, destroy,
        )
    }
}

/// The route builder. Accumulates entries by value through a fluent chain
/// and converts to a real `axum::Router` via [`Router::into_axum_router`].
///
/// Every method here consumes `self` and returns a new value rather than
/// mutating in place - a call left unchained (`Router::new().get(...);`)
/// silently drops the route, hence `#[must_use]`.
#[derive(Default)]
#[must_use]
pub struct Router {
    entries: Vec<Entry>,
    middlewares: Vec<BoxedMiddleware>,
    session_layer: Option<SessionManagerLayer<SessionBackend>>,
}

impl Router {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn get<H, T>(self, path: &str, handler: H) -> Self
    where
        H: Handler<T, ()>,
        T: 'static,
    {
        self.push("GET", path, get(handler))
    }

    pub fn post<H, T>(self, path: &str, handler: H) -> Self
    where
        H: Handler<T, ()>,
        T: 'static,
    {
        self.push("POST", path, post(handler))
    }

    pub fn put<H, T>(self, path: &str, handler: H) -> Self
    where
        H: Handler<T, ()>,
        T: 'static,
    {
        self.push("PUT", path, put(handler))
    }

    pub fn patch<H, T>(self, path: &str, handler: H) -> Self
    where
        H: Handler<T, ()>,
        T: 'static,
    {
        self.push("PATCH", path, patch(handler))
    }

    pub fn delete<H, T>(self, path: &str, handler: H) -> Self
    where
        H: Handler<T, ()>,
        T: 'static,
    {
        self.push("DELETE", path, delete(handler))
    }

    /// Names the most recently added route (mirrors Laravel's
    /// `Route::get(...)->name(...)`, which also applies to the immediately
    /// preceding registration).
    ///
    /// Unlike Laravel's `Route::name('prefix.')->group(...)`, chaining
    /// `.name(...)` directly after `.group(...)` does **not** prefix every
    /// route inside the group - it only names the group's last route, same
    /// as anywhere else. Name routes inside the `build` closure instead.
    /// A no-op (not an error) if no route has been added yet.
    pub fn name(mut self, name: &str) -> Self {
        if let Some(last) = self.entries.last_mut() {
            last.info.name = Some(name.to_string());
        }
        self
    }

    /// Nests a group of routes under a shared path prefix. `build` receives
    /// a fresh `Router` and returns it with its own routes added - plain
    /// value-passing, no implicit/global registration.
    ///
    /// Any `.middleware(...)` calls made *inside* `build`'s closure are
    /// scoped to just this group's routes (Laravel's
    /// `Route::middleware(...)->group(...)`) - baked into each entry's
    /// `MethodRouter` before it's merged into `self`, so they can't leak
    /// out and affect sibling routes registered outside the group. This is
    /// a different mechanism from top-level `.middleware()` (which applies
    /// to every entry in `self`, including ones added by `.group()`) -
    /// the two compose: a route inside a group is wrapped by the group's
    /// own middleware first, then by the parent router's global middleware.
    /// Calling `.with_sessions(...)` inside `build`'s closure isn't
    /// meaningful anyway: sessions are a single layer for the whole app,
    /// applied only by the outermost `Router::into_axum_router()` call -
    /// and since `with_sessions` is `async` while `build: FnOnce(Router) ->
    /// Router` isn't, `r.with_sessions(pool, secure)` inside the closure
    /// produces a `Future`, not a `Router`, so it doesn't even type-check
    /// as a chain continuation there.
    pub fn group<F>(mut self, prefix: &str, build: F) -> Self
    where
        F: FnOnce(Router) -> Router,
    {
        let sub = build(Router::new());
        for entry in sub.entries {
            let info = RouteInfo {
                method: entry.info.method,
                path: format!("{prefix}{}", entry.info.path),
                name: entry.info.name,
            };
            // Reversed for the same reason `into_axum_router` reverses its
            // own list: applying back-to-front makes the *first*-registered
            // middleware in `build`'s closure end up outermost, so call
            // order is execution order here too.
            let method_router = sub
                .middlewares
                .iter()
                .rev()
                .fold(entry.method_router, |router, middleware| middleware(router));
            self.entries.push(Entry {
                info,
                method_router,
                immune_to_parent_middleware: false,
            });
        }
        self
    }

    /// Merges `other`'s routes into `self` under `prefix`, keeping each
    /// router's own top-level `.middleware(...)` stack fully **independent** -
    /// unlike [`Router::group`] (which deliberately shares `self`'s
    /// top-level middleware with whatever its closure registers, see that
    /// method's own doc comment), neither router's global middleware leaks
    /// onto the other's routes. Laravel's own `routes/web.php`/
    /// `routes/api.php` split works this way implicitly (the framework's
    /// own bootstrap puts each file under its own middleware *group* -
    /// `web`/`api` - that never shares state with the other); this is the
    /// explicit equivalent for two `Router` values built independently and
    /// combined by hand, e.g. a `web`-style router's own CSRF middleware
    /// must never apply to an `api`-style router's routes, and the `api`
    /// router's own rate-limiting middleware must never apply back to the
    /// `web` router's routes either. Use `.group(...)` when nested routes
    /// *should* inherit the parent's top-level middleware (the common
    /// case - an auth-gated section of the same route file); use `.merge`
    /// only when combining two genuinely separate route trees whose
    /// middleware stacks must stay isolated.
    ///
    /// `other`'s own top-level middleware is baked into its entries
    /// immediately here - the same "wrap now, not deferred to
    /// `into_axum_router()`" mechanism `.group(...)` already uses for a
    /// closure's sub-router - so calling `.middleware(...)` on `other`
    /// *after* passing it to this method has no effect; register all of
    /// `other`'s own middleware before merging it in. `other`'s own
    /// `session_layer` (if `.with_sessions(...)` was ever called on it) is
    /// discarded, not merged - same stance `.group(...)`'s own doc comment
    /// already takes: sessions are a single layer for the whole app,
    /// applied only by the outermost `Router::into_axum_router()` call, so
    /// call `.with_sessions(...)` only on the final, fully-merged router.
    pub fn merge(mut self, prefix: &str, other: Router) -> Self {
        for entry in other.entries {
            let info = RouteInfo {
                method: entry.info.method,
                path: format!("{prefix}{}", entry.info.path),
                name: entry.info.name,
            };
            // Reversed for the same reason `.group()`/`into_axum_router`
            // reverse their own lists - see either's doc comment.
            let method_router = other
                .middlewares
                .iter()
                .rev()
                .fold(entry.method_router, |router, middleware| middleware(router));
            self.entries.push(Entry {
                info,
                method_router,
                immune_to_parent_middleware: true,
            });
        }
        self
    }

    /// Merges `plugin`'s [`Plugin::routes()`] into `self`, unprefixed - a
    /// plugin's routes are already complete, absolute paths
    /// (`/__larust_wire/runtime.js`), unlike a real `.merge(prefix, ...)`
    /// call (e.g. mounting `routes/api.rs` under `app.config().api_prefix`)
    /// - nothing here should be textually prepended.
    ///
    /// **Deliberately NOT `self.merge("", plugin.routes())`** - that was
    /// this method's original implementation, and it was a real bug, not
    /// just a missed optimization: `.merge()` marks every incoming entry
    /// `immune_to_parent_middleware`, correct for its own purpose
    /// (isolating `routes/api.rs` from `routes/web.rs`'s own middleware
    /// stack) but wrong here - a plugin's routes are ordinary, first-class
    /// routes on *this* app, contributed by a crate instead of hand-
    /// written, and should inherit whatever this router's own
    /// `.middleware(...)` calls apply, exactly as a hand-written
    /// `.post(...)` call in their place would. The bug was concrete, not
    /// theoretical: `WirePlugin`'s `/__larust_wire/{component_id}` route
    /// silently stopped being covered by the app's own trailing
    /// `.middleware(csrf::verify)` call the moment `demo`/`scaffold.rs`
    /// switched from a hand-written `.post(...)` registration to
    /// `.plugin(WirePlugin)` - a real CSRF-protection regression, caught
    /// only by re-reading `merge`'s own body afterward, not by any test
    /// (nothing exercised cross-origin request rejection specifically).
    /// This method now pushes each entry with `immune_to_parent_middleware:
    /// false` instead, so it composes the same way manual registration
    /// always did. A plugin's own `routes()`-internal `.middleware(...)`
    /// calls (if it ever registers any) are unaffected either way - those
    /// are baked into each entry's `method_router` immediately, the same
    /// `other.middlewares` fold `.merge()` itself still uses.
    ///
    /// Call this only at the top level of a `Router` chain, never inside a
    /// [`Router::group`] closure - `.group()` prefixes and re-wraps every
    /// entry in its closure's sub-router uniformly, with no awareness that
    /// some of them arrived via a nested `.plugin()`/`.merge()` call, so
    /// nesting it would double-prefix a plugin's already-absolute paths
    /// and wrongly apply the group's own middleware to them (this part is
    /// unrelated to `immune_to_parent_middleware` - `.group()`'s own fold
    /// never checks that flag at all). See `docs/ARCHITECTURE.md`'s
    /// "Plugins" section.
    pub fn plugin<P: Plugin>(mut self, plugin: P) -> Self {
        let other = plugin.routes();
        for entry in other.entries {
            let method_router = other
                .middlewares
                .iter()
                .rev()
                .fold(entry.method_router, |router, middleware| middleware(router));
            self.entries.push(Entry {
                info: entry.info,
                method_router,
                immune_to_parent_middleware: false,
            });
        }
        self
    }

    /// Registers all 7 RESTful routes for a resource in one call - Laravel's
    /// `Route::resource('posts', PostController::class)`. `prefix` is the
    /// resource's bare name, matching Laravel's own calling convention -
    /// **no leading slash** (`"posts"`, not `"/posts"`; the path gets one
    /// prepended automatically, the route *names* must not have one at
    /// all, and `prefix` is used for both). `param` is the path-parameter
    /// name used for `show`/`edit`/`update`/`destroy` (`"post"`), matching
    /// whatever `#[derive(Model)]`'s route model binding expects for the
    /// bound type (its snake_case name by default, or `#[route_key(...)]`'s
    /// override) - `param` is taken explicitly rather than singularized
    /// from `prefix` (`"categories"` → `"category"`, not `"categorie"`),
    /// matching this codebase's existing "explicit string, never inferred"
    /// stance for anything with this shape (see `#[belongs_to_many(...)]`'s
    /// `related_pivot_key`).
    ///
    /// Generates, in Laravel's own order and naming convention:
    ///
    /// | Method | Path | Name |
    /// |---|---|---|
    /// | GET | `/{prefix}` | `{prefix}.index` |
    /// | GET | `/{prefix}/create` | `{prefix}.create` |
    /// | POST | `/{prefix}` | `{prefix}.store` |
    /// | GET | `/{prefix}/{{param}}` | `{prefix}.show` |
    /// | GET | `/{prefix}/{{param}}/edit` | `{prefix}.edit` |
    /// | PUT | `/{prefix}/{{param}}` | `{prefix}.update` |
    /// | DELETE | `/{prefix}/{{param}}` | `{prefix}.destroy` |
    ///
    /// Built from this struct's own `.get`/`.post`/`.put`/`.delete`/`.name` -
    /// sugar over them, not a separate registration path - so it composes
    /// with `.middleware(...)`/`.group(...)` exactly like a hand-written
    /// sequence of those calls would (e.g. wrapping a whole resource behind
    /// `require_auth`).
    #[allow(clippy::too_many_arguments)] // mirrors Laravel's own resource(): one call, all 7 actions
    pub fn resource<
        HIndex,
        TIndex,
        HCreate,
        TCreate,
        HStore,
        TStore,
        HShow,
        TShow,
        HEdit,
        TEdit,
        HUpdate,
        TUpdate,
        HDestroy,
        TDestroy,
    >(
        self,
        prefix: &str,
        param: &str,
        index: HIndex,
        create: HCreate,
        store: HStore,
        show: HShow,
        edit: HEdit,
        update: HUpdate,
        destroy: HDestroy,
    ) -> Self
    where
        HIndex: Handler<TIndex, ()>,
        TIndex: 'static,
        HCreate: Handler<TCreate, ()>,
        TCreate: 'static,
        HStore: Handler<TStore, ()>,
        TStore: 'static,
        HShow: Handler<TShow, ()>,
        TShow: 'static,
        HEdit: Handler<TEdit, ()>,
        TEdit: 'static,
        HUpdate: Handler<TUpdate, ()>,
        TUpdate: 'static,
        HDestroy: Handler<TDestroy, ()>,
        TDestroy: 'static,
    {
        let index_path = format!("/{prefix}");
        let create_path = format!("/{prefix}/create");
        let item_path = format!("/{prefix}/{{{param}}}");
        let edit_path = format!("{item_path}/edit");

        self.get(&index_path, index)
            .name(&format!("{prefix}.index"))
            .get(&create_path, create)
            .name(&format!("{prefix}.create"))
            .post(&index_path, store)
            .name(&format!("{prefix}.store"))
            .get(&item_path, show)
            .name(&format!("{prefix}.show"))
            .get(&edit_path, edit)
            .name(&format!("{prefix}.edit"))
            .put(&item_path, update)
            .name(&format!("{prefix}.update"))
            .delete(&item_path, destroy)
            .name(&format!("{prefix}.destroy"))
    }

    /// Snapshot of currently registered routes, for introspection (e.g.
    /// `xr route:list`).
    pub fn routes(&self) -> Vec<RouteInfo> {
        self.entries
            .iter()
            .map(|entry| entry.info.clone())
            .collect()
    }

    /// Applies middleware to every route registered on `self` (Laravel's
    /// global middleware stack) - including routes added via `.group(...)`,
    /// since a group's entries are merged into `self.entries` before this
    /// router is converted. For middleware scoped to only *some* routes,
    /// call `.middleware(...)` inside a `.group(...)` closure instead - see
    /// that method's doc comment. Accepts any `tower::Layer`, matching
    /// `axum::Router::layer` exactly - wrap an extractor-based handler with
    /// `axum::middleware::from_fn(handler)` first (needed because
    /// `from_fn`'s own generic dispatch over arbitrary leading extractor
    /// arguments can't be re-expressed through a narrower wrapper signature
    /// here).
    ///
    /// The first middleware registered runs first on an incoming request -
    /// call order is execution order, matching Laravel's middleware-array
    /// semantics (not axum's own raw `.layer()` semantics, where the last
    /// `.layer()` call ends up outermost/runs-first; `into_axum_router()`
    /// applies these in reverse specifically to invert that so callers of
    /// *this* method don't have to think about it).
    ///
    /// Session data (from `.with_sessions()`) is available to every
    /// middleware registered here regardless of call order relative to
    /// `.with_sessions()` - that layer is always outermost.
    pub fn middleware<L>(mut self, layer: L) -> Self
    where
        L: tower::Layer<axum::routing::Route> + Clone + Send + 'static,
        L::Service: tower::Service<Request> + Clone + Send + 'static,
        <L::Service as tower::Service<Request>>::Response: axum::response::IntoResponse + 'static,
        <L::Service as tower::Service<Request>>::Error: Into<std::convert::Infallible> + 'static,
        <L::Service as tower::Service<Request>>::Future: Send + 'static,
    {
        self.middlewares.push(Box::new(move |router: MethodRouter| {
            router.layer(layer.clone())
        }));
        self
    }

    /// Enables cookie-based sessions, backed by either an `AnySessionStore`
    /// over `pool` or a `FileSessionStore` (see `larust_http::session`'s
    /// own doc comment - `Config::session_driver` picks which one; session
    /// data survives a process restart either way, unlike an in-memory
    /// store). Always applied outermost, so session data is available to
    /// every other middleware registered via `.middleware(...)` regardless
    /// of call order.
    ///
    /// Async because preparing the store does real I/O (the database
    /// backend's idempotent `CREATE TABLE IF NOT EXISTS`; the file
    /// backend's `create_dir_all`) - call this only once a database
    /// connection actually exists (even when the file backend ends up
    /// being the one actually used - which backend is active isn't known
    /// until `session_layer` itself resolves `Config::session_driver`),
    /// and prefer calling it *after* checking for a `route:list`-style
    /// early exit, since introspecting registered routes doesn't need a
    /// working database at all.
    ///
    /// `secure` sets the session cookie's `Secure` attribute - pass
    /// `app.config().session_secure_cookie` (`true` unless a
    /// `SESSION_SECURE_COOKIE=false` override says otherwise) rather than a
    /// literal, so local dev on a custom hostname (e.g. a `.test` domain)
    /// can opt out without a code change. See
    /// `larust_http::session::session_layer`'s doc comment for why this
    /// matters.
    pub async fn with_sessions(mut self, pool: &AnyPool, secure: bool) -> Result<Self, AppError> {
        self.session_layer = Some(session_layer(pool, secure).await?);
        Ok(self)
    }

    /// Converts to a real `axum::Router` and publishes named routes to the
    /// process-wide registry that [`resolve_route_name`] reads from.
    pub fn into_axum_router(self) -> axum::Router {
        let mut names = HashMap::new();
        let mut router = axum::Router::new();

        for entry in self.entries {
            // Applied per-entry (via `MethodRouter::layer`) rather than once
            // over the whole `axum::Router` - the same mechanism
            // `Router::group` uses for group-scoped middleware, so a
            // top-level `.middleware()` call and a group-scoped one compose
            // predictably (see `Router::group`'s doc comment). Reversed for
            // the same reason as there: axum's `.layer()` makes the *last*
            // call outermost, so applying back-to-front makes the
            // *first*-registered middleware end up outermost - call order
            // becomes execution order, per `Router::middleware`'s doc
            // comment. Skipped entirely for an entry `Router::merge`
            // brought in - see `Entry::immune_to_parent_middleware`'s own
            // doc comment for why `self.middlewares` must never reach it.
            let method_router = if entry.immune_to_parent_middleware {
                entry.method_router
            } else {
                self.middlewares
                    .iter()
                    .rev()
                    .fold(entry.method_router, |router, middleware| middleware(router))
            };

            let axum_path = to_axum_path(&entry.info.path);
            router = router.route(&axum_path, method_router);
            if let Some(name) = entry.info.name {
                names.insert(name, entry.info.path);
            }
        }

        if let Some(layer) = self.session_layer {
            router = router.layer(layer);
        }

        // Always on, outermost - see `crate::push_registry_scope::scope`'s
        // own doc comment for why this isn't an opt-in `.middleware(...)`
        // call the way CSRF is.
        router = router.layer(axum::middleware::from_fn(crate::push_registry_scope::scope));

        publish_route_names(names);
        router
    }

    fn push(mut self, method: &'static str, path: &str, method_router: MethodRouter) -> Self {
        self.entries.push(Entry {
            info: RouteInfo {
                method,
                path: path.to_string(),
                name: None,
            },
            method_router,
            immune_to_parent_middleware: false,
        });
        self
    }
}

static ROUTE_NAMES: OnceLock<HashMap<String, String>> = OnceLock::new();

fn publish_route_names(names: HashMap<String, String>) {
    // `OnceLock` can only be set once. A second `into_axum_router()` call in
    // the same process doesn't panic, but it does mean `resolve_route_name`
    // keeps resolving against the *first* router's names - silently wrong,
    // not just silently ignored, so this is worth surfacing rather than
    // swallowing outright.
    if ROUTE_NAMES.set(names).is_err() {
        tracing::warn!(
            "into_axum_router() called more than once in this process; \
             route name resolution still uses the first router's names"
        );
    }
}

/// Resolves a named route to its declared path (Laravel-style `{param}`
/// placeholders are left unsubstituted - parameter binding lands in a
/// later milestone).
pub fn resolve_route_name(name: &str) -> Option<String> {
    ROUTE_NAMES.get().and_then(|names| names.get(name).cloned())
}

#[cfg(test)]
mod tests {
    use super::*;

    async fn index() -> &'static str {
        "index"
    }
    async fn show() -> &'static str {
        "show"
    }

    #[test]
    fn name_applies_to_most_recently_added_route() {
        let router = Router::new()
            .get("/", index)
            .get("/posts", index)
            .name("posts.index")
            .get("/posts/{post}", show)
            .name("posts.show");

        let routes = router.routes();
        assert_eq!(routes[0].name, None);
        assert_eq!(routes[1].name.as_deref(), Some("posts.index"));
        assert_eq!(routes[2].name.as_deref(), Some("posts.show"));
    }

    #[test]
    fn group_prefixes_nested_route_paths() {
        let router = Route::group("/admin", |r| {
            r.get("/dashboard", index).name("admin.dashboard")
        });

        let routes = router.routes();
        assert_eq!(routes.len(), 1);
        assert_eq!(routes[0].path, "/admin/dashboard");
        assert_eq!(routes[0].name.as_deref(), Some("admin.dashboard"));
    }

    #[test]
    fn route_static_entry_points_match_router_instance_methods() {
        let via_route = Route::get("/posts", index).routes();
        let via_router = Router::new().get("/posts", index).routes();

        assert_eq!(via_route[0].method, via_router[0].method);
        assert_eq!(via_route[0].path, via_router[0].path);
    }

    struct TestPlugin;
    impl Plugin for TestPlugin {
        fn routes(&self) -> Router {
            Router::new()
                .get("/__test_plugin/runtime.js", index)
                .post("/__test_plugin/{id}", show)
        }
    }
    struct EmptyPlugin;
    impl Plugin for EmptyPlugin {}

    #[test]
    fn plugin_merges_routes_unprefixed() {
        let routes = Router::new().get("/", index).plugin(TestPlugin).routes();

        assert_eq!(routes.len(), 3);
        assert_eq!(routes[1].method, "GET");
        assert_eq!(routes[1].path, "/__test_plugin/runtime.js");
        assert_eq!(routes[2].method, "POST");
        assert_eq!(routes[2].path, "/__test_plugin/{id}");
    }

    #[test]
    fn plugin_default_routes_method_is_a_no_op() {
        let routes = Router::new().get("/", index).plugin(EmptyPlugin).routes();
        assert_eq!(routes.len(), 1);
    }

    // Pins a known landmine, not a "correct" behavior - see `Plugin`'s own
    // doc comment and docs/ARCHITECTURE.md's "Plugins" section: `.group()`
    // doesn't special-case a nested `.plugin()`'s already-absolute paths,
    // so it double-prefixes them just like it would a raw `.merge()` call.
    #[test]
    fn group_double_prefixes_a_nested_plugin_call() {
        let routes = Route::group("/admin", |r| r.plugin(TestPlugin)).routes();

        assert_eq!(routes[0].path, "/admin/__test_plugin/runtime.js");
        assert_eq!(routes[1].path, "/admin/__test_plugin/{id}");
    }
}