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: [{ value: draft, label: 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::relation::RelationConfig;
37use crate::{ImportConfig, Resource};
38use laterite_core::Text;
39
40/// A resource as a descriptor file spells it.
41#[derive(Debug, Deserialize)]
42#[serde(deny_unknown_fields)]
43struct ResourceFile {
44    /// The table the screens read and write.
45    entity: String,
46    /// Where the resource mounts, under the module's own namespace.
47    path: String,
48    /// The navigation label, and the default title for both screens.
49    title: Text,
50    /// Required once the resource has a form: an unguarded write aborts boot.
51    #[serde(default)]
52    permission: Option<String>,
53    /// The primary key column.
54    #[serde(default = "default_id_field")]
55    id_field: String,
56    list: ListConfig,
57    #[serde(default)]
58    form: Option<FormConfig>,
59    /// `true`, or a map of [`ImportConfig`] keys. Absent or `false` means the
60    /// resource is not imported into.
61    #[serde(default)]
62    import: Option<ImportKey>,
63    /// The picker source the command palette finds records through.
64    #[serde(default)]
65    palette: Option<String>,
66    /// Related records, keyed by relation name; see `RelationConfig`.
67    #[serde(default, deserialize_with = "crate::keyed::deserialize")]
68    relations: Vec<RelationConfig>,
69    /// Where the menu entry sits, or `false` for none; see `nav::Placement`.
70    #[serde(default, deserialize_with = "crate::nav::deserialize_placement")]
71    menu: crate::nav::Placement,
72}
73
74#[derive(Debug, Deserialize)]
75#[serde(untagged)]
76enum ImportKey {
77    Flag(bool),
78    Config(ImportConfig),
79}
80
81fn default_id_field() -> String {
82    "id".to_string()
83}
84
85/// What went wrong reading a descriptor file.
86#[derive(Debug)]
87pub struct DescriptorError {
88    /// The file, as the caller named it.
89    pub source: String,
90    pub message: String,
91}
92
93impl std::fmt::Display for DescriptorError {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        write!(f, "{}: {}", self.source, self.message)
96    }
97}
98
99impl std::error::Error for DescriptorError {}
100
101/// Checks every entry's type keys against the built-in types, so a typo is
102/// named when the file is read. A type a module contributes is checked when
103/// the screen is prepared, where its registry is in hand.
104fn check_type_keys(list: &ListConfig, form: Option<&FormConfig>) -> Result<(), String> {
105    let columns = crate::list::builtin_column_registry();
106    for column in &list.columns {
107        let declared = columns
108            .get(&column.column_type)
109            .and_then(|column_type| column_type.option_keys());
110        if let Some(keys) = declared {
111            let keys: Vec<String> = keys.iter().map(|k| k.to_string()).collect();
112            crate::field::check_option_keys(&column.options, &keys).map_err(|e| {
113                format!("column `{}` (`{}`): {e}", column.field, column.column_type)
114            })?;
115        }
116    }
117    for filter in &list.filters {
118        let declared: Option<&[&str]> = match filter.filter_type.as_str() {
119            "select" => Some(&["options"]),
120            "boolean" | "text" | "number" | "date" => Some(&[]),
121            _ => None,
122        };
123        if let Some(keys) = declared {
124            let keys: Vec<String> = keys.iter().map(|k| k.to_string()).collect();
125            crate::field::check_option_keys(&filter.options, &keys).map_err(|e| {
126                format!("filter `{}` (`{}`): {e}", filter.field, filter.filter_type)
127            })?;
128        }
129    }
130    if let Some(form) = form {
131        let fields = crate::field::builtin_registry();
132        for field in &form.fields {
133            if let Some(field_type) = fields.get(&field.field_type) {
134                crate::field::check_field_keys(field, field_type.as_ref())
135                    .map_err(|e| format!("field `{}` (`{}`): {e}", field.name, field.field_type))?;
136            }
137        }
138    }
139    Ok(())
140}
141
142/// Reads a resource from the contents of a descriptor file.
143///
144/// `source` names the file in any error, so a message points at something the
145/// author can open.
146pub fn from_yaml(yaml: &str, source: &str) -> Result<Resource, DescriptorError> {
147    let file: ResourceFile = serde_saphyr::from_str(yaml).map_err(|e| DescriptorError {
148        source: source.to_string(),
149        message: e.to_string(),
150    })?;
151    check_type_keys(&file.list, file.form.as_ref()).map_err(|message| DescriptorError {
152        source: source.to_string(),
153        message,
154    })?;
155
156    let mut list = file.list;
157    list.entity = file.entity.clone();
158    list.id_field = file.id_field.clone();
159    if list.title.source().is_empty() {
160        list.title = file.title.clone();
161    }
162    // Rows link to the form when there is one, so a file need not repeat the
163    // path it already gave.
164    if file.form.is_some() && list.edit_base.is_none() {
165        list.edit_base = Some(file.path.clone());
166    }
167    for column in &mut list.columns {
168        if column.label.source().is_empty() {
169            column.label = humanize(&column.field);
170        }
171    }
172    for filter in &mut list.filters {
173        if filter.label.source().is_empty() {
174            filter.label = humanize(&filter.field);
175        }
176    }
177
178    let mut resource = Resource::new(file.path.clone(), file.title.clone(), list);
179    if let Some(permission) = file.permission {
180        resource = resource.permission(permission);
181    }
182    if let Some(mut form) = file.form {
183        form.entity = file.entity;
184        form.id_field = file.id_field;
185        form.base_path = file.path;
186        if form.title.source().is_empty() {
187            form.title = file.title;
188        }
189        for field in &mut form.fields {
190            if field.label.source().is_empty() {
191                field.label = humanize(&field.name);
192            }
193        }
194        resource = resource.form(form);
195    }
196    match file.import {
197        Some(ImportKey::Flag(true)) => resource = resource.import(ImportConfig::new()),
198        Some(ImportKey::Config(import)) => resource = resource.import(import),
199        Some(ImportKey::Flag(false)) | None => {}
200    }
201    resource = resource.menu(file.menu);
202    if let Some(source) = file.palette {
203        resource = resource.palette(source);
204    }
205    for mut relation in file.relations {
206        check_type_keys(&relation.list, Some(&relation.form)).map_err(|message| {
207            DescriptorError {
208                source: source.to_string(),
209                message: format!("relation `{}`: {message}", relation.name),
210            }
211        })?;
212        for column in &mut relation.list.columns {
213            if column.label.source().is_empty() {
214                column.label = humanize(&column.field);
215            }
216        }
217        for field in &mut relation.form.fields {
218            if field.label.source().is_empty() {
219                field.label = humanize(&field.name);
220            }
221        }
222        resource = resource.relation(relation);
223    }
224    Ok(resource)
225}
226
227/// Reads a descriptor file at compile time, relative to the crate's manifest.
228///
229/// ```rust,ignore
230/// registry.add_resource(laterite_admin::resource!("descriptors/posts.yaml"));
231/// ```
232///
233/// The file is embedded, so a built binary carries its screens and a missing or
234/// malformed one fails the build rather than the deployment. The path is
235/// manifest-relative, so it is the same path you would open in an editor, and
236/// it appears in any error.
237///
238/// A descriptor that must be read at run time is a different producer of the
239/// same [`Resource`]: call [`from_yaml`] with whatever you loaded.
240#[macro_export]
241macro_rules! resource {
242    ($path:literal) => {
243        $crate::descriptor::from_yaml(
244            ::std::include_str!(::std::concat!(
245                ::std::env!("CARGO_MANIFEST_DIR"),
246                "/",
247                $path
248            )),
249            $path,
250        )
251        .unwrap_or_else(|e| ::std::panic!("{e}"))
252    };
253}
254
255/// `created_at` reads as "Created at" when a file names no label. Still a
256/// `Text`, so it localizes and `lat i18n extract` finds it like any other.
257pub(crate) fn humanize(name: &str) -> Text {
258    let mut out = String::with_capacity(name.len());
259    for (i, part) in name.split(['_', '-']).enumerate() {
260        if part.is_empty() {
261            continue;
262        }
263        if i > 0 {
264            out.push(' ');
265        }
266        let mut chars = part.chars();
267        if let Some(first) = chars.next() {
268            if i == 0 {
269                out.extend(first.to_uppercase());
270            } else {
271                out.push(first);
272            }
273            out.push_str(chars.as_str());
274        }
275    }
276    Text::dynamic(out)
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    const POSTS: &str = r#"
284entity: posts
285path: /posts
286title: Posts
287permission: acme.manage_posts
288list:
289  creatable: true
290  order_by: created_at
291  order_dir: desc
292  columns:
293    title:      { searchable: true }
294    created_at: { type: datetime }
295form:
296  fields:
297    title: { rules: [required] }
298"#;
299
300    #[test]
301    fn a_file_describes_one_resource() {
302        let resource = from_yaml(POSTS, "posts.yaml").expect("reads");
303        assert_eq!(resource.base_path, "/posts");
304        assert_eq!(resource.permission.as_deref(), Some("acme.manage_posts"));
305        assert_eq!(resource.list.entity, "posts");
306        assert_eq!(resource.list.order_by, "created_at");
307        assert!(resource.list.creatable);
308    }
309
310    /// The entity, id column and path are written once and reach both screens,
311    /// so a file cannot say one thing to the list and another to the form.
312    #[test]
313    fn the_resource_level_keys_fill_both_screens() {
314        let resource = from_yaml(POSTS, "posts.yaml").expect("reads");
315        let form = resource.form.as_ref().expect("a form");
316        assert_eq!(form.entity, "posts");
317        assert_eq!(form.base_path, "/posts");
318        assert_eq!(form.id_field, "id");
319        assert_eq!(resource.list.id_field, "id");
320        assert_eq!(resource.list.edit_base.as_deref(), Some("/posts"));
321    }
322
323    #[test]
324    fn a_column_is_named_by_its_key_and_labelled_from_it() {
325        let resource = from_yaml(POSTS, "posts.yaml").expect("reads");
326        let names: Vec<&str> = resource
327            .list
328            .columns
329            .iter()
330            .map(|c| c.field.as_str())
331            .collect();
332        assert_eq!(names, ["title", "created_at"], "in the order written");
333        assert_eq!(resource.list.columns[1].label.source(), "Created at");
334        assert!(resource.list.columns[0].is_searchable());
335    }
336
337    #[test]
338    fn a_key_the_framework_does_not_know_is_refused() {
339        let yaml = POSTS.replace("searchable: true", "searchabel: true");
340        let err = from_yaml(&yaml, "posts.yaml")
341            .err()
342            .expect("a typo is an error");
343        assert_eq!(err.source, "posts.yaml");
344        assert!(
345            err.message.contains("searchabel"),
346            "and names it: {}",
347            err.message
348        );
349    }
350
351    #[test]
352    fn a_missing_required_key_is_refused() {
353        let yaml = POSTS.replace("entity: posts\n", "");
354        let err = from_yaml(&yaml, "posts.yaml")
355            .err()
356            .expect("entity is required");
357        assert!(err.message.contains("entity"), "{}", err.message);
358    }
359
360    /// Writing the descriptor back out gives the file it came from, so the
361    /// format is a faithful projection of the structs rather than a lossy one.
362    #[test]
363    fn a_descriptor_round_trips_through_the_file_format() {
364        let resource = from_yaml(POSTS, "posts.yaml").expect("reads");
365        let written = serde_saphyr::to_string(&resource.list).expect("writes");
366        let read_back: ListConfig = serde_saphyr::from_str(&written).expect("reads back");
367        assert_eq!(
368            read_back
369                .columns
370                .iter()
371                .map(|c| (c.field.clone(), c.column_type.clone()))
372                .collect::<Vec<_>>(),
373            resource
374                .list
375                .columns
376                .iter()
377                .map(|c| (c.field.clone(), c.column_type.clone()))
378                .collect::<Vec<_>>(),
379            "columns survive, named and in order"
380        );
381        assert_eq!(read_back.order_by, resource.list.order_by);
382        assert_eq!(read_back.creatable, resource.list.creatable);
383    }
384
385    #[test]
386    fn a_file_may_describe_a_list_with_no_form() {
387        let yaml = POSTS.split("form:").next().unwrap();
388        let resource = from_yaml(yaml, "posts.yaml").expect("reads");
389        assert!(resource.form.is_none());
390        assert!(resource.list.edit_base.is_none(), "and links no rows");
391    }
392}