1use 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#[derive(Debug, Deserialize)]
42#[serde(deny_unknown_fields)]
43struct ResourceFile {
44 entity: String,
46 path: String,
48 title: Text,
50 #[serde(default)]
52 permission: Option<String>,
53 #[serde(default = "default_id_field")]
55 id_field: String,
56 list: ListConfig,
57 #[serde(default)]
58 form: Option<FormConfig>,
59 #[serde(default)]
62 import: Option<ImportKey>,
63 #[serde(default)]
65 palette: Option<String>,
66 #[serde(default, deserialize_with = "crate::keyed::deserialize")]
68 relations: Vec<RelationConfig>,
69 #[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#[derive(Debug)]
87pub struct DescriptorError {
88 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
101fn 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
142pub 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 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#[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
255pub(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 #[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 #[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}