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