Skip to main content

laterite_admin/
descriptor.rs

1//! Reading a resource from a YAML file.
2//!
3//! One file describes one [`Resource`]: what it is, where it mounts, who may
4//! reach it, and the list and form screens over it.
5//!
6//! ```yaml
7//! entity: posts
8//! path: /posts
9//! title: Posts
10//! permission: acme.blog.manage_posts
11//! list:
12//!   order_by: created_at
13//!   order_dir: desc
14//!   creatable: true
15//!   columns:
16//!     title:      { searchable: true }
17//!     status:     { type: status_pill, width: 10% }
18//!     created_at: { type: datetime, label: Created }
19//! form:
20//!   title: Post
21//!   fields:
22//!     title:  { rules: [required, { max_length: 120 }] }
23//!     status: { type: select, options: { options: { draft: Draft } } }
24//! ```
25//!
26//! The entity, the path and the id column are written once at the top and
27//! filled into both screens, so the two cannot disagree about what they edit.
28//! A key the framework does not know is an error rather than a shrug: a typo in
29//! a data-authored admin is otherwise invisible until someone notices the
30//! screen is wrong.
31
32use serde::Deserialize;
33
34use crate::form::FormConfig;
35use crate::list::ListConfig;
36use crate::Resource;
37use laterite_core::Text;
38
39/// A resource as a descriptor file spells it.
40#[derive(Debug, Deserialize)]
41#[serde(deny_unknown_fields)]
42struct ResourceFile {
43    /// The table the screens read and write.
44    entity: String,
45    /// Where the resource mounts, under the module's own namespace.
46    path: String,
47    /// The navigation label, and the default title for both screens.
48    title: Text,
49    /// Required once the resource has a form: an unguarded write aborts boot.
50    #[serde(default)]
51    permission: Option<String>,
52    /// The primary key column.
53    #[serde(default = "default_id_field")]
54    id_field: String,
55    list: ListConfig,
56    #[serde(default)]
57    form: Option<FormConfig>,
58}
59
60fn default_id_field() -> String {
61    "id".to_string()
62}
63
64/// What went wrong reading a descriptor file.
65#[derive(Debug)]
66pub struct DescriptorError {
67    /// The file, as the caller named it.
68    pub source: String,
69    pub message: String,
70}
71
72impl std::fmt::Display for DescriptorError {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        write!(f, "{}: {}", self.source, self.message)
75    }
76}
77
78impl std::error::Error for DescriptorError {}
79
80/// Reads a resource from the contents of a descriptor file.
81///
82/// `source` names the file in any error, so a message points at something the
83/// author can open.
84pub fn from_yaml(yaml: &str, source: &str) -> Result<Resource, DescriptorError> {
85    let file: ResourceFile = serde_saphyr::from_str(yaml).map_err(|e| DescriptorError {
86        source: source.to_string(),
87        message: e.to_string(),
88    })?;
89
90    let mut list = file.list;
91    list.entity = file.entity.clone();
92    list.id_field = file.id_field.clone();
93    if list.title.source().is_empty() {
94        list.title = file.title.clone();
95    }
96    // Rows link to the form when there is one, so a file need not repeat the
97    // path it already gave.
98    if file.form.is_some() && list.edit_base.is_none() {
99        list.edit_base = Some(file.path.clone());
100    }
101    for column in &mut list.columns {
102        if column.label.source().is_empty() {
103            column.label = humanize(&column.field);
104        }
105    }
106    for filter in &mut list.filters {
107        if filter.label.source().is_empty() {
108            filter.label = humanize(&filter.field);
109        }
110    }
111
112    let mut resource = Resource::new(file.path.clone(), file.title.clone(), list);
113    if let Some(permission) = file.permission {
114        resource = resource.permission(permission);
115    }
116    if let Some(mut form) = file.form {
117        form.entity = file.entity;
118        form.id_field = file.id_field;
119        form.base_path = file.path;
120        if form.title.source().is_empty() {
121            form.title = file.title;
122        }
123        for field in &mut form.fields {
124            if field.label.source().is_empty() {
125                field.label = humanize(&field.name);
126            }
127        }
128        resource = resource.form(form);
129    }
130    Ok(resource)
131}
132
133/// Reads a descriptor file at compile time, relative to the crate's manifest.
134///
135/// ```rust,ignore
136/// registry.add_resource(laterite_admin::resource!("descriptors/posts.yaml"));
137/// ```
138///
139/// The file is embedded, so a built binary carries its screens and a missing or
140/// malformed one fails the build rather than the deployment. The path is
141/// manifest-relative, so it is the same path you would open in an editor, and
142/// it appears in any error.
143///
144/// A descriptor that must be read at run time is a different producer of the
145/// same [`Resource`]: call [`from_yaml`] with whatever you loaded.
146#[macro_export]
147macro_rules! resource {
148    ($path:literal) => {
149        $crate::descriptor::from_yaml(
150            ::std::include_str!(::std::concat!(
151                ::std::env!("CARGO_MANIFEST_DIR"),
152                "/",
153                $path
154            )),
155            $path,
156        )
157        .unwrap_or_else(|e| ::std::panic!("{e}"))
158    };
159}
160
161/// `created_at` reads as "Created at" when a file names no label. Still a
162/// `Text`, so it localizes and `lat i18n extract` finds it like any other.
163fn humanize(name: &str) -> Text {
164    let mut out = String::with_capacity(name.len());
165    for (i, part) in name.split(['_', '-']).enumerate() {
166        if part.is_empty() {
167            continue;
168        }
169        if i > 0 {
170            out.push(' ');
171        }
172        let mut chars = part.chars();
173        if let Some(first) = chars.next() {
174            if i == 0 {
175                out.extend(first.to_uppercase());
176            } else {
177                out.push(first);
178            }
179            out.push_str(chars.as_str());
180        }
181    }
182    Text::dynamic(out)
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    const POSTS: &str = r#"
190entity: posts
191path: /posts
192title: Posts
193permission: acme.manage_posts
194list:
195  creatable: true
196  order_by: created_at
197  order_dir: desc
198  columns:
199    title:      { searchable: true }
200    created_at: { type: datetime }
201form:
202  fields:
203    title: { rules: [required] }
204"#;
205
206    #[test]
207    fn a_file_describes_one_resource() {
208        let resource = from_yaml(POSTS, "posts.yaml").expect("reads");
209        assert_eq!(resource.base_path, "/posts");
210        assert_eq!(resource.permission.as_deref(), Some("acme.manage_posts"));
211        assert_eq!(resource.list.entity, "posts");
212        assert_eq!(resource.list.order_by, "created_at");
213        assert!(resource.list.creatable);
214    }
215
216    /// The entity, id column and path are written once and reach both screens,
217    /// so a file cannot say one thing to the list and another to the form.
218    #[test]
219    fn the_resource_level_keys_fill_both_screens() {
220        let resource = from_yaml(POSTS, "posts.yaml").expect("reads");
221        let form = resource.form.as_ref().expect("a form");
222        assert_eq!(form.entity, "posts");
223        assert_eq!(form.base_path, "/posts");
224        assert_eq!(form.id_field, "id");
225        assert_eq!(resource.list.id_field, "id");
226        assert_eq!(resource.list.edit_base.as_deref(), Some("/posts"));
227    }
228
229    #[test]
230    fn a_column_is_named_by_its_key_and_labelled_from_it() {
231        let resource = from_yaml(POSTS, "posts.yaml").expect("reads");
232        let names: Vec<&str> = resource
233            .list
234            .columns
235            .iter()
236            .map(|c| c.field.as_str())
237            .collect();
238        assert_eq!(names, ["title", "created_at"], "in the order written");
239        assert_eq!(resource.list.columns[1].label.source(), "Created at");
240        assert!(resource.list.columns[0].is_searchable());
241    }
242
243    #[test]
244    fn a_key_the_framework_does_not_know_is_refused() {
245        let yaml = POSTS.replace("searchable: true", "searchabel: true");
246        let err = from_yaml(&yaml, "posts.yaml")
247            .err()
248            .expect("a typo is an error");
249        assert_eq!(err.source, "posts.yaml");
250        assert!(
251            err.message.contains("searchabel"),
252            "and names it: {}",
253            err.message
254        );
255    }
256
257    #[test]
258    fn a_missing_required_key_is_refused() {
259        let yaml = POSTS.replace("entity: posts\n", "");
260        let err = from_yaml(&yaml, "posts.yaml")
261            .err()
262            .expect("entity is required");
263        assert!(err.message.contains("entity"), "{}", err.message);
264    }
265
266    /// Writing the descriptor back out gives the file it came from, so the
267    /// format is a faithful projection of the structs rather than a lossy one.
268    #[test]
269    fn a_descriptor_round_trips_through_the_file_format() {
270        let resource = from_yaml(POSTS, "posts.yaml").expect("reads");
271        let written = serde_saphyr::to_string(&resource.list).expect("writes");
272        let read_back: ListConfig = serde_saphyr::from_str(&written).expect("reads back");
273        assert_eq!(
274            read_back
275                .columns
276                .iter()
277                .map(|c| (c.field.clone(), c.column_type.clone()))
278                .collect::<Vec<_>>(),
279            resource
280                .list
281                .columns
282                .iter()
283                .map(|c| (c.field.clone(), c.column_type.clone()))
284                .collect::<Vec<_>>(),
285            "columns survive, named and in order"
286        );
287        assert_eq!(read_back.order_by, resource.list.order_by);
288        assert_eq!(read_back.creatable, resource.list.creatable);
289    }
290
291    #[test]
292    fn a_file_may_describe_a_list_with_no_form() {
293        let yaml = POSTS.split("form:").next().unwrap();
294        let resource = from_yaml(yaml, "posts.yaml").expect("reads");
295        assert!(resource.form.is_none());
296        assert!(resource.list.edit_base.is_none(), "and links no rows");
297    }
298}