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::collections::HashMap;
12use std::sync::Arc;
13
14use axum::Router;
15use laterite_core::{Db, Events, Registry, Text};
16
17use crate::form::{FormHandle, Forms};
18use crate::list::ImportLink;
19
20/// What a contributed route is given at boot.
21///
22/// Deliberately narrow: the admin's own router state stays private, so its
23/// composition can change without breaking a screen. Fields are read through
24/// accessors, so this can gain context after 1.0; `new` cannot, and extra context
25/// would arrive as a builder.
26#[derive(Clone)]
27pub struct RouteCtx {
28 db: Db,
29 base_path: Arc<str>,
30 admin_path: Arc<str>,
31 base_url: Arc<str>,
32 plugin_defined: Arc<Registry>,
33 events: Events,
34 forms: Forms,
35}
36
37impl RouteCtx {
38 pub(crate) fn new(
39 db: Db,
40 base_path: &str,
41 admin_path: &str,
42 base_url: &str,
43 plugin_defined: Arc<Registry>,
44 events: Events,
45 forms: Forms,
46 ) -> Self {
47 Self {
48 db,
49 base_path: Arc::from(base_path),
50 admin_path: Arc::from(admin_path),
51 base_url: Arc::from(base_url),
52 plugin_defined,
53 events,
54 forms,
55 }
56 }
57
58 pub fn db(&self) -> &Db {
59 &self.db
60 }
61
62 /// A resource's form, to write its records from code: an importer, a sync,
63 /// anything that saves what an operator could have typed.
64 ///
65 /// `path` is the resource's path as its descriptor declares it, relative to
66 /// the admin root (`/products`). `None` when no resource mounts there or the
67 /// one that does has no form. The handle is `Clone` and owns what it needs,
68 /// so a detached task may keep it.
69 pub fn form(&self, path: &str) -> Option<FormHandle> {
70 self.forms
71 .get(path)
72 .map(|form| FormHandle::new(self.db.clone(), form.clone()))
73 }
74
75 /// The application's event bus, for a route that announces a fact:
76 /// `ctx.events().emit(&Published { article_id }).await`. Clone it into the
77 /// route's state.
78 pub fn events(&self) -> &Events {
79 &self.events
80 }
81
82 /// The site's own origin, without a trailing slash: the configured
83 /// `app.url`, falling back to the bind address when none is declared.
84 ///
85 /// For the absolute URLs a route has to emit rather than link: a `<loc>` in a
86 /// sitemap, a canonical URL, an entry in a feed. The request's `Host` header
87 /// is not a substitute, because behind a proxy it is whatever the proxy sent.
88 pub fn base_url(&self) -> &str {
89 &self.base_url
90 }
91
92 /// The contributions of type `T` that the framework itself does not consume.
93 ///
94 /// This is how one module reads another's contributions. A module declaring
95 /// its own kind of extension point defines a type, other modules contribute
96 /// it from their `register`, and the route reads them here: the framework
97 /// never learns the type, and registration order does not matter because
98 /// everything is collected before any route runs.
99 pub fn contributions<T: Send + Sync + 'static>(&self) -> Vec<&T> {
100 self.plugin_defined.items::<T>()
101 }
102
103 /// Where this screen actually mounted, after the module namespace, any base
104 /// it declared, the deployment's overrides and the admin mount.
105 pub fn base_path(&self) -> &str {
106 &self.base_path
107 }
108
109 /// Where the admin panel is mounted, after `backend.path`. Ask rather than
110 /// assuming `/admin`: an operator may have moved it, and a module building a
111 /// link into the panel, or excluding it from a sitemap, needs the real one.
112 pub fn admin_path(&self) -> &str {
113 &self.admin_path
114 }
115
116 /// An absolute admin URL under this screen. Build every self-link this way:
117 /// a deployment can move the screen, so a hardcoded path would break.
118 pub fn url(&self, relative: &str) -> String {
119 join(&self.base_path, relative)
120 }
121
122 /// A context built by hand, for testing a route without booting the framework.
123 ///
124 /// `mount` takes a `RouteCtx`, and the framework's own constructor is private,
125 /// so without this a plugin could only exercise its routes through a full boot.
126 /// With it, a route is an ordinary `tower` service:
127 ///
128 /// ```ignore
129 /// let ctx = RouteCtx::builder(db).admin_path("/backoffice").build();
130 /// let response = MyRoute.mount(&ctx).oneshot(request).await?;
131 /// ```
132 ///
133 /// This is also how the context grows after 1.0: a new field gets a builder
134 /// method and a default, which `new` could not have offered.
135 pub fn builder(db: Db) -> RouteCtxBuilder {
136 RouteCtxBuilder {
137 db,
138 base_path: "/".to_string(),
139 admin_path: "/admin".to_string(),
140 base_url: "http://localhost".to_string(),
141 plugin_defined: None,
142 events: None,
143 }
144 }
145}
146
147/// Builds a [`RouteCtx`]. Every field has a default, so a test names only what it
148/// asserts on.
149pub struct RouteCtxBuilder {
150 db: Db,
151 base_path: String,
152 admin_path: String,
153 base_url: String,
154 plugin_defined: Option<Arc<Registry>>,
155 events: Option<Events>,
156}
157
158impl RouteCtxBuilder {
159 /// Where the screen mounted. Defaults to `/`.
160 pub fn base_path(mut self, path: impl Into<String>) -> Self {
161 self.base_path = path.into();
162 self
163 }
164
165 /// Where the panel mounted. Defaults to `/admin`, which is what a route
166 /// excluding the panel should *not* be allowed to assume: set it to something
167 /// else in a test that checks the exclusion is read rather than hardcoded.
168 pub fn admin_path(mut self, path: impl Into<String>) -> Self {
169 self.admin_path = path.into();
170 self
171 }
172
173 /// The site's origin. Defaults to `http://localhost`.
174 pub fn base_url(mut self, url: impl Into<String>) -> Self {
175 self.base_url = url.into();
176 self
177 }
178
179 /// The contributions `RouteCtx::contributions` will read. Defaults to empty.
180 pub fn contributions(mut self, registry: Arc<Registry>) -> Self {
181 self.plugin_defined = Some(registry);
182 self
183 }
184
185 /// The bus `RouteCtx::events` will hand out. Defaults to one nothing
186 /// listens on; pass one built with a listener to assert on what a route
187 /// announces.
188 pub fn events(mut self, events: Events) -> Self {
189 self.events = Some(events);
190 self
191 }
192
193 pub fn build(self) -> RouteCtx {
194 let events = self.events.unwrap_or_else(|| Events::new(self.db.clone()));
195 RouteCtx::new(
196 self.db,
197 &self.base_path,
198 &self.admin_path,
199 &self.base_url,
200 self.plugin_defined
201 .unwrap_or_else(|| Arc::new(Registry::new())),
202 events,
203 Arc::new(HashMap::new()),
204 )
205 }
206}
207
208fn join(base: &str, relative: &str) -> String {
209 let base = base.trim_end_matches('/');
210 let relative = relative.trim_start_matches('/');
211 if relative.is_empty() {
212 base.to_string()
213 } else {
214 format!("{base}/{relative}")
215 }
216}
217
218/// A module's own admin screen.
219pub trait Screen: Send + Sync + 'static {
220 /// The routes this screen serves, relative to its own base. Handlers capture
221 /// what they need from `ctx`, so the router carries no framework state.
222 fn mount(&self, ctx: &RouteCtx) -> Router;
223}
224
225/// A registered screen: where it mounts, what it is called, and who may reach it.
226///
227/// Built with [`ScreenReg::new`] plus its builder methods. Non-exhaustive so the
228/// framework can learn something new about a screen after 1.0 without a major bump:
229/// anything it needs to know belongs here rather than as another trait method, which
230/// is also what keeps the code half replaceable at the plugin boundary.
231#[non_exhaustive]
232pub struct ScreenReg {
233 /// Path relative to the module's admin base, starting with a slash.
234 pub base_path: String,
235 /// Menu label, localized at render. `None` mounts it without a nav entry.
236 pub nav_label: Option<Text>,
237 /// Where the entry sits: under a section, with an icon, at a position.
238 pub menu: crate::nav::Placement,
239 /// The permission every route under this screen requires. Required, because
240 /// an unguarded screen exposes whatever it serves to any signed-in operator.
241 pub permission: String,
242 pub screen: Arc<dyn Screen>,
243}
244
245impl ScreenReg {
246 pub fn new(
247 base_path: impl Into<String>,
248 permission: impl Into<String>,
249 screen: Arc<dyn Screen>,
250 ) -> Self {
251 Self {
252 base_path: base_path.into(),
253 nav_label: None,
254 menu: crate::nav::Placement::default(),
255 permission: permission.into(),
256 screen,
257 }
258 }
259
260 /// Shows this screen in the menu under `label`.
261 pub fn in_menu(mut self, label: impl Into<Text>) -> Self {
262 self.nav_label = Some(label.into());
263 self
264 }
265
266 /// Where the menu entry sits. See [`Placement`](crate::nav::Placement).
267 pub fn menu(mut self, placement: crate::nav::Placement) -> Self {
268 self.menu = placement;
269 self
270 }
271}
272
273/// A module's own route outside the admin.
274///
275/// Public by definition: no permission gate and no admin chrome, though the
276/// styled error pages still apply. A route that accepts a POST declares its own
277/// stance on CSRF, because the admin's blanket protection assumes a session.
278pub trait PublicRoute: Send + Sync + 'static {
279 /// The routes this endpoint serves, relative to its declared path.
280 fn mount(&self, ctx: &RouteCtx) -> Router;
281}
282
283/// A registered public route.
284///
285/// The path is literal and never namespaced: `/robots.txt` has to be exactly
286/// that. Collisions are therefore likelier than in the admin, so two claims on
287/// one path, or a claim on the admin mount, abort the boot.
288/// Built with [`PublicRouteReg::new`]. Non-exhaustive for the same reason as
289/// [`ScreenReg`].
290#[non_exhaustive]
291pub struct PublicRouteReg {
292 pub path: String,
293 pub route: Arc<dyn PublicRoute>,
294}
295
296impl PublicRouteReg {
297 pub fn new(path: impl Into<String>, route: Arc<dyn PublicRoute>) -> Self {
298 Self {
299 path: path.into(),
300 route,
301 }
302 }
303}
304
305/// A feature a module mounts under every resource whose descriptor declares
306/// its key: the routes behind `import: true`, say.
307///
308/// The admin owns the key and the button; the module owns the routes. A
309/// descriptor declaring a key no module serves aborts boot naming it, so the
310/// admin never depends on the module.
311pub trait ResourceFeature: Send + Sync + 'static {
312 /// The routes, relative to `{resource}/{key}`, which is `ctx.base_path()`.
313 fn mount(&self, ctx: &RouteCtx, resource: &ResourceInfo) -> Router;
314}
315
316/// A registered feature, keyed by the descriptor key it serves. Mounted under
317/// every resource declaring that key, behind the key's permission or, when the
318/// key names none, the resource's.
319#[non_exhaustive]
320pub struct ResourceFeatureReg {
321 pub key: String,
322 pub feature: Arc<dyn ResourceFeature>,
323}
324
325impl ResourceFeatureReg {
326 pub fn new(key: impl Into<String>, feature: Arc<dyn ResourceFeature>) -> Self {
327 Self {
328 key: key.into(),
329 feature,
330 }
331 }
332}
333
334/// The resource a feature mounts under, as the feature sees it.
335#[derive(Clone)]
336#[non_exhaustive]
337pub struct ResourceInfo {
338 /// The resource's path as its descriptor declares it, relative to the
339 /// admin root (`/products`).
340 pub path: String,
341 /// Where its list mounted (`/admin/products`).
342 pub base_path: String,
343 /// The resource's menu label.
344 pub label: Text,
345 /// The permission the feature's routes are gated by.
346 pub permission: String,
347 /// The resource's form, to write its records through.
348 pub form: FormHandle,
349}
350
351impl ResourceInfo {
352 pub(crate) fn new(
353 path: String,
354 base_path: String,
355 label: Text,
356 permission: String,
357 form: FormHandle,
358 ) -> Self {
359 Self {
360 path,
361 base_path,
362 label,
363 permission,
364 form,
365 }
366 }
367}
368
369/// The link a list shows to a feature mounted under it.
370pub(crate) fn feature_link(base_path: &str, key: &str, permission: &str) -> ImportLink {
371 ImportLink {
372 href: format!("{base_path}/{key}"),
373 permission: permission.to_string(),
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 #[test]
382 fn a_self_link_builds_from_where_the_screen_actually_mounted() {
383 let base = "/admin/rainmill/location/import";
384 assert_eq!(
385 join(base, "/step2"),
386 "/admin/rainmill/location/import/step2"
387 );
388 assert_eq!(join(base, "step2"), "/admin/rainmill/location/import/step2");
389 assert_eq!(join(base, ""), "/admin/rainmill/location/import");
390 }
391
392 #[test]
393 fn a_moved_screen_moves_its_links_with_it() {
394 assert_eq!(join("/admin/places", "/step2"), "/admin/places/step2");
395 }
396
397 #[test]
398 fn a_registration_defaults_to_no_menu_entry() {
399 struct Noop;
400 impl Screen for Noop {
401 fn mount(&self, _ctx: &RouteCtx) -> Router {
402 Router::new()
403 }
404 }
405 let reg = ScreenReg::new("/import", "acme.import", Arc::new(Noop));
406 assert!(reg.nav_label.is_none());
407 assert!(reg.in_menu("Import").nav_label.is_some());
408 }
409}