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, 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}
28
29impl RouteCtx {
30    pub(crate) fn new(db: Db, base_path: &str, admin_path: &str) -> Self {
31        Self {
32            db,
33            base_path: Arc::from(base_path),
34            admin_path: Arc::from(admin_path),
35        }
36    }
37
38    pub fn db(&self) -> &Db {
39        &self.db
40    }
41
42    /// Where this screen actually mounted, after the module namespace, any base
43    /// it declared, the deployment's overrides and the admin mount.
44    pub fn base_path(&self) -> &str {
45        &self.base_path
46    }
47
48    /// Where the admin panel is mounted, after `backend.path`. Ask rather than
49    /// assuming `/admin`: an operator may have moved it, and a module building a
50    /// link into the panel, or excluding it from a sitemap, needs the real one.
51    pub fn admin_path(&self) -> &str {
52        &self.admin_path
53    }
54
55    /// An absolute admin URL under this screen. Build every self-link this way:
56    /// a deployment can move the screen, so a hardcoded path would break.
57    pub fn url(&self, relative: &str) -> String {
58        join(&self.base_path, relative)
59    }
60}
61
62fn join(base: &str, relative: &str) -> String {
63    let base = base.trim_end_matches('/');
64    let relative = relative.trim_start_matches('/');
65    if relative.is_empty() {
66        base.to_string()
67    } else {
68        format!("{base}/{relative}")
69    }
70}
71
72/// A module's own admin screen.
73pub trait Screen: Send + Sync + 'static {
74    /// The routes this screen serves, relative to its own base. Handlers capture
75    /// what they need from `ctx`, so the router carries no framework state.
76    fn mount(&self, ctx: &RouteCtx) -> Router;
77}
78
79/// A registered screen: where it mounts, what it is called, and who may reach it.
80pub struct ScreenReg {
81    /// Path relative to the module's admin base, starting with a slash.
82    pub base_path: String,
83    /// Menu label, localized at render. `None` mounts it without a nav entry.
84    pub nav_label: Option<Text>,
85    /// The permission every route under this screen requires. Required, because
86    /// an unguarded screen exposes whatever it serves to any signed-in operator.
87    pub permission: String,
88    pub screen: Arc<dyn Screen>,
89}
90
91impl ScreenReg {
92    pub fn new(
93        base_path: impl Into<String>,
94        permission: impl Into<String>,
95        screen: Arc<dyn Screen>,
96    ) -> Self {
97        Self {
98            base_path: base_path.into(),
99            nav_label: None,
100            permission: permission.into(),
101            screen,
102        }
103    }
104
105    /// Shows this screen in the menu under `label`.
106    pub fn in_menu(mut self, label: impl Into<Text>) -> Self {
107        self.nav_label = Some(label.into());
108        self
109    }
110}
111
112/// A module's own route outside the admin.
113///
114/// Public by definition: no permission gate and no admin chrome, though the
115/// styled error pages still apply. A route that accepts a POST declares its own
116/// stance on CSRF, because the admin's blanket protection assumes a session.
117pub trait PublicRoute: Send + Sync + 'static {
118    /// The routes this endpoint serves, relative to its declared path.
119    fn mount(&self, ctx: &RouteCtx) -> Router;
120}
121
122/// A registered public route.
123///
124/// The path is literal and never namespaced: `/robots.txt` has to be exactly
125/// that. Collisions are therefore likelier than in the admin, so two claims on
126/// one path, or a claim on the admin mount, abort the boot.
127pub struct PublicRouteReg {
128    pub path: String,
129    pub route: Arc<dyn PublicRoute>,
130}
131
132impl PublicRouteReg {
133    pub fn new(path: impl Into<String>, route: Arc<dyn PublicRoute>) -> Self {
134        Self {
135            path: path.into(),
136            route,
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn a_self_link_builds_from_where_the_screen_actually_mounted() {
147        let base = "/admin/rainmill/location/import";
148        assert_eq!(
149            join(base, "/step2"),
150            "/admin/rainmill/location/import/step2"
151        );
152        assert_eq!(join(base, "step2"), "/admin/rainmill/location/import/step2");
153        assert_eq!(join(base, ""), "/admin/rainmill/location/import");
154    }
155
156    #[test]
157    fn a_moved_screen_moves_its_links_with_it() {
158        assert_eq!(join("/admin/places", "/step2"), "/admin/places/step2");
159    }
160
161    #[test]
162    fn a_registration_defaults_to_no_menu_entry() {
163        struct Noop;
164        impl Screen for Noop {
165            fn mount(&self, _ctx: &RouteCtx) -> Router {
166                Router::new()
167            }
168        }
169        let reg = ScreenReg::new("/import", "acme.import", Arc::new(Noop));
170        assert!(reg.nav_label.is_none());
171        assert!(reg.in_menu("Import").nav_label.is_some());
172    }
173}