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}