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}