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