Skip to main content

laterite_admin/
routes.rs

1//! Routes a module mounts itself: admin screens, and public endpoints.
2//!
3//! A [`Resource`](crate::Resource) covers list and form screens as data. A
4//! [`Screen`] is the substrate underneath: any routes a module wants, mounted
5//! inside the admin with the same session, permission, CSRF and error handling a
6//! resource gets. Reach for it only when descriptors cannot express the screen.
7//!
8//! A [`PublicRoute`] is the same idea outside the admin, for the endpoints that
9//! have to live at a literal path: `/robots.txt`, a sitemap, a feed, a webhook.
10
11use std::sync::Arc;
12
13use axum::Router;
14use laterite_core::{Db, Events, Registry, Text};
15
16/// What a contributed route is given at boot.
17///
18/// Deliberately narrow: the admin's own router state stays private, so its
19/// composition can change without breaking a screen. Fields are read through
20/// accessors, so this can gain context after 1.0; `new` cannot, and extra context
21/// would arrive as a builder.
22#[derive(Clone)]
23pub struct RouteCtx {
24    db: Db,
25    base_path: Arc<str>,
26    admin_path: Arc<str>,
27    base_url: Arc<str>,
28    plugin_defined: Arc<Registry>,
29    events: Events,
30}
31
32impl RouteCtx {
33    pub(crate) fn new(
34        db: Db,
35        base_path: &str,
36        admin_path: &str,
37        base_url: &str,
38        plugin_defined: Arc<Registry>,
39        events: Events,
40    ) -> Self {
41        Self {
42            db,
43            base_path: Arc::from(base_path),
44            admin_path: Arc::from(admin_path),
45            base_url: Arc::from(base_url),
46            plugin_defined,
47            events,
48        }
49    }
50
51    pub fn db(&self) -> &Db {
52        &self.db
53    }
54
55    /// The application's event bus, for a route that announces a fact:
56    /// `ctx.events().emit(&Published { article_id }).await`. Clone it into the
57    /// route's state.
58    pub fn events(&self) -> &Events {
59        &self.events
60    }
61
62    /// The site's own origin, without a trailing slash: the configured
63    /// `app.url`, falling back to the bind address when none is declared.
64    ///
65    /// For the absolute URLs a route has to emit rather than link: a `<loc>` in a
66    /// sitemap, a canonical URL, an entry in a feed. The request's `Host` header
67    /// is not a substitute, because behind a proxy it is whatever the proxy sent.
68    pub fn base_url(&self) -> &str {
69        &self.base_url
70    }
71
72    /// The contributions of type `T` that the framework itself does not consume.
73    ///
74    /// This is how one module reads another's contributions. A module declaring
75    /// its own kind of extension point defines a type, other modules contribute
76    /// it from their `register`, and the route reads them here: the framework
77    /// never learns the type, and registration order does not matter because
78    /// everything is collected before any route runs.
79    pub fn contributions<T: Send + Sync + 'static>(&self) -> Vec<&T> {
80        self.plugin_defined.items::<T>()
81    }
82
83    /// Where this screen actually mounted, after the module namespace, any base
84    /// it declared, the deployment's overrides and the admin mount.
85    pub fn base_path(&self) -> &str {
86        &self.base_path
87    }
88
89    /// Where the admin panel is mounted, after `backend.path`. Ask rather than
90    /// assuming `/admin`: an operator may have moved it, and a module building a
91    /// link into the panel, or excluding it from a sitemap, needs the real one.
92    pub fn admin_path(&self) -> &str {
93        &self.admin_path
94    }
95
96    /// An absolute admin URL under this screen. Build every self-link this way:
97    /// a deployment can move the screen, so a hardcoded path would break.
98    pub fn url(&self, relative: &str) -> String {
99        join(&self.base_path, relative)
100    }
101
102    /// A context built by hand, for testing a route without booting the framework.
103    ///
104    /// `mount` takes a `RouteCtx`, and the framework's own constructor is private,
105    /// so without this a plugin could only exercise its routes through a full boot.
106    /// With it, a route is an ordinary `tower` service:
107    ///
108    /// ```ignore
109    /// let ctx = RouteCtx::builder(db).admin_path("/backoffice").build();
110    /// let response = MyRoute.mount(&ctx).oneshot(request).await?;
111    /// ```
112    ///
113    /// This is also how the context grows after 1.0: a new field gets a builder
114    /// method and a default, which `new` could not have offered.
115    pub fn builder(db: Db) -> RouteCtxBuilder {
116        RouteCtxBuilder {
117            db,
118            base_path: "/".to_string(),
119            admin_path: "/admin".to_string(),
120            base_url: "http://localhost".to_string(),
121            plugin_defined: None,
122            events: None,
123        }
124    }
125}
126
127/// Builds a [`RouteCtx`]. Every field has a default, so a test names only what it
128/// asserts on.
129pub struct RouteCtxBuilder {
130    db: Db,
131    base_path: String,
132    admin_path: String,
133    base_url: String,
134    plugin_defined: Option<Arc<Registry>>,
135    events: Option<Events>,
136}
137
138impl RouteCtxBuilder {
139    /// Where the screen mounted. Defaults to `/`.
140    pub fn base_path(mut self, path: impl Into<String>) -> Self {
141        self.base_path = path.into();
142        self
143    }
144
145    /// Where the panel mounted. Defaults to `/admin`, which is what a route
146    /// excluding the panel should *not* be allowed to assume: set it to something
147    /// else in a test that checks the exclusion is read rather than hardcoded.
148    pub fn admin_path(mut self, path: impl Into<String>) -> Self {
149        self.admin_path = path.into();
150        self
151    }
152
153    /// The site's origin. Defaults to `http://localhost`.
154    pub fn base_url(mut self, url: impl Into<String>) -> Self {
155        self.base_url = url.into();
156        self
157    }
158
159    /// The contributions `RouteCtx::contributions` will read. Defaults to empty.
160    pub fn contributions(mut self, registry: Arc<Registry>) -> Self {
161        self.plugin_defined = Some(registry);
162        self
163    }
164
165    /// The bus `RouteCtx::events` will hand out. Defaults to one nothing
166    /// listens on; pass one built with a listener to assert on what a route
167    /// announces.
168    pub fn events(mut self, events: Events) -> Self {
169        self.events = Some(events);
170        self
171    }
172
173    pub fn build(self) -> RouteCtx {
174        let events = self.events.unwrap_or_else(|| Events::new(self.db.clone()));
175        RouteCtx::new(
176            self.db,
177            &self.base_path,
178            &self.admin_path,
179            &self.base_url,
180            self.plugin_defined
181                .unwrap_or_else(|| Arc::new(Registry::new())),
182            events,
183        )
184    }
185}
186
187fn join(base: &str, relative: &str) -> String {
188    let base = base.trim_end_matches('/');
189    let relative = relative.trim_start_matches('/');
190    if relative.is_empty() {
191        base.to_string()
192    } else {
193        format!("{base}/{relative}")
194    }
195}
196
197/// A module's own admin screen.
198pub trait Screen: Send + Sync + 'static {
199    /// The routes this screen serves, relative to its own base. Handlers capture
200    /// what they need from `ctx`, so the router carries no framework state.
201    fn mount(&self, ctx: &RouteCtx) -> Router;
202}
203
204/// A registered screen: where it mounts, what it is called, and who may reach it.
205///
206/// Built with [`ScreenReg::new`] plus its builder methods. Non-exhaustive so the
207/// framework can learn something new about a screen after 1.0 without a major bump:
208/// anything it needs to know belongs here rather than as another trait method, which
209/// is also what keeps the code half replaceable at the plugin boundary.
210#[non_exhaustive]
211pub struct ScreenReg {
212    /// Path relative to the module's admin base, starting with a slash.
213    pub base_path: String,
214    /// Menu label, localized at render. `None` mounts it without a nav entry.
215    pub nav_label: Option<Text>,
216    /// The permission every route under this screen requires. Required, because
217    /// an unguarded screen exposes whatever it serves to any signed-in operator.
218    pub permission: String,
219    pub screen: Arc<dyn Screen>,
220}
221
222impl ScreenReg {
223    pub fn new(
224        base_path: impl Into<String>,
225        permission: impl Into<String>,
226        screen: Arc<dyn Screen>,
227    ) -> Self {
228        Self {
229            base_path: base_path.into(),
230            nav_label: None,
231            permission: permission.into(),
232            screen,
233        }
234    }
235
236    /// Shows this screen in the menu under `label`.
237    pub fn in_menu(mut self, label: impl Into<Text>) -> Self {
238        self.nav_label = Some(label.into());
239        self
240    }
241}
242
243/// A module's own route outside the admin.
244///
245/// Public by definition: no permission gate and no admin chrome, though the
246/// styled error pages still apply. A route that accepts a POST declares its own
247/// stance on CSRF, because the admin's blanket protection assumes a session.
248pub trait PublicRoute: Send + Sync + 'static {
249    /// The routes this endpoint serves, relative to its declared path.
250    fn mount(&self, ctx: &RouteCtx) -> Router;
251}
252
253/// A registered public route.
254///
255/// The path is literal and never namespaced: `/robots.txt` has to be exactly
256/// that. Collisions are therefore likelier than in the admin, so two claims on
257/// one path, or a claim on the admin mount, abort the boot.
258/// Built with [`PublicRouteReg::new`]. Non-exhaustive for the same reason as
259/// [`ScreenReg`].
260#[non_exhaustive]
261pub struct PublicRouteReg {
262    pub path: String,
263    pub route: Arc<dyn PublicRoute>,
264}
265
266impl PublicRouteReg {
267    pub fn new(path: impl Into<String>, route: Arc<dyn PublicRoute>) -> Self {
268        Self {
269            path: path.into(),
270            route,
271        }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn a_self_link_builds_from_where_the_screen_actually_mounted() {
281        let base = "/admin/rainmill/location/import";
282        assert_eq!(
283            join(base, "/step2"),
284            "/admin/rainmill/location/import/step2"
285        );
286        assert_eq!(join(base, "step2"), "/admin/rainmill/location/import/step2");
287        assert_eq!(join(base, ""), "/admin/rainmill/location/import");
288    }
289
290    #[test]
291    fn a_moved_screen_moves_its_links_with_it() {
292        assert_eq!(join("/admin/places", "/step2"), "/admin/places/step2");
293    }
294
295    #[test]
296    fn a_registration_defaults_to_no_menu_entry() {
297        struct Noop;
298        impl Screen for Noop {
299            fn mount(&self, _ctx: &RouteCtx) -> Router {
300                Router::new()
301            }
302        }
303        let reg = ScreenReg::new("/import", "acme.import", Arc::new(Noop));
304        assert!(reg.nav_label.is_none());
305        assert!(reg.in_menu("Import").nav_label.is_some());
306    }
307}