Skip to main content

umbral_core/
routes.rs

1//! Route registry — a snapshot of every URL path the framework knows
2//! about, grouped by plugin.
3//!
4//! The registry is populated once at `App::build()` time from two
5//! sources:
6//!
7//! 1. The implicit `"app"` plugin's path list, fed from the
8//!    [`Routes`] builder passed to [`crate::AppBuilder::routes`].
9//!    Each `.get(...) / .post(...)` etc. call records both a handler
10//!    *and* a [`RouteSpec`], so the registry is automatically in
11//!    sync with the actual axum router for user-binary routes.
12//! 2. Each registered plugin's `Plugin::route_paths()` contribution,
13//!    walked in topological dependency order.
14//!
15//! The registry is opt-in for surfacing. Currently the only consumer
16//! is the dev-mode default 404 template, which renders the path list
17//! so a developer who hits a typoed URL can see what's available
18//! without grepping the router tree. The registry is read by
19//! `crate::errors::render_not_found` only when `settings.environment
20//! == Dev`, so production 404 responses stay minimal.
21//!
22//! ## What this is *not*
23//!
24//! The registry is a *declared* list, not a live introspection of
25//! axum's route table. axum doesn't expose its internal `RouteTable`,
26//! so plugins that contribute routes through `Plugin::routes()`
27//! report them via this companion `Plugin::route_paths()` method. The
28//! two can drift — if a plugin author adds a `.route("/foo", ...)` to
29//! its `routes()` method but forgets to add `"/foo"` to
30//! `route_paths()`, the registry won't mention it. The cost of that
31//! drift is "404 page is slightly stale," not "framework is broken."
32//!
33//! For user-binary routes, the [`Routes`] builder eliminates drift
34//! at the source: a path can only land in the axum router by going
35//! through `Routes::get/post/...`, which also records the spec. The
36//! escape hatch `Routes::with_router` *can* merge an external
37//! `axum::Router` whose paths the registry doesn't see — by design,
38//! since that's where typed-State / middleware / nested routers
39//! live and there's no axum API to introspect them.
40
41use std::collections::BTreeMap;
42use std::sync::OnceLock;
43
44use axum::Router;
45use axum::handler::Handler;
46
47/// One declared route entry: the URL path pattern plus the HTTP
48/// methods it accepts. The dev-mode 404 template renders the methods
49/// as colored badges next to each path so a developer can tell at a
50/// glance which verb the endpoint expects.
51///
52/// `methods` is `Vec<&'static str>` because every realistic value is
53/// a method name literal (`"GET"`, `"POST"`, etc.). When a plugin
54/// declares a path without naming methods, `methods` stays empty and
55/// the template falls back to an "ANY" badge.
56#[derive(Debug, Clone, Default, serde::Serialize)]
57pub struct RouteSpec {
58    pub path: String,
59    pub methods: Vec<&'static str>,
60}
61
62impl RouteSpec {
63    /// Construct a spec with the given path and method names. Use
64    /// when you want explicit control; the `From` impls below cover
65    /// the ergonomic shorthands.
66    pub fn new<P: Into<String>>(path: P, methods: Vec<&'static str>) -> Self {
67        Self {
68            path: path.into(),
69            methods,
70        }
71    }
72}
73
74impl From<&str> for RouteSpec {
75    /// `"/admin/"` → spec with no method declared.
76    fn from(path: &str) -> Self {
77        Self {
78            path: path.to_string(),
79            methods: Vec::new(),
80        }
81    }
82}
83
84impl From<String> for RouteSpec {
85    fn from(path: String) -> Self {
86        Self {
87            path,
88            methods: Vec::new(),
89        }
90    }
91}
92
93impl From<(&'static str, &str)> for RouteSpec {
94    /// `("GET", "/articles")` → spec with one method.
95    fn from((method, path): (&'static str, &str)) -> Self {
96        Self {
97            path: path.to_string(),
98            methods: vec![method],
99        }
100    }
101}
102
103impl From<(&'static str, String)> for RouteSpec {
104    fn from((method, path): (&'static str, String)) -> Self {
105        Self {
106            path,
107            methods: vec![method],
108        }
109    }
110}
111
112impl From<(&[&'static str], &str)> for RouteSpec {
113    /// `(&["GET", "POST"], "/api/post/")` → spec with two methods.
114    fn from((methods, path): (&[&'static str], &str)) -> Self {
115        Self {
116            path: path.to_string(),
117            methods: methods.to_vec(),
118        }
119    }
120}
121
122/// Snapshot of declared routes, keyed by plugin name. The implicit
123/// `"app"` plugin holds the user's hand-registered paths; built-in
124/// and third-party plugins hold their own contributions.
125///
126/// Iteration order is alphabetical by plugin name (BTreeMap), which
127/// gives the 404 template a stable, human-friendly listing without
128/// the framework picking an arbitrary plugin to show first.
129#[derive(Debug, Clone, Default)]
130pub struct RouteRegistry {
131    pub by_plugin: BTreeMap<String, Vec<RouteSpec>>,
132}
133
134impl RouteRegistry {
135    /// Total number of declared paths across every plugin. Used by
136    /// the 404 template's pluralisation and by tests asserting that
137    /// at least *something* got registered.
138    pub fn total(&self) -> usize {
139        self.by_plugin.values().map(|v| v.len()).sum()
140    }
141}
142
143/// Builder for the user binary's hand-registered routes.
144///
145/// Replaces the `(.router(...) + .route_paths([...]))` double-entry
146/// pattern with a single builder that records both as you go:
147///
148/// ```ignore
149/// use umbral::prelude::*;
150///
151/// App::builder()
152///     .routes(
153///         Routes::new()
154///             .get("/", home)
155///             .get("/articles", list_articles_html)
156///             .get("/articles/{id}", article_detail)
157///             .post("/api/articles", create_article),
158///     )
159///     .build()?;
160/// ```
161///
162/// Behind the scenes each `.get(...)` / `.post(...)` / etc. call
163/// records a [`RouteSpec`] *and* registers the handler with an
164/// internal `axum::Router`. `AppBuilder::routes` extracts both —
165/// the router becomes the user-binary router, the specs flow into
166/// the [`RouteRegistry`] for the dev-mode 404 page.
167///
168/// ## Why this exists
169///
170/// axum's `Router` doesn't expose its internal route table, so the
171/// framework can't introspect what was registered. The old API
172/// asked users to declare paths twice — once via `.route(...)` for
173/// the actual handler, once via `.route_paths([...])` for the dev
174/// 404 surface. `Routes` tracks both in one call.
175///
176/// ## Escape hatches
177///
178/// - **Need axum middleware / nest / fallback / State?** Build a
179///   plain `axum::Router` and pass it to [`Routes::with_router`].
180///   That router merges into the tracked one; you'll need to
181///   declare its paths via `route_paths(...)` if you want them in
182///   the dev 404 page.
183/// - **Multi-method route on one path?** Use [`Routes::route`]
184///   with an explicit method list.
185#[must_use = "Routes must be passed to AppBuilder::routes to take effect"]
186pub struct Routes {
187    inner: Router,
188    specs: Vec<RouteSpec>,
189}
190
191impl Routes {
192    /// Empty builder.
193    pub fn new() -> Self {
194        Self {
195            inner: Router::new(),
196            specs: Vec::new(),
197        }
198    }
199
200    /// Register a `GET` handler. Same handler shape as
201    /// `axum::routing::get(...)`.
202    pub fn get<H, T>(self, path: &str, handler: H) -> Self
203    where
204        H: Handler<T, ()>,
205        T: 'static,
206    {
207        self.with_method("GET", path, axum::routing::get(handler))
208    }
209
210    /// Register a `POST` handler.
211    pub fn post<H, T>(self, path: &str, handler: H) -> Self
212    where
213        H: Handler<T, ()>,
214        T: 'static,
215    {
216        self.with_method("POST", path, axum::routing::post(handler))
217    }
218
219    /// Register a `PUT` handler.
220    pub fn put<H, T>(self, path: &str, handler: H) -> Self
221    where
222        H: Handler<T, ()>,
223        T: 'static,
224    {
225        self.with_method("PUT", path, axum::routing::put(handler))
226    }
227
228    /// Register a `PATCH` handler.
229    pub fn patch<H, T>(self, path: &str, handler: H) -> Self
230    where
231        H: Handler<T, ()>,
232        T: 'static,
233    {
234        self.with_method("PATCH", path, axum::routing::patch(handler))
235    }
236
237    /// Register a `DELETE` handler.
238    pub fn delete<H, T>(self, path: &str, handler: H) -> Self
239    where
240        H: Handler<T, ()>,
241        T: 'static,
242    {
243        self.with_method("DELETE", path, axum::routing::delete(handler))
244    }
245
246    /// Register a `HEAD` handler.
247    pub fn head<H, T>(self, path: &str, handler: H) -> Self
248    where
249        H: Handler<T, ()>,
250        T: 'static,
251    {
252        self.with_method("HEAD", path, axum::routing::head(handler))
253    }
254
255    /// Register a `OPTIONS` handler.
256    pub fn options<H, T>(self, path: &str, handler: H) -> Self
257    where
258        H: Handler<T, ()>,
259        T: 'static,
260    {
261        self.with_method("OPTIONS", path, axum::routing::options(handler))
262    }
263
264    /// Register a path with a single method using a pre-built
265    /// `MethodRouter` — the right shape for per-route middleware.
266    ///
267    /// The per-method shorthands above accept a bare handler, which
268    /// the framework wraps in `axum::routing::<method>(...)` for you.
269    /// When you need to layer middleware (`login_required_html`,
270    /// rate-limiting, per-route timeouts, etc.) you need the
271    /// `MethodRouter` form so you can chain `.layer(...)`:
272    ///
273    /// ```ignore
274    /// use axum::routing::get;
275    ///
276    /// Routes::new()
277    ///     .get("/", home)                                 // bare handler
278    ///     .layered("GET", "/dashboard", get(dashboard)    // layered
279    ///         .layer(login_required_html("/login")))
280    /// ```
281    ///
282    /// The layer attaches to *this route only* — exactly what
283    /// `axum::routing::MethodRouter::layer` already does. The plain
284    /// `axum::Router::layer` would have applied to every route on
285    /// the Router instance, which is the gotcha the old scaffold
286    /// fell into.
287    ///
288    /// Sugar for [`Self::route`] with a single-element method slice.
289    pub fn layered(
290        self,
291        method: &'static str,
292        path: &str,
293        handler: axum::routing::MethodRouter<()>,
294    ) -> Self {
295        self.route(&[method], path, handler)
296    }
297
298    /// Register one or more methods on a path. Use this when several
299    /// HTTP verbs share a handler-router (`axum::routing::get(h1).post(h2)`)
300    /// — the per-method shorthands above each declare exactly one
301    /// method, so a chained `MethodRouter` needs this explicit form
302    /// to land its full method list in the registry.
303    ///
304    /// ```ignore
305    /// use axum::routing::{get, post};
306    ///
307    /// Routes::new().route(
308    ///     &["GET", "POST"],
309    ///     "/api/comments",
310    ///     get(list_comments).post(create_comment),
311    /// )
312    /// ```
313    pub fn route(
314        mut self,
315        methods: &[&'static str],
316        path: &str,
317        handler: axum::routing::MethodRouter<()>,
318    ) -> Self {
319        self.specs.push(RouteSpec {
320            path: path.to_string(),
321            methods: methods.to_vec(),
322        });
323        self.inner = self.inner.route(path, handler);
324        self
325    }
326
327    /// Merge a pre-built `axum::Router` into the tracked routes.
328    ///
329    /// Use when you need axum features the per-method shorthands
330    /// don't expose: `nest`, `fallback`, middleware layers, typed
331    /// State, etc. The merged router contributes its handlers but
332    /// *not* its paths — paths inside the external router aren't
333    /// visible to the framework, so they won't appear in the dev
334    /// 404 page unless you declare them via
335    /// [`AppBuilder::route_paths`](crate::AppBuilder::route_paths).
336    pub fn with_router(mut self, router: Router) -> Self {
337        self.inner = self.inner.merge(router);
338        self
339    }
340
341    /// Consume into the inner axum Router plus the tracked specs.
342    /// `AppBuilder::routes` is the canonical consumer.
343    pub fn into_parts(self) -> (Router, Vec<RouteSpec>) {
344        (self.inner, self.specs)
345    }
346
347    /// Shared body for the per-method shorthands.
348    fn with_method(
349        mut self,
350        method: &'static str,
351        path: &str,
352        handler: axum::routing::MethodRouter<()>,
353    ) -> Self {
354        self.specs.push(RouteSpec {
355            path: path.to_string(),
356            methods: vec![method],
357        });
358        self.inner = self.inner.route(path, handler);
359        self
360    }
361}
362
363impl Default for Routes {
364    fn default() -> Self {
365        Self::new()
366    }
367}
368
369static REGISTRY: OnceLock<RouteRegistry> = OnceLock::new();
370
371/// Publish the registry. Called from `App::build()` after every
372/// plugin's `route_paths()` has been collected. Safe to call exactly
373/// once; subsequent calls are no-ops.
374pub fn init(registry: RouteRegistry) {
375    let _ = REGISTRY.set(registry);
376}
377
378/// Read the registry. Returns `None` if `init` hasn't been called
379/// (production binaries that bypass `App::build()`, tests that
380/// short-circuit the build flow). Callers should treat `None` as
381/// "no routes to surface" rather than as an error.
382pub fn get() -> Option<&'static RouteRegistry> {
383    REGISTRY.get()
384}
385
386// =========================================================================
387// OpenAPI path registry (BUG-20).
388//
389// `Plugin::openapi_paths()` lets a plugin contribute fully-formed
390// OpenAPI Path Item Objects keyed by URL. App::build collects every
391// plugin's contribution into a flat Vec and publishes it here; the
392// umbral-openapi crate reads from this at spec-build time.
393//
394// The shape mirrors `RouteRegistry`: a OnceLock with the same `init`
395// / `get` pattern, lifecycle bound to `App::build()`. Returning
396// `None` is the "build wasn't called" case; consumers treat that
397// the same as "no plugin contributed routes."
398// =========================================================================
399
400static OPENAPI_REGISTRY: OnceLock<Vec<(String, serde_json::Value)>> = OnceLock::new();
401
402/// Publish the OpenAPI registry. Called from `App::build()` after
403/// every plugin's `openapi_paths()` has been collected.
404pub fn init_openapi(entries: Vec<(String, serde_json::Value)>) {
405    let _ = OPENAPI_REGISTRY.set(entries);
406}
407
408/// Read the OpenAPI registry. `None` for pre-build callers.
409pub fn registered_openapi_paths() -> Option<&'static [(String, serde_json::Value)]> {
410    OPENAPI_REGISTRY.get().map(|v| v.as_slice())
411}
412
413// The URL the OpenAPI JSON spec is served at. Populated by
414// `umbral-openapi`'s `Plugin::routes()` so cross-plugin consumers
415// (notably `umbral-playground`, which has to fetch the spec from
416// the SPA) can discover the configured mount without taking a
417// cross-plugin dependency on `umbral-openapi`. The default of
418// `/openapi/openapi.json` becomes wrong the moment the user calls
419// `OpenApiPlugin::default().at("/api/docs")`; this registry is
420// how the playground's SPA learns about that remap.
421static OPENAPI_SPEC_URL: OnceLock<String> = OnceLock::new();
422
423/// Publish the OpenAPI spec URL. Called from
424/// `OpenApiPlugin::routes()` with the configured mount point.
425pub fn init_openapi_spec_url(url: String) {
426    let _ = OPENAPI_SPEC_URL.set(url);
427}
428
429/// Read the OpenAPI spec URL. `None` when OpenApiPlugin isn't
430/// installed (the OnceLock was never populated). Consumers
431/// typically fall back to `/openapi/openapi.json` for backwards
432/// compat when this returns `None`.
433pub fn registered_openapi_spec_url() -> Option<&'static str> {
434    OPENAPI_SPEC_URL.get().map(|s| s.as_str())
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    async fn dummy_get() -> &'static str {
442        "ok"
443    }
444    async fn dummy_post() -> &'static str {
445        "ok"
446    }
447
448    #[test]
449    fn routes_builder_records_one_spec_per_get_with_method_and_path() {
450        let (_router, specs) = Routes::new()
451            .get("/", dummy_get)
452            .get("/articles", dummy_get)
453            .post("/api/articles", dummy_post)
454            .into_parts();
455
456        assert_eq!(specs.len(), 3, "one spec per builder call: {specs:?}");
457        assert_eq!(specs[0].path, "/");
458        assert_eq!(specs[0].methods, vec!["GET"]);
459        assert_eq!(specs[1].path, "/articles");
460        assert_eq!(specs[1].methods, vec!["GET"]);
461        assert_eq!(specs[2].path, "/api/articles");
462        assert_eq!(specs[2].methods, vec!["POST"]);
463    }
464
465    #[test]
466    fn routes_builder_supports_multi_method_via_route() {
467        use axum::routing::get;
468        let (_router, specs) = Routes::new()
469            .route(
470                &["GET", "POST"],
471                "/api/comments",
472                get(dummy_get).post(dummy_post),
473            )
474            .into_parts();
475
476        assert_eq!(specs.len(), 1);
477        assert_eq!(specs[0].path, "/api/comments");
478        assert_eq!(specs[0].methods, vec!["GET", "POST"]);
479    }
480
481    #[test]
482    fn routes_with_router_merges_axum_router_silently() {
483        use axum::Router;
484        use axum::routing::get;
485        let external = Router::new().route("/external", get(dummy_get));
486        let (_router, specs) = Routes::new()
487            .get("/tracked", dummy_get)
488            .with_router(external)
489            .into_parts();
490
491        // Only the tracked route is in specs; the merged axum router
492        // contributes its handler without surfacing its path in the
493        // registry. That's the documented contract.
494        assert_eq!(specs.len(), 1);
495        assert_eq!(specs[0].path, "/tracked");
496    }
497
498    #[test]
499    fn total_sums_per_plugin_paths_and_handles_empty_groups() {
500        let mut reg = RouteRegistry::default();
501        reg.by_plugin
502            .insert("app".to_string(), vec!["/".into(), "/articles".into()]);
503        reg.by_plugin.insert(
504            "admin".to_string(),
505            vec![
506                "/admin/".into(),
507                "/admin/login".into(),
508                "/admin/logout".into(),
509            ],
510        );
511        reg.by_plugin.insert("sessions".to_string(), Vec::new());
512
513        assert_eq!(reg.total(), 5);
514    }
515}