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
80pub 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 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#[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
161fn 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 #[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 #[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}