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, deserialize_with = "crate::keyed::deserialize")]
65 relations: Vec<RelationConfig>,
66}
67
68#[derive(Debug, Deserialize)]
69#[serde(untagged)]
70enum ImportKey {
71 Flag(bool),
72 Config(ImportConfig),
73}
74
75fn default_id_field() -> String {
76 "id".to_string()
77}
78
79#[derive(Debug)]
81pub struct DescriptorError {
82 pub source: String,
84 pub message: String,
85}
86
87impl std::fmt::Display for DescriptorError {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 write!(f, "{}: {}", self.source, self.message)
90 }
91}
92
93impl std::error::Error for DescriptorError {}
94
95fn check_type_keys(list: &ListConfig, form: Option<&FormConfig>) -> Result<(), String> {
99 let columns = crate::list::builtin_column_registry();
100 for column in &list.columns {
101 let declared = columns
102 .get(&column.column_type)
103 .and_then(|column_type| column_type.option_keys());
104 if let Some(keys) = declared {
105 let keys: Vec<String> = keys.iter().map(|k| k.to_string()).collect();
106 crate::field::check_option_keys(&column.options, &keys).map_err(|e| {
107 format!("column `{}` (`{}`): {e}", column.field, column.column_type)
108 })?;
109 }
110 }
111 for filter in &list.filters {
112 let declared: Option<&[&str]> = match filter.filter_type.as_str() {
113 "select" => Some(&["options"]),
114 "boolean" | "text" | "number" | "date" => Some(&[]),
115 _ => None,
116 };
117 if let Some(keys) = declared {
118 let keys: Vec<String> = keys.iter().map(|k| k.to_string()).collect();
119 crate::field::check_option_keys(&filter.options, &keys).map_err(|e| {
120 format!("filter `{}` (`{}`): {e}", filter.field, filter.filter_type)
121 })?;
122 }
123 }
124 if let Some(form) = form {
125 let fields = crate::field::builtin_registry();
126 for field in &form.fields {
127 if let Some(field_type) = fields.get(&field.field_type) {
128 crate::field::check_field_keys(field, field_type.as_ref())
129 .map_err(|e| format!("field `{}` (`{}`): {e}", field.name, field.field_type))?;
130 }
131 }
132 }
133 Ok(())
134}
135
136pub fn from_yaml(yaml: &str, source: &str) -> Result<Resource, DescriptorError> {
141 let file: ResourceFile = serde_saphyr::from_str(yaml).map_err(|e| DescriptorError {
142 source: source.to_string(),
143 message: e.to_string(),
144 })?;
145 check_type_keys(&file.list, file.form.as_ref()).map_err(|message| DescriptorError {
146 source: source.to_string(),
147 message,
148 })?;
149
150 let mut list = file.list;
151 list.entity = file.entity.clone();
152 list.id_field = file.id_field.clone();
153 if list.title.source().is_empty() {
154 list.title = file.title.clone();
155 }
156 if file.form.is_some() && list.edit_base.is_none() {
159 list.edit_base = Some(file.path.clone());
160 }
161 for column in &mut list.columns {
162 if column.label.source().is_empty() {
163 column.label = humanize(&column.field);
164 }
165 }
166 for filter in &mut list.filters {
167 if filter.label.source().is_empty() {
168 filter.label = humanize(&filter.field);
169 }
170 }
171
172 let mut resource = Resource::new(file.path.clone(), file.title.clone(), list);
173 if let Some(permission) = file.permission {
174 resource = resource.permission(permission);
175 }
176 if let Some(mut form) = file.form {
177 form.entity = file.entity;
178 form.id_field = file.id_field;
179 form.base_path = file.path;
180 if form.title.source().is_empty() {
181 form.title = file.title;
182 }
183 for field in &mut form.fields {
184 if field.label.source().is_empty() {
185 field.label = humanize(&field.name);
186 }
187 }
188 resource = resource.form(form);
189 }
190 match file.import {
191 Some(ImportKey::Flag(true)) => resource = resource.import(ImportConfig::new()),
192 Some(ImportKey::Config(import)) => resource = resource.import(import),
193 Some(ImportKey::Flag(false)) | None => {}
194 }
195 for mut relation in file.relations {
196 check_type_keys(&relation.list, Some(&relation.form)).map_err(|message| {
197 DescriptorError {
198 source: source.to_string(),
199 message: format!("relation `{}`: {message}", relation.name),
200 }
201 })?;
202 for column in &mut relation.list.columns {
203 if column.label.source().is_empty() {
204 column.label = humanize(&column.field);
205 }
206 }
207 for field in &mut relation.form.fields {
208 if field.label.source().is_empty() {
209 field.label = humanize(&field.name);
210 }
211 }
212 resource = resource.relation(relation);
213 }
214 Ok(resource)
215}
216
217#[macro_export]
231macro_rules! resource {
232 ($path:literal) => {
233 $crate::descriptor::from_yaml(
234 ::std::include_str!(::std::concat!(
235 ::std::env!("CARGO_MANIFEST_DIR"),
236 "/",
237 $path
238 )),
239 $path,
240 )
241 .unwrap_or_else(|e| ::std::panic!("{e}"))
242 };
243}
244
245pub(crate) fn humanize(name: &str) -> Text {
248 let mut out = String::with_capacity(name.len());
249 for (i, part) in name.split(['_', '-']).enumerate() {
250 if part.is_empty() {
251 continue;
252 }
253 if i > 0 {
254 out.push(' ');
255 }
256 let mut chars = part.chars();
257 if let Some(first) = chars.next() {
258 if i == 0 {
259 out.extend(first.to_uppercase());
260 } else {
261 out.push(first);
262 }
263 out.push_str(chars.as_str());
264 }
265 }
266 Text::dynamic(out)
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 const POSTS: &str = r#"
274entity: posts
275path: /posts
276title: Posts
277permission: acme.manage_posts
278list:
279 creatable: true
280 order_by: created_at
281 order_dir: desc
282 columns:
283 title: { searchable: true }
284 created_at: { type: datetime }
285form:
286 fields:
287 title: { rules: [required] }
288"#;
289
290 #[test]
291 fn a_file_describes_one_resource() {
292 let resource = from_yaml(POSTS, "posts.yaml").expect("reads");
293 assert_eq!(resource.base_path, "/posts");
294 assert_eq!(resource.permission.as_deref(), Some("acme.manage_posts"));
295 assert_eq!(resource.list.entity, "posts");
296 assert_eq!(resource.list.order_by, "created_at");
297 assert!(resource.list.creatable);
298 }
299
300 #[test]
303 fn the_resource_level_keys_fill_both_screens() {
304 let resource = from_yaml(POSTS, "posts.yaml").expect("reads");
305 let form = resource.form.as_ref().expect("a form");
306 assert_eq!(form.entity, "posts");
307 assert_eq!(form.base_path, "/posts");
308 assert_eq!(form.id_field, "id");
309 assert_eq!(resource.list.id_field, "id");
310 assert_eq!(resource.list.edit_base.as_deref(), Some("/posts"));
311 }
312
313 #[test]
314 fn a_column_is_named_by_its_key_and_labelled_from_it() {
315 let resource = from_yaml(POSTS, "posts.yaml").expect("reads");
316 let names: Vec<&str> = resource
317 .list
318 .columns
319 .iter()
320 .map(|c| c.field.as_str())
321 .collect();
322 assert_eq!(names, ["title", "created_at"], "in the order written");
323 assert_eq!(resource.list.columns[1].label.source(), "Created at");
324 assert!(resource.list.columns[0].is_searchable());
325 }
326
327 #[test]
328 fn a_key_the_framework_does_not_know_is_refused() {
329 let yaml = POSTS.replace("searchable: true", "searchabel: true");
330 let err = from_yaml(&yaml, "posts.yaml")
331 .err()
332 .expect("a typo is an error");
333 assert_eq!(err.source, "posts.yaml");
334 assert!(
335 err.message.contains("searchabel"),
336 "and names it: {}",
337 err.message
338 );
339 }
340
341 #[test]
342 fn a_missing_required_key_is_refused() {
343 let yaml = POSTS.replace("entity: posts\n", "");
344 let err = from_yaml(&yaml, "posts.yaml")
345 .err()
346 .expect("entity is required");
347 assert!(err.message.contains("entity"), "{}", err.message);
348 }
349
350 #[test]
353 fn a_descriptor_round_trips_through_the_file_format() {
354 let resource = from_yaml(POSTS, "posts.yaml").expect("reads");
355 let written = serde_saphyr::to_string(&resource.list).expect("writes");
356 let read_back: ListConfig = serde_saphyr::from_str(&written).expect("reads back");
357 assert_eq!(
358 read_back
359 .columns
360 .iter()
361 .map(|c| (c.field.clone(), c.column_type.clone()))
362 .collect::<Vec<_>>(),
363 resource
364 .list
365 .columns
366 .iter()
367 .map(|c| (c.field.clone(), c.column_type.clone()))
368 .collect::<Vec<_>>(),
369 "columns survive, named and in order"
370 );
371 assert_eq!(read_back.order_by, resource.list.order_by);
372 assert_eq!(read_back.creatable, resource.list.creatable);
373 }
374
375 #[test]
376 fn a_file_may_describe_a_list_with_no_form() {
377 let yaml = POSTS.split("form:").next().unwrap();
378 let resource = from_yaml(yaml, "posts.yaml").expect("reads");
379 assert!(resource.form.is_none());
380 assert!(resource.list.edit_base.is_none(), "and links no rows");
381 }
382}