laterite_admin/
descriptor.rs1use serde::Deserialize;
33
34use crate::form::FormConfig;
35use crate::list::ListConfig;
36use crate::{ImportConfig, 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 #[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#[derive(Debug)]
77pub struct DescriptorError {
78 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
91fn 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
132pub 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 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#[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
222fn 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 #[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 #[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}