1use serde::Deserialize;
9use std::collections::BTreeMap;
10use std::path::Path;
11
12#[derive(Debug, Clone, Deserialize)]
14pub struct Resource {
15 #[serde(rename = "resource")]
16 pub meta: ResourceMeta,
17 #[serde(default)]
19 pub fields: BTreeMap<String, Field>,
20 #[serde(default)]
21 pub permissions: Permissions,
22 #[serde(default)]
24 pub hooks: Hooks,
25 #[serde(default)]
27 pub auth: Option<AuthSpec>,
28 #[serde(default)]
30 pub admin: ResourceAdmin,
31}
32
33#[derive(Debug, Clone, Deserialize)]
34pub struct ResourceMeta {
35 pub name: String,
36 pub table: Option<String>,
38 #[serde(default = "yes")]
40 pub timestamps: bool,
41 #[serde(default = "default_owner_field")]
43 pub owner_field: String,
44 #[serde(default = "default_scope")]
47 pub scope: Scope,
48}
49
50fn yes() -> bool {
51 true
52}
53fn default_owner_field() -> String {
54 "owner_id".to_string()
55}
56fn default_scope() -> Scope {
57 Scope::Organization
58}
59
60impl Resource {
61 pub fn table_name(&self) -> String {
63 self.meta
64 .table
65 .clone()
66 .unwrap_or_else(|| format!("apiplant_{}", self.meta.name))
67 }
68
69 pub fn hook(&self, event: HookEvent) -> Option<&str> {
71 self.hooks.get(event)
72 }
73
74 pub fn auth_hook(&self, event: AuthEvent) -> Option<&str> {
77 self.hooks.get_auth(event)
78 }
79
80 pub fn validate(&self) -> crate::Result<()> {
82 for (event, function) in self.hooks.iter() {
83 if function.trim().is_empty() {
84 return Err(crate::Error::Schema {
85 resource: self.meta.name.clone(),
86 message: format!("hook `{}` names an empty function", event.as_str()),
87 });
88 }
89 }
90 for (event, function) in self.hooks.auth_iter() {
91 if function.trim().is_empty() {
92 return Err(crate::Error::Schema {
93 resource: self.meta.name.clone(),
94 message: format!("hook `{}` names an empty function", event.as_str()),
95 });
96 }
97 if self.meta.name != "user" {
100 return Err(crate::Error::Schema {
101 resource: self.meta.name.clone(),
102 message: format!(
103 "hook `{}` only exists on the `user` resource, which owns the auth endpoints",
104 event.as_str()
105 ),
106 });
107 }
108 }
109 for (fname, field) in &self.fields {
110 if fname == "id" {
111 return Err(crate::Error::Schema {
112 resource: self.meta.name.clone(),
113 message: "`id` is reserved and added automatically".into(),
114 });
115 }
116 if field.ty == FieldType::Reference && field.references.is_none() {
117 return Err(crate::Error::Schema {
118 resource: self.meta.name.clone(),
119 message: format!("field `{fname}` is a reference without `references`"),
120 });
121 }
122 if field.admin.format != ContentFormat::Plain
123 && !matches!(field.ty, FieldType::Text | FieldType::String)
124 {
125 return Err(crate::Error::Schema {
126 resource: self.meta.name.clone(),
127 message: format!(
128 "field `{fname}` sets [admin] format = \"{}\" but is not a text field",
129 field.admin.format.as_str()
130 ),
131 });
132 }
133 }
134 for column in &self.admin.columns {
138 if !self.fields.contains_key(column) && column != "id" {
139 return Err(crate::Error::Schema {
140 resource: self.meta.name.clone(),
141 message: format!("[admin] columns names unknown field `{column}`"),
142 });
143 }
144 }
145 for (key, declared) in [
146 ("display_field", &self.admin.display_field),
147 ("search_field", &self.admin.search_field),
148 ] {
149 if let Some(field) = declared {
150 let Some(declared_field) = self.fields.get(field) else {
151 return Err(crate::Error::Schema {
152 resource: self.meta.name.clone(),
153 message: format!("[admin] {key} names unknown field `{field}`"),
154 });
155 };
156 if key == "search_field"
160 && !matches!(declared_field.ty, FieldType::String | FieldType::Text)
161 {
162 return Err(crate::Error::Schema {
163 resource: self.meta.name.clone(),
164 message: format!(
165 "[admin] search_field names `{field}`, which is not a text field and cannot be searched"
166 ),
167 });
168 }
169 }
170 }
171 Ok(())
172 }
173
174 pub fn admin_label(&self) -> String {
176 self.admin
177 .label
178 .clone()
179 .unwrap_or_else(|| titleize(&self.meta.name))
180 }
181
182 pub fn admin_plural(&self) -> String {
184 self.admin
185 .plural
186 .clone()
187 .unwrap_or_else(|| pluralize(&self.admin_label()))
188 }
189
190 pub fn admin_display_field(&self) -> Option<String> {
196 if let Some(declared) = &self.admin.display_field {
197 if self.fields.contains_key(declared) {
198 return Some(declared.clone());
199 }
200 }
201 const PREFERRED: [&str; 7] = ["name", "title", "label", "slug", "code", "number", "email"];
202 for candidate in PREFERRED {
203 if let Some(field) = self.fields.get(candidate) {
204 if !field.hidden && matches!(field.ty, FieldType::String | FieldType::Text) {
205 return Some(candidate.to_string());
206 }
207 }
208 }
209 self.fields
210 .iter()
211 .find(|(_, field)| !field.hidden && field.ty == FieldType::String)
212 .map(|(name, _)| name.clone())
213 }
214
215 pub fn admin_search_field(&self) -> Option<String> {
221 let candidate = match &self.admin.search_field {
222 Some(declared) if self.fields.contains_key(declared) => Some(declared.clone()),
223 Some(_) | None => self.admin_display_field(),
224 };
225 candidate.filter(|name| {
226 self.fields
227 .get(name)
228 .is_some_and(|field| matches!(field.ty, FieldType::String | FieldType::Text))
229 })
230 }
231
232 pub fn admin_columns(&self) -> Vec<String> {
238 let declared: Vec<String> = self
239 .admin
240 .columns
241 .iter()
242 .filter(|name| self.fields.contains_key(*name))
243 .cloned()
244 .collect();
245 if !declared.is_empty() {
246 return declared;
247 }
248
249 let display = self.admin_display_field();
250 let mut columns: Vec<String> = display.iter().cloned().collect();
251 for (name, field) in &self.fields {
252 if columns.len() >= 5 {
253 break;
254 }
255 let skip = field.hidden
256 || !field.admin.visible
257 || Some(name) == display.as_ref()
258 || name == "organization_id"
259 || matches!(field.ty, FieldType::Json | FieldType::Text);
260 if !skip {
261 columns.push(name.clone());
262 }
263 }
264 columns
265 }
266
267 pub fn references(&self) -> Vec<Reference> {
269 self.fields
270 .iter()
271 .filter_map(|(name, field)| {
272 if field.ty != FieldType::Reference {
273 return None;
274 }
275 let target = field.references.clone()?;
276 Some(Reference {
277 field: name.clone(),
278 target,
279 relation: relation_name(name).to_string(),
280 on_delete: field.on_delete.unwrap_or(OnDelete::Restrict),
281 required: field.required,
282 })
283 })
284 .collect()
285 }
286
287 pub fn reference_by_relation(&self, relation: &str) -> Option<Reference> {
289 self.references()
290 .into_iter()
291 .find(|r| r.relation == relation)
292 }
293
294 pub fn is_org_scoped(&self) -> bool {
296 self.meta.scope == Scope::Organization
297 }
298
299 pub fn org_column(&self) -> Option<&'static str> {
303 if self.is_org_scoped() {
304 Some("organization_id")
305 } else if self.meta.name == "organization" {
306 Some("id")
307 } else {
308 None
309 }
310 }
311
312 pub fn load(path: &Path) -> crate::Result<Self> {
314 let text = std::fs::read_to_string(path).map_err(|e| crate::Error::Io {
315 path: path.to_path_buf(),
316 source: e,
317 })?;
318 let source = path.file_name().unwrap_or_default().to_string_lossy();
321 let resource: Resource =
322 crate::env::parse_toml(&text, &source).map_err(|e| crate::Error::Toml {
323 path: path.to_path_buf(),
324 source: e,
325 })?;
326 resource.validate()?;
327 Ok(resource)
328 }
329}
330
331#[derive(Debug, Clone, Default, Deserialize)]
339#[serde(default, deny_unknown_fields)]
340pub struct ResourceAdmin {
341 pub visible: Option<bool>,
344 pub roles: Vec<String>,
347 pub label: Option<String>,
349 pub plural: Option<String>,
351 pub group: Option<String>,
353 pub display_field: Option<String>,
356 pub columns: Vec<String>,
358 pub search_field: Option<String>,
360 pub order: i64,
362}
363
364impl ResourceAdmin {
365 pub fn is_visible(&self, resource_name: &str) -> bool {
373 self.visible.unwrap_or(!is_auth_resource(resource_name))
374 }
375}
376
377pub fn is_auth_resource(name: &str) -> bool {
380 matches!(
381 name,
382 "user" | "organization" | "membership" | "membership_role" | "api_key" | "oauth_connection"
383 )
384}
385
386#[derive(Debug, Clone, Deserialize)]
388#[serde(default, deny_unknown_fields)]
389pub struct FieldAdmin {
390 pub visible: bool,
393 pub readonly: bool,
395 pub label: Option<String>,
397 pub help: Option<String>,
399 pub widget: Widget,
401 pub options: Vec<String>,
404 pub placeholder: Option<String>,
406 pub format: ContentFormat,
410}
411
412impl Default for FieldAdmin {
413 fn default() -> Self {
414 FieldAdmin {
415 visible: true,
416 readonly: false,
417 label: None,
418 help: None,
419 widget: Widget::Auto,
420 options: Vec::new(),
421 placeholder: None,
422 format: ContentFormat::Plain,
423 }
424 }
425}
426
427#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
432#[serde(rename_all = "snake_case")]
433pub enum ContentFormat {
434 #[default]
436 Plain,
437 Markdown,
438 Html,
439}
440
441impl ContentFormat {
442 pub fn as_str(self) -> &'static str {
443 match self {
444 ContentFormat::Plain => "plain",
445 ContentFormat::Markdown => "markdown",
446 ContentFormat::Html => "html",
447 }
448 }
449}
450
451#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
453#[serde(rename_all = "snake_case")]
454pub enum Widget {
455 Auto,
457 Text,
458 Textarea,
459 Select,
460 Email,
461 Url,
462 Password,
463 Color,
464 Date,
465 DateTime,
466 Json,
467 Switch,
468}
469
470impl Widget {
471 pub fn as_str(self) -> &'static str {
472 match self {
473 Widget::Auto => "auto",
474 Widget::Text => "text",
475 Widget::Textarea => "textarea",
476 Widget::Select => "select",
477 Widget::Email => "email",
478 Widget::Url => "url",
479 Widget::Password => "password",
480 Widget::Color => "color",
481 Widget::Date => "date",
482 Widget::DateTime => "date_time",
483 Widget::Json => "json",
484 Widget::Switch => "switch",
485 }
486 }
487}
488
489#[derive(Debug, Clone, Deserialize)]
491pub struct Field {
492 #[serde(rename = "type")]
493 pub ty: FieldType,
494 #[serde(default)]
496 pub references: Option<String>,
497 #[serde(default)]
498 pub required: bool,
499 #[serde(default)]
500 pub unique: bool,
501 #[serde(default)]
503 pub hidden: bool,
504 #[serde(default)]
506 pub default: Option<serde_json::Value>,
507 pub max_length: Option<u32>,
508 #[serde(default)]
511 pub on_delete: Option<OnDelete>,
512 #[serde(default)]
514 pub admin: FieldAdmin,
515}
516
517#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
519#[serde(rename_all = "snake_case")]
520pub enum FieldType {
521 String,
522 Text,
523 Integer,
524 BigInt,
525 Float,
526 Boolean,
527 Uuid,
528 Timestamp,
529 Json,
530 Reference,
532}
533
534#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
536#[serde(rename_all = "snake_case")]
537pub enum OnDelete {
538 Restrict,
540 SetNull,
542 Cascade,
544 NoAction,
546}
547
548impl OnDelete {
549 pub fn to_sql(self) -> &'static str {
550 match self {
551 OnDelete::Restrict => "RESTRICT",
552 OnDelete::SetNull => "SET NULL",
553 OnDelete::Cascade => "CASCADE",
554 OnDelete::NoAction => "NO ACTION",
555 }
556 }
557}
558
559#[derive(Debug, Clone)]
562pub struct Reference {
563 pub field: String,
564 pub target: String,
565 pub relation: String,
566 pub on_delete: OnDelete,
567 pub required: bool,
568}
569
570pub fn relation_name(field: &str) -> &str {
573 field.strip_suffix("_id").unwrap_or(field)
574}
575
576pub fn titleize(name: &str) -> String {
579 let spaced = name.trim_end_matches("_id").replace('_', " ");
580 let mut chars = spaced.chars();
581 match chars.next() {
582 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
583 None => spaced,
584 }
585}
586
587pub fn pluralize(label: &str) -> String {
590 let lower = label.to_lowercase();
591 if lower.ends_with('s')
592 || lower.ends_with("x")
593 || lower.ends_with("ch")
594 || lower.ends_with("sh")
595 {
596 format!("{label}es")
597 } else if lower.ends_with('y')
598 && !lower.ends_with("ay")
599 && !lower.ends_with("ey")
600 && !lower.ends_with("oy")
601 && !lower.ends_with("uy")
602 {
603 format!("{}ies", &label[..label.len() - 1])
604 } else {
605 format!("{label}s")
606 }
607}
608
609#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
612#[serde(rename_all = "snake_case")]
613pub enum Scope {
614 Organization,
617 Global,
619}
620
621#[derive(Debug, Clone, PartialEq, Eq)]
628pub enum Access {
629 Public,
632 Authenticated,
634 Member,
636 Role(String),
638 Owner,
640 Private,
642}
643
644impl Access {
645 pub fn parse(s: &str) -> Access {
647 match s {
648 "public" => Access::Public,
649 "authenticated" => Access::Authenticated,
650 "member" => Access::Member,
651 "owner" => Access::Owner,
652 "private" => Access::Private,
653 other => other
654 .strip_prefix("role:")
655 .map(|role| Access::Role(role.to_string()))
656 .unwrap_or(Access::Private),
657 }
658 }
659}
660
661#[derive(Debug, Clone, Deserialize)]
663#[serde(from = "PermissionsRaw")]
664pub struct Permissions {
665 pub list: Access,
666 pub read: Access,
667 pub create: Access,
668 pub update: Access,
669 pub delete: Access,
670}
671
672impl Default for Permissions {
673 fn default() -> Self {
674 Permissions {
678 list: Access::Member,
679 read: Access::Member,
680 create: Access::Member,
681 update: Access::Member,
682 delete: Access::Member,
683 }
684 }
685}
686
687#[derive(Deserialize)]
688struct PermissionsRaw {
689 list: Option<String>,
690 read: Option<String>,
691 create: Option<String>,
692 update: Option<String>,
693 delete: Option<String>,
694}
695
696impl From<PermissionsRaw> for Permissions {
697 fn from(r: PermissionsRaw) -> Self {
698 let d = Permissions::default();
699 Permissions {
700 list: r.list.map(|s| Access::parse(&s)).unwrap_or(d.list),
701 read: r.read.map(|s| Access::parse(&s)).unwrap_or(d.read),
702 create: r.create.map(|s| Access::parse(&s)).unwrap_or(d.create),
703 update: r.update.map(|s| Access::parse(&s)).unwrap_or(d.update),
704 delete: r.delete.map(|s| Access::parse(&s)).unwrap_or(d.delete),
705 }
706 }
707}
708
709#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
716pub enum HookEvent {
717 BeforeList,
718 AfterList,
719 BeforeRead,
720 AfterRead,
721 BeforeCreate,
722 AfterCreate,
723 BeforeUpdate,
724 AfterUpdate,
725 BeforeDelete,
726 AfterDelete,
727}
728
729impl HookEvent {
730 pub const ALL: [HookEvent; 10] = [
732 HookEvent::BeforeList,
733 HookEvent::AfterList,
734 HookEvent::BeforeRead,
735 HookEvent::AfterRead,
736 HookEvent::BeforeCreate,
737 HookEvent::AfterCreate,
738 HookEvent::BeforeUpdate,
739 HookEvent::AfterUpdate,
740 HookEvent::BeforeDelete,
741 HookEvent::AfterDelete,
742 ];
743
744 pub fn as_str(self) -> &'static str {
746 match self {
747 HookEvent::BeforeList => "before_list",
748 HookEvent::AfterList => "after_list",
749 HookEvent::BeforeRead => "before_read",
750 HookEvent::AfterRead => "after_read",
751 HookEvent::BeforeCreate => "before_create",
752 HookEvent::AfterCreate => "after_create",
753 HookEvent::BeforeUpdate => "before_update",
754 HookEvent::AfterUpdate => "after_update",
755 HookEvent::BeforeDelete => "before_delete",
756 HookEvent::AfterDelete => "after_delete",
757 }
758 }
759
760 pub fn action(self) -> &'static str {
762 match self {
763 HookEvent::BeforeList | HookEvent::AfterList => "list",
764 HookEvent::BeforeRead | HookEvent::AfterRead => "read",
765 HookEvent::BeforeCreate | HookEvent::AfterCreate => "create",
766 HookEvent::BeforeUpdate | HookEvent::AfterUpdate => "update",
767 HookEvent::BeforeDelete | HookEvent::AfterDelete => "delete",
768 }
769 }
770
771 pub fn phase(self) -> &'static str {
773 if self.is_before() {
774 "before"
775 } else {
776 "after"
777 }
778 }
779
780 pub fn is_before(self) -> bool {
782 matches!(
783 self,
784 HookEvent::BeforeList
785 | HookEvent::BeforeRead
786 | HookEvent::BeforeCreate
787 | HookEvent::BeforeUpdate
788 | HookEvent::BeforeDelete
789 )
790 }
791}
792
793#[derive(Debug, Clone, Default, Deserialize)]
802#[serde(default, deny_unknown_fields)]
803pub struct Hooks {
804 pub before_list: Option<String>,
805 pub after_list: Option<String>,
806 pub before_read: Option<String>,
807 pub after_read: Option<String>,
808 pub before_create: Option<String>,
809 pub after_create: Option<String>,
810 pub before_update: Option<String>,
811 pub after_update: Option<String>,
812 pub before_delete: Option<String>,
813 pub after_delete: Option<String>,
814 pub before_register: Option<String>,
815 pub after_register: Option<String>,
816 pub before_login: Option<String>,
817 pub after_login: Option<String>,
818 pub before_api_key: Option<String>,
819 pub after_api_key: Option<String>,
820}
821
822impl Hooks {
823 pub fn get(&self, event: HookEvent) -> Option<&str> {
825 let slot = match event {
826 HookEvent::BeforeList => &self.before_list,
827 HookEvent::AfterList => &self.after_list,
828 HookEvent::BeforeRead => &self.before_read,
829 HookEvent::AfterRead => &self.after_read,
830 HookEvent::BeforeCreate => &self.before_create,
831 HookEvent::AfterCreate => &self.after_create,
832 HookEvent::BeforeUpdate => &self.before_update,
833 HookEvent::AfterUpdate => &self.after_update,
834 HookEvent::BeforeDelete => &self.before_delete,
835 HookEvent::AfterDelete => &self.after_delete,
836 };
837 slot.as_deref()
838 }
839
840 pub fn iter(&self) -> impl Iterator<Item = (HookEvent, &str)> {
842 HookEvent::ALL
843 .into_iter()
844 .filter_map(|event| self.get(event).map(|name| (event, name)))
845 }
846
847 pub fn is_empty(&self) -> bool {
849 self.iter().next().is_none() && self.auth_iter().next().is_none()
850 }
851}
852
853#[derive(Debug, Clone, Deserialize)]
855#[serde(default)]
856pub struct AuthSpec {
857 pub identity_field: String,
859 pub password_field: String,
861 pub oauth_providers: Vec<String>,
863}
864
865impl Default for AuthSpec {
866 fn default() -> Self {
867 AuthSpec {
868 identity_field: "email".to_string(),
869 password_field: "password_hash".to_string(),
870 oauth_providers: Vec::new(),
871 }
872 }
873}
874
875#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
883pub enum AuthEvent {
884 BeforeRegister,
885 AfterRegister,
886 BeforeLogin,
887 AfterLogin,
888 BeforeApiKey,
889 AfterApiKey,
890}
891
892impl AuthEvent {
893 pub const ALL: [AuthEvent; 6] = [
895 AuthEvent::BeforeRegister,
896 AuthEvent::AfterRegister,
897 AuthEvent::BeforeLogin,
898 AuthEvent::AfterLogin,
899 AuthEvent::BeforeApiKey,
900 AuthEvent::AfterApiKey,
901 ];
902
903 pub fn as_str(self) -> &'static str {
905 match self {
906 AuthEvent::BeforeRegister => "before_register",
907 AuthEvent::AfterRegister => "after_register",
908 AuthEvent::BeforeLogin => "before_login",
909 AuthEvent::AfterLogin => "after_login",
910 AuthEvent::BeforeApiKey => "before_api_key",
911 AuthEvent::AfterApiKey => "after_api_key",
912 }
913 }
914
915 pub fn action(self) -> &'static str {
917 match self {
918 AuthEvent::BeforeRegister | AuthEvent::AfterRegister => "register",
919 AuthEvent::BeforeLogin | AuthEvent::AfterLogin => "login",
920 AuthEvent::BeforeApiKey | AuthEvent::AfterApiKey => "api_key",
921 }
922 }
923
924 pub fn phase(self) -> &'static str {
926 if self.is_before() {
927 "before"
928 } else {
929 "after"
930 }
931 }
932
933 pub fn is_before(self) -> bool {
935 matches!(
936 self,
937 AuthEvent::BeforeRegister | AuthEvent::BeforeLogin | AuthEvent::BeforeApiKey
938 )
939 }
940}
941
942impl Hooks {
943 pub fn get_auth(&self, event: AuthEvent) -> Option<&str> {
945 let slot = match event {
946 AuthEvent::BeforeRegister => &self.before_register,
947 AuthEvent::AfterRegister => &self.after_register,
948 AuthEvent::BeforeLogin => &self.before_login,
949 AuthEvent::AfterLogin => &self.after_login,
950 AuthEvent::BeforeApiKey => &self.before_api_key,
951 AuthEvent::AfterApiKey => &self.after_api_key,
952 };
953 slot.as_deref()
954 }
955
956 pub fn auth_iter(&self) -> impl Iterator<Item = (AuthEvent, &str)> {
958 AuthEvent::ALL
959 .into_iter()
960 .filter_map(|event| self.get_auth(event).map(|name| (event, name)))
961 }
962}
963
964#[cfg(test)]
965mod tests {
966 use super::*;
967
968 fn parse_resource(src: &str) -> Resource {
969 let resource: Resource = toml::from_str(src).unwrap();
970 resource.validate().unwrap();
971 resource
972 }
973
974 #[test]
975 fn access_parser_and_permissions_defaults_match_org_membership_model() {
976 assert_eq!(Access::parse("public"), Access::Public);
977 assert_eq!(Access::parse("authenticated"), Access::Authenticated);
978 assert_eq!(Access::parse("member"), Access::Member);
979 assert_eq!(Access::parse("owner"), Access::Owner);
980 assert_eq!(Access::parse("role:admin"), Access::Role("admin".into()));
981 assert_eq!(Access::parse("wat"), Access::Private);
982
983 let defaults = Permissions::default();
984 assert_eq!(defaults.list, Access::Member);
985 assert_eq!(defaults.read, Access::Member);
986 assert_eq!(defaults.create, Access::Member);
987 assert_eq!(defaults.update, Access::Member);
988 assert_eq!(defaults.delete, Access::Member);
989 }
990
991 #[test]
992 fn validate_rejects_reserved_id_and_dangling_reference_definition() {
993 let reserved_id: Resource = toml::from_str(
994 r#"
995[resource]
996name = "bad"
997
998[fields.id]
999type = "string"
1000"#,
1001 )
1002 .unwrap();
1003 assert!(reserved_id.validate().is_err());
1004
1005 let missing_target: Resource = toml::from_str(
1006 r#"
1007[resource]
1008name = "bad_ref"
1009
1010[fields.owner_id]
1011type = "reference"
1012"#,
1013 )
1014 .unwrap();
1015 assert!(missing_target.validate().is_err());
1016 }
1017
1018 #[test]
1019 fn content_format_is_only_allowed_on_text_fields() {
1020 let good: Resource = toml::from_str(
1021 r#"
1022[resource]
1023name = "article"
1024
1025[fields.body]
1026type = "text"
1027
1028[fields.body.admin]
1029format = "markdown"
1030"#,
1031 )
1032 .unwrap();
1033 good.validate().unwrap();
1034 assert_eq!(good.fields["body"].admin.format, ContentFormat::Markdown);
1035
1036 let bad: Resource = toml::from_str(
1037 r#"
1038[resource]
1039name = "article"
1040
1041[fields.published]
1042type = "boolean"
1043
1044[fields.published.admin]
1045format = "html"
1046"#,
1047 )
1048 .unwrap();
1049 assert!(bad.validate().is_err());
1050 }
1051
1052 #[test]
1053 fn references_derive_relation_names_and_default_on_delete() {
1054 let resource = parse_resource(
1055 r#"
1056[resource]
1057name = "comment"
1058
1059[fields.post_id]
1060type = "reference"
1061references = "post"
1062required = true
1063
1064[fields.author_id]
1065type = "reference"
1066references = "user"
1067on_delete = "cascade"
1068"#,
1069 );
1070
1071 assert_eq!(relation_name("owner_id"), "owner");
1072 assert_eq!(relation_name("slug"), "slug");
1073
1074 let refs = resource.references();
1075 assert_eq!(refs.len(), 2);
1076 let post = refs.iter().find(|rf| rf.field == "post_id").unwrap();
1077 assert_eq!(post.target, "post");
1078 assert_eq!(post.relation, "post");
1079 assert_eq!(post.on_delete, OnDelete::Restrict);
1080 assert!(post.required);
1081
1082 let author = resource.reference_by_relation("author").unwrap();
1083 assert_eq!(author.field, "author_id");
1084 assert_eq!(author.on_delete, OnDelete::Cascade);
1085 }
1086
1087 #[test]
1088 fn hooks_parse_per_event_and_iterate_in_lifecycle_order() {
1089 let resource = parse_resource(
1090 r#"
1091[resource]
1092name = "post"
1093
1094[fields.title]
1095type = "string"
1096
1097[hooks]
1098before_create = "validate_post"
1099after_create = "notify"
1100after_list = "redact"
1101"#,
1102 );
1103
1104 assert_eq!(
1105 resource.hook(HookEvent::BeforeCreate),
1106 Some("validate_post")
1107 );
1108 assert_eq!(resource.hook(HookEvent::AfterCreate), Some("notify"));
1109 assert_eq!(resource.hook(HookEvent::AfterList), Some("redact"));
1110 assert_eq!(resource.hook(HookEvent::BeforeUpdate), None);
1111 assert!(!resource.hooks.is_empty());
1112
1113 let declared: Vec<_> = resource.hooks.iter().collect();
1114 assert_eq!(
1115 declared,
1116 vec![
1117 (HookEvent::AfterList, "redact"),
1118 (HookEvent::BeforeCreate, "validate_post"),
1119 (HookEvent::AfterCreate, "notify"),
1120 ]
1121 );
1122 }
1123
1124 #[test]
1125 fn hook_events_expose_wire_names_actions_and_phases() {
1126 assert_eq!(HookEvent::BeforeCreate.as_str(), "before_create");
1127 assert_eq!(HookEvent::BeforeCreate.action(), "create");
1128 assert_eq!(HookEvent::BeforeCreate.phase(), "before");
1129 assert!(HookEvent::BeforeCreate.is_before());
1130
1131 assert_eq!(HookEvent::AfterList.as_str(), "after_list");
1132 assert_eq!(HookEvent::AfterList.action(), "list");
1133 assert_eq!(HookEvent::AfterList.phase(), "after");
1134 assert!(!HookEvent::AfterList.is_before());
1135
1136 for event in HookEvent::ALL {
1138 let resource = parse_resource(&format!(
1139 "[resource]\nname = \"post\"\n\n[hooks]\n{} = \"h\"\n",
1140 event.as_str()
1141 ));
1142 assert_eq!(resource.hook(event), Some("h"), "{}", event.as_str());
1143 assert_eq!(resource.hooks.iter().count(), 1);
1144 }
1145 }
1146
1147 #[test]
1148 fn hooks_reject_typos_and_empty_function_names() {
1149 let typo = toml::from_str::<Resource>(
1150 r#"
1151[resource]
1152name = "post"
1153
1154[hooks]
1155befor_create = "oops"
1156"#,
1157 );
1158 assert!(typo.is_err(), "unknown hook keys must not be ignored");
1159
1160 let empty: Resource = toml::from_str(
1161 r#"
1162[resource]
1163name = "post"
1164
1165[hooks]
1166after_delete = " "
1167"#,
1168 )
1169 .unwrap();
1170 assert!(empty.validate().is_err());
1171 }
1172
1173 #[test]
1174 fn auth_events_expose_wire_names_actions_and_phases() {
1175 assert_eq!(AuthEvent::BeforeLogin.as_str(), "before_login");
1176 assert_eq!(AuthEvent::BeforeLogin.action(), "login");
1177 assert_eq!(AuthEvent::BeforeLogin.phase(), "before");
1178 assert!(AuthEvent::BeforeLogin.is_before());
1179
1180 assert_eq!(AuthEvent::AfterApiKey.action(), "api_key");
1181 assert_eq!(AuthEvent::AfterApiKey.phase(), "after");
1182 assert!(!AuthEvent::AfterApiKey.is_before());
1183
1184 for event in AuthEvent::ALL {
1187 let resource = parse_resource(&format!(
1188 "[resource]\nname = \"user\"\n\n[hooks]\nafter_create = \"c\"\n{} = \"h\"\n",
1189 event.as_str()
1190 ));
1191 assert_eq!(resource.auth_hook(event), Some("h"), "{}", event.as_str());
1192 assert_eq!(resource.hook(HookEvent::AfterCreate), Some("c"));
1193 assert_eq!(resource.hooks.auth_iter().count(), 1);
1194 assert_eq!(resource.hooks.iter().count(), 1);
1197 }
1198 }
1199
1200 #[test]
1201 fn auth_hooks_are_absent_by_default_and_reject_typos_and_empty_names() {
1202 let plain =
1203 parse_resource("[resource]\nname = \"user\"\n\n[hooks]\nafter_create = \"c\"\n");
1204 assert_eq!(plain.auth_hook(AuthEvent::BeforeLogin), None);
1205 assert!(parse_resource("[resource]\nname = \"user\"\n")
1206 .hooks
1207 .is_empty());
1208
1209 let typo = toml::from_str::<Resource>(
1210 "[resource]\nname = \"user\"\n\n[hooks]\nbefore_signin = \"oops\"\n",
1211 );
1212 assert!(typo.is_err(), "unknown auth hook keys must not be ignored");
1213
1214 let empty: Resource =
1215 toml::from_str("[resource]\nname = \"user\"\n\n[hooks]\nafter_login = \" \"\n")
1216 .unwrap();
1217 assert!(empty.validate().is_err());
1218 }
1219
1220 #[test]
1221 fn auth_hooks_are_rejected_on_any_resource_but_user() {
1222 let stray: Resource =
1225 toml::from_str("[resource]\nname = \"post\"\n\n[hooks]\nbefore_login = \"h\"\n")
1226 .unwrap();
1227 let message = stray.validate().unwrap_err().to_string();
1228 assert!(message.contains("before_login"), "{message}");
1229 assert!(message.contains("user"), "{message}");
1230 }
1231
1232 #[test]
1233 fn resources_have_no_hooks_by_default() {
1234 let resource = parse_resource("[resource]\nname = \"post\"\n");
1235 assert!(resource.hooks.is_empty());
1236 assert!(HookEvent::ALL
1237 .into_iter()
1238 .all(|event| resource.hook(event).is_none()));
1239 }
1240
1241 #[test]
1242 fn admin_visibility_defaults_hide_auth_resources_but_nothing_else() {
1243 let post = parse_resource("[resource]\nname = \"post\"\n");
1244 assert!(post.admin.is_visible("post"));
1245
1246 let user = parse_resource(crate::defaults::USER_TOML);
1247 assert!(!user.admin.is_visible("user"));
1248 assert!(!parse_resource(crate::defaults::MEMBERSHIP_TOML)
1249 .admin
1250 .is_visible("membership"));
1251
1252 let replaced = parse_resource(
1254 "[resource]\nname = \"user\"\nscope = \"global\"\n\n[fields.email]\ntype = \"string\"\n",
1255 );
1256 assert!(!replaced.admin.is_visible("user"));
1257
1258 let opted_in = parse_resource(
1260 "[resource]\nname = \"user\"\nscope = \"global\"\n\n[admin]\nvisible = true\n",
1261 );
1262 assert!(opted_in.admin.is_visible("user"));
1263 }
1264
1265 #[test]
1266 fn admin_labels_are_inferred_and_overridable() {
1267 let inferred = parse_resource("[resource]\nname = \"purchase_order\"\n");
1268 assert_eq!(inferred.admin_label(), "Purchase order");
1269 assert_eq!(inferred.admin_plural(), "Purchase orders");
1270
1271 let overridden = parse_resource(
1272 "[resource]\nname = \"person\"\n\n[admin]\nlabel = \"Person\"\nplural = \"People\"\n",
1273 );
1274 assert_eq!(overridden.admin_plural(), "People");
1275
1276 assert_eq!(titleize("owner_id"), "Owner");
1277 assert_eq!(titleize("total_cents"), "Total cents");
1278 assert_eq!(pluralize("Category"), "Categories");
1279 assert_eq!(pluralize("Address"), "Addresses");
1280 assert_eq!(pluralize("Day"), "Days");
1281 assert_eq!(pluralize("Product"), "Products");
1282 }
1283
1284 #[test]
1285 fn display_field_prefers_conventional_names_then_any_string() {
1286 let conventional = parse_resource(
1287 r#"
1288[resource]
1289name = "product"
1290
1291[fields.sku]
1292type = "string"
1293
1294[fields.name]
1295type = "string"
1296"#,
1297 );
1298 assert_eq!(conventional.admin_display_field().as_deref(), Some("name"));
1299
1300 let only_odd_names =
1301 parse_resource("[resource]\nname = \"blob\"\n\n[fields.zzz]\ntype = \"string\"\n");
1302 assert_eq!(only_odd_names.admin_display_field().as_deref(), Some("zzz"));
1303
1304 let nothing_stringy =
1305 parse_resource("[resource]\nname = \"tick\"\n\n[fields.count]\ntype = \"integer\"\n");
1306 assert_eq!(nothing_stringy.admin_display_field(), None);
1307
1308 let declared = parse_resource(
1310 r#"
1311[resource]
1312name = "product"
1313
1314[admin]
1315display_field = "sku"
1316
1317[fields.sku]
1318type = "string"
1319
1320[fields.name]
1321type = "string"
1322"#,
1323 );
1324 assert_eq!(declared.admin_display_field().as_deref(), Some("sku"));
1325 assert_eq!(declared.admin_search_field().as_deref(), Some("sku"));
1327 }
1328
1329 #[test]
1330 fn inferred_columns_skip_blobs_and_dashboard_hidden_fields() {
1331 let resource = parse_resource(
1332 r#"
1333[resource]
1334name = "product"
1335
1336[fields.name]
1337type = "string"
1338
1339[fields.status]
1340type = "string"
1341
1342[fields.description]
1343type = "text"
1344
1345[fields.attributes]
1346type = "json"
1347
1348[fields.secret_ratio]
1349type = "float"
1350
1351[fields.secret_ratio.admin]
1352visible = false
1353"#,
1354 );
1355 assert_eq!(resource.admin_columns(), vec!["name", "status"]);
1356
1357 let declared = parse_resource(
1358 r#"
1359[resource]
1360name = "product"
1361
1362[admin]
1363columns = ["status", "name"]
1364
1365[fields.name]
1366type = "string"
1367
1368[fields.status]
1369type = "string"
1370"#,
1371 );
1372 assert_eq!(declared.admin_columns(), vec!["status", "name"]);
1373 }
1374
1375 #[test]
1376 fn admin_section_rejects_columns_naming_fields_that_do_not_exist() {
1377 let bad_column: Resource = toml::from_str(
1378 "[resource]\nname = \"post\"\n\n[admin]\ncolumns = [\"nope\"]\n\n[fields.title]\ntype = \"string\"\n",
1379 )
1380 .unwrap();
1381 assert!(bad_column.validate().is_err());
1382
1383 let bad_display: Resource = toml::from_str(
1384 "[resource]\nname = \"post\"\n\n[admin]\ndisplay_field = \"nope\"\n\n[fields.title]\ntype = \"string\"\n",
1385 )
1386 .unwrap();
1387 assert!(bad_display.validate().is_err());
1388
1389 let unsearchable: Resource = toml::from_str(
1392 "[resource]\nname = \"tick\"\n\n[admin]\nsearch_field = \"count\"\n\n[fields.count]\ntype = \"integer\"\n",
1393 )
1394 .unwrap();
1395 assert!(unsearchable.validate().is_err());
1396
1397 let named_by_a_number = parse_resource(
1400 "[resource]\nname = \"tick\"\n\n[admin]\ndisplay_field = \"count\"\n\n[fields.count]\ntype = \"integer\"\n",
1401 );
1402 assert_eq!(
1403 named_by_a_number.admin_display_field().as_deref(),
1404 Some("count")
1405 );
1406 assert_eq!(named_by_a_number.admin_search_field(), None);
1407
1408 assert!(toml::from_str::<Resource>(
1410 "[resource]\nname = \"post\"\n\n[admin]\nvisibel = true\n"
1411 )
1412 .is_err());
1413 }
1414
1415 #[test]
1416 fn field_admin_carries_widget_options_and_visibility() {
1417 let resource = parse_resource(
1418 r#"
1419[resource]
1420name = "product"
1421
1422[fields.status]
1423type = "string"
1424
1425[fields.status.admin]
1426label = "Lifecycle"
1427widget = "select"
1428options = ["draft", "active|Live"]
1429readonly = true
1430"#,
1431 );
1432 let status = &resource.fields["status"];
1433 assert_eq!(status.admin.label.as_deref(), Some("Lifecycle"));
1434 assert_eq!(status.admin.widget, Widget::Select);
1435 assert_eq!(status.admin.widget.as_str(), "select");
1436 assert_eq!(status.admin.options, vec!["draft", "active|Live"]);
1437 assert!(status.admin.readonly);
1438 assert!(status.admin.visible);
1440 assert_eq!(resource.fields["status"].admin.help, None);
1441 }
1442
1443 #[test]
1444 fn org_column_reflects_resource_scope() {
1445 let org_scoped = parse_resource(
1446 r#"
1447[resource]
1448name = "post"
1449
1450[fields.title]
1451type = "string"
1452"#,
1453 );
1454 let global = parse_resource(
1455 r#"
1456[resource]
1457name = "plan"
1458scope = "global"
1459
1460[fields.name]
1461type = "string"
1462"#,
1463 );
1464 let organization = parse_resource(crate::defaults::ORGANIZATION_TOML);
1465
1466 assert_eq!(org_scoped.org_column(), Some("organization_id"));
1467 assert_eq!(global.org_column(), None);
1468 assert_eq!(organization.org_column(), Some("id"));
1469 }
1470}