laterite_admin/
descriptor.rs1use serde::Deserialize;
33
34use crate::form::FormConfig;
35use crate::list::ListConfig;
36use crate::Resource;
37use laterite_core::Text;
38
39#[derive(Debug, Deserialize)]
41#[serde(deny_unknown_fields)]
42struct ResourceFile {
43 entity: String,
45 path: String,
47 title: Text,
49 #[serde(default)]
51 permission: Option<String>,
52 #[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#[derive(Debug)]
66pub struct DescriptorError {
67 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
80fn check_type_keys(list: &ListConfig, form: Option<&FormConfig>) -> Result<(), String> {
84 let columns = crate::list::builtin_column_registry();
85 for column in &list.columns {
86 let declared = columns
87 .get(&column.column_type)
88 .and_then(|column_type| column_type.option_keys());
89 if let Some(keys) = declared {
90 let keys: Vec<String> = keys.iter().map(|k| k.to_string()).collect();
91 crate::field::check_option_keys(&column.options, &keys).map_err(|e| {
92 format!("column `{}` (`{}`): {e}", column.field, column.column_type)
93 })?;
94 }
95 }
96 for filter in &list.filters {
97 let declared: Option<&[&str]> = match filter.filter_type.as_str() {
98 "select" => Some(&["options"]),
99 "boolean" | "text" | "number" | "date" => Some(&[]),
100 _ => None,
101 };
102 if let Some(keys) = declared {
103 let keys: Vec<String> = keys.iter().map(|k| k.to_string()).collect();
104 crate::field::check_option_keys(&filter.options, &keys).map_err(|e| {
105 format!("filter `{}` (`{}`): {e}", filter.field, filter.filter_type)
106 })?;
107 }
108 }
109 if let Some(form) = form {
110 let fields = crate::field::builtin_registry();
111 for field in &form.fields {
112 if let Some(field_type) = fields.get(&field.field_type) {
113 crate::field::check_field_keys(field, field_type.as_ref())
114 .map_err(|e| format!("field `{}` (`{}`): {e}", field.name, field.field_type))?;
115 }
116 }
117 }
118 Ok(())
119}
120
121pub fn from_yaml(yaml: &str, source: &str) -> Result<Resource, DescriptorError> {
126 let file: ResourceFile = serde_saphyr::from_str(yaml).map_err(|e| DescriptorError {
127 source: source.to_string(),
128 message: e.to_string(),
129 })?;
130 check_type_keys(&file.list, file.form.as_ref()).map_err(|message| DescriptorError {
131 source: source.to_string(),
132 message,
133 })?;
134
135 let mut list = file.list;
136 list.entity = file.entity.clone();
137 list.id_field = file.id_field.clone();
138 if list.title.source().is_empty() {
139 list.title = file.title.clone();
140 }
141 if file.form.is_some() && list.edit_base.is_none() {
144 list.edit_base = Some(file.path.clone());
145 }
146 for column in &mut list.columns {
147 if column.label.source().is_empty() {
148 column.label = humanize(&column.field);
149 }
150 }
151 for filter in &mut list.filters {
152 if filter.label.source().is_empty() {
153 filter.label = humanize(&filter.field);
154 }
155 }
156
157 let mut resource = Resource::new(file.path.clone(), file.title.clone(), list);
158 if let Some(permission) = file.permission {
159 resource = resource.permission(permission);
160 }
161 if let Some(mut form) = file.form {
162 form.entity = file.entity;
163 form.id_field = file.id_field;
164 form.base_path = file.path;
165 if form.title.source().is_empty() {
166 form.title = file.title;
167 }
168 for field in &mut form.fields {
169 if field.label.source().is_empty() {
170 field.label = humanize(&field.name);
171 }
172 }
173 resource = resource.form(form);
174 }
175 Ok(resource)
176}
177
178#[macro_export]
192macro_rules! resource {
193 ($path:literal) => {
194 $crate::descriptor::from_yaml(
195 ::std::include_str!(::std::concat!(
196 ::std::env!("CARGO_MANIFEST_DIR"),
197 "/",
198 $path
199 )),
200 $path,
201 )
202 .unwrap_or_else(|e| ::std::panic!("{e}"))
203 };
204}
205
206fn humanize(name: &str) -> Text {
209 let mut out = String::with_capacity(name.len());
210 for (i, part) in name.split(['_', '-']).enumerate() {
211 if part.is_empty() {
212 continue;
213 }
214 if i > 0 {
215 out.push(' ');
216 }
217 let mut chars = part.chars();
218 if let Some(first) = chars.next() {
219 if i == 0 {
220 out.extend(first.to_uppercase());
221 } else {
222 out.push(first);
223 }
224 out.push_str(chars.as_str());
225 }
226 }
227 Text::dynamic(out)
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 const POSTS: &str = r#"
235entity: posts
236path: /posts
237title: Posts
238permission: acme.manage_posts
239list:
240 creatable: true
241 order_by: created_at
242 order_dir: desc
243 columns:
244 title: { searchable: true }
245 created_at: { type: datetime }
246form:
247 fields:
248 title: { rules: [required] }
249"#;
250
251 #[test]
252 fn a_file_describes_one_resource() {
253 let resource = from_yaml(POSTS, "posts.yaml").expect("reads");
254 assert_eq!(resource.base_path, "/posts");
255 assert_eq!(resource.permission.as_deref(), Some("acme.manage_posts"));
256 assert_eq!(resource.list.entity, "posts");
257 assert_eq!(resource.list.order_by, "created_at");
258 assert!(resource.list.creatable);
259 }
260
261 #[test]
264 fn the_resource_level_keys_fill_both_screens() {
265 let resource = from_yaml(POSTS, "posts.yaml").expect("reads");
266 let form = resource.form.as_ref().expect("a form");
267 assert_eq!(form.entity, "posts");
268 assert_eq!(form.base_path, "/posts");
269 assert_eq!(form.id_field, "id");
270 assert_eq!(resource.list.id_field, "id");
271 assert_eq!(resource.list.edit_base.as_deref(), Some("/posts"));
272 }
273
274 #[test]
275 fn a_column_is_named_by_its_key_and_labelled_from_it() {
276 let resource = from_yaml(POSTS, "posts.yaml").expect("reads");
277 let names: Vec<&str> = resource
278 .list
279 .columns
280 .iter()
281 .map(|c| c.field.as_str())
282 .collect();
283 assert_eq!(names, ["title", "created_at"], "in the order written");
284 assert_eq!(resource.list.columns[1].label.source(), "Created at");
285 assert!(resource.list.columns[0].is_searchable());
286 }
287
288 #[test]
289 fn a_key_the_framework_does_not_know_is_refused() {
290 let yaml = POSTS.replace("searchable: true", "searchabel: true");
291 let err = from_yaml(&yaml, "posts.yaml")
292 .err()
293 .expect("a typo is an error");
294 assert_eq!(err.source, "posts.yaml");
295 assert!(
296 err.message.contains("searchabel"),
297 "and names it: {}",
298 err.message
299 );
300 }
301
302 #[test]
303 fn a_missing_required_key_is_refused() {
304 let yaml = POSTS.replace("entity: posts\n", "");
305 let err = from_yaml(&yaml, "posts.yaml")
306 .err()
307 .expect("entity is required");
308 assert!(err.message.contains("entity"), "{}", err.message);
309 }
310
311 #[test]
314 fn a_descriptor_round_trips_through_the_file_format() {
315 let resource = from_yaml(POSTS, "posts.yaml").expect("reads");
316 let written = serde_saphyr::to_string(&resource.list).expect("writes");
317 let read_back: ListConfig = serde_saphyr::from_str(&written).expect("reads back");
318 assert_eq!(
319 read_back
320 .columns
321 .iter()
322 .map(|c| (c.field.clone(), c.column_type.clone()))
323 .collect::<Vec<_>>(),
324 resource
325 .list
326 .columns
327 .iter()
328 .map(|c| (c.field.clone(), c.column_type.clone()))
329 .collect::<Vec<_>>(),
330 "columns survive, named and in order"
331 );
332 assert_eq!(read_back.order_by, resource.list.order_by);
333 assert_eq!(read_back.creatable, resource.list.creatable);
334 }
335
336 #[test]
337 fn a_file_may_describe_a_list_with_no_form() {
338 let yaml = POSTS.split("form:").next().unwrap();
339 let resource = from_yaml(yaml, "posts.yaml").expect("reads");
340 assert!(resource.form.is_none());
341 assert!(resource.list.edit_base.is_none(), "and links no rows");
342 }
343}