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