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"
383 | "organization"
384 | "membership"
385 | "membership_role"
386 | "api_key"
387 | "oauth_connection"
388 | "invitation"
389 | "auth_token"
390 )
391}
392
393#[derive(Debug, Clone, Deserialize)]
395#[serde(default, deny_unknown_fields)]
396pub struct FieldAdmin {
397 pub visible: bool,
400 pub readonly: bool,
402 pub label: Option<String>,
404 pub help: Option<String>,
406 pub widget: Widget,
408 pub options: Vec<String>,
411 pub placeholder: Option<String>,
413 pub signup: Option<bool>,
422 pub format: ContentFormat,
426}
427
428impl Default for FieldAdmin {
429 fn default() -> Self {
430 FieldAdmin {
431 visible: true,
432 readonly: false,
433 label: None,
434 help: None,
435 widget: Widget::Auto,
436 options: Vec::new(),
437 placeholder: None,
438 format: ContentFormat::Plain,
439 signup: None,
440 }
441 }
442}
443
444impl FieldAdmin {
445 pub fn in_signup(&self, field: &Field) -> bool {
452 self.signup.unwrap_or(field.required)
453 }
454}
455
456#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
461#[serde(rename_all = "snake_case")]
462pub enum ContentFormat {
463 #[default]
465 Plain,
466 Markdown,
467 Html,
468}
469
470impl ContentFormat {
471 pub fn as_str(self) -> &'static str {
472 match self {
473 ContentFormat::Plain => "plain",
474 ContentFormat::Markdown => "markdown",
475 ContentFormat::Html => "html",
476 }
477 }
478}
479
480#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
482#[serde(rename_all = "snake_case")]
483pub enum Widget {
484 Auto,
486 Text,
487 Textarea,
488 Select,
489 Email,
490 Url,
491 Password,
492 Color,
493 Date,
494 DateTime,
495 Json,
496 Switch,
497}
498
499impl Widget {
500 pub fn as_str(self) -> &'static str {
501 match self {
502 Widget::Auto => "auto",
503 Widget::Text => "text",
504 Widget::Textarea => "textarea",
505 Widget::Select => "select",
506 Widget::Email => "email",
507 Widget::Url => "url",
508 Widget::Password => "password",
509 Widget::Color => "color",
510 Widget::Date => "date",
511 Widget::DateTime => "date_time",
512 Widget::Json => "json",
513 Widget::Switch => "switch",
514 }
515 }
516}
517
518#[derive(Debug, Clone, Deserialize)]
520pub struct Field {
521 #[serde(rename = "type")]
522 pub ty: FieldType,
523 #[serde(default)]
525 pub references: Option<String>,
526 #[serde(default)]
527 pub required: bool,
528 #[serde(default)]
529 pub unique: bool,
530 #[serde(default)]
532 pub hidden: bool,
533 #[serde(default)]
535 pub default: Option<serde_json::Value>,
536 pub max_length: Option<u32>,
537 #[serde(default)]
540 pub on_delete: Option<OnDelete>,
541 #[serde(default)]
543 pub admin: FieldAdmin,
544}
545
546#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
548#[serde(rename_all = "snake_case")]
549pub enum FieldType {
550 String,
551 Text,
552 Integer,
553 BigInt,
554 Float,
555 Boolean,
556 Uuid,
557 Timestamp,
558 Json,
559 Reference,
561}
562
563#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
565#[serde(rename_all = "snake_case")]
566pub enum OnDelete {
567 Restrict,
569 SetNull,
571 Cascade,
573 NoAction,
575}
576
577impl OnDelete {
578 pub fn to_sql(self) -> &'static str {
579 match self {
580 OnDelete::Restrict => "RESTRICT",
581 OnDelete::SetNull => "SET NULL",
582 OnDelete::Cascade => "CASCADE",
583 OnDelete::NoAction => "NO ACTION",
584 }
585 }
586}
587
588#[derive(Debug, Clone)]
591pub struct Reference {
592 pub field: String,
593 pub target: String,
594 pub relation: String,
595 pub on_delete: OnDelete,
596 pub required: bool,
597}
598
599pub fn relation_name(field: &str) -> &str {
602 field.strip_suffix("_id").unwrap_or(field)
603}
604
605pub fn titleize(name: &str) -> String {
608 let spaced = name.trim_end_matches("_id").replace('_', " ");
609 let mut chars = spaced.chars();
610 match chars.next() {
611 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
612 None => spaced,
613 }
614}
615
616pub fn pluralize(label: &str) -> String {
619 let lower = label.to_lowercase();
620 if lower.ends_with('s')
621 || lower.ends_with("x")
622 || lower.ends_with("ch")
623 || lower.ends_with("sh")
624 {
625 format!("{label}es")
626 } else if lower.ends_with('y')
627 && !lower.ends_with("ay")
628 && !lower.ends_with("ey")
629 && !lower.ends_with("oy")
630 && !lower.ends_with("uy")
631 {
632 format!("{}ies", &label[..label.len() - 1])
633 } else {
634 format!("{label}s")
635 }
636}
637
638#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
641#[serde(rename_all = "snake_case")]
642pub enum Scope {
643 Organization,
646 Global,
648}
649
650#[derive(Debug, Clone, PartialEq, Eq)]
657pub enum Access {
658 Public,
661 Authenticated,
663 Member,
665 Role(String),
667 Owner,
669 Private,
671}
672
673impl Access {
674 pub fn parse(s: &str) -> Access {
676 match s {
677 "public" => Access::Public,
678 "authenticated" => Access::Authenticated,
679 "member" => Access::Member,
680 "owner" => Access::Owner,
681 "private" => Access::Private,
682 other => other
683 .strip_prefix("role:")
684 .map(|role| Access::Role(role.to_string()))
685 .unwrap_or(Access::Private),
686 }
687 }
688
689 pub fn as_string(&self) -> String {
691 match self {
692 Access::Public => "public".to_string(),
693 Access::Authenticated => "authenticated".to_string(),
694 Access::Member => "member".to_string(),
695 Access::Role(role) => format!("role:{role}"),
696 Access::Owner => "owner".to_string(),
697 Access::Private => "private".to_string(),
698 }
699 }
700}
701
702#[derive(Debug, Clone, Deserialize)]
704#[serde(from = "PermissionsRaw")]
705pub struct Permissions {
706 pub list: Access,
707 pub read: Access,
708 pub create: Access,
709 pub update: Access,
710 pub delete: Access,
711}
712
713impl Default for Permissions {
714 fn default() -> Self {
715 Permissions {
719 list: Access::Member,
720 read: Access::Member,
721 create: Access::Member,
722 update: Access::Member,
723 delete: Access::Member,
724 }
725 }
726}
727
728#[derive(Deserialize)]
729struct PermissionsRaw {
730 list: Option<String>,
731 read: Option<String>,
732 create: Option<String>,
733 update: Option<String>,
734 delete: Option<String>,
735}
736
737impl From<PermissionsRaw> for Permissions {
738 fn from(r: PermissionsRaw) -> Self {
739 let d = Permissions::default();
740 Permissions {
741 list: r.list.map(|s| Access::parse(&s)).unwrap_or(d.list),
742 read: r.read.map(|s| Access::parse(&s)).unwrap_or(d.read),
743 create: r.create.map(|s| Access::parse(&s)).unwrap_or(d.create),
744 update: r.update.map(|s| Access::parse(&s)).unwrap_or(d.update),
745 delete: r.delete.map(|s| Access::parse(&s)).unwrap_or(d.delete),
746 }
747 }
748}
749
750#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
757pub enum HookEvent {
758 BeforeList,
759 AfterList,
760 BeforeRead,
761 AfterRead,
762 BeforeCreate,
763 AfterCreate,
764 BeforeUpdate,
765 AfterUpdate,
766 BeforeDelete,
767 AfterDelete,
768}
769
770impl HookEvent {
771 pub const ALL: [HookEvent; 10] = [
773 HookEvent::BeforeList,
774 HookEvent::AfterList,
775 HookEvent::BeforeRead,
776 HookEvent::AfterRead,
777 HookEvent::BeforeCreate,
778 HookEvent::AfterCreate,
779 HookEvent::BeforeUpdate,
780 HookEvent::AfterUpdate,
781 HookEvent::BeforeDelete,
782 HookEvent::AfterDelete,
783 ];
784
785 pub fn as_str(self) -> &'static str {
787 match self {
788 HookEvent::BeforeList => "before_list",
789 HookEvent::AfterList => "after_list",
790 HookEvent::BeforeRead => "before_read",
791 HookEvent::AfterRead => "after_read",
792 HookEvent::BeforeCreate => "before_create",
793 HookEvent::AfterCreate => "after_create",
794 HookEvent::BeforeUpdate => "before_update",
795 HookEvent::AfterUpdate => "after_update",
796 HookEvent::BeforeDelete => "before_delete",
797 HookEvent::AfterDelete => "after_delete",
798 }
799 }
800
801 pub fn action(self) -> &'static str {
803 match self {
804 HookEvent::BeforeList | HookEvent::AfterList => "list",
805 HookEvent::BeforeRead | HookEvent::AfterRead => "read",
806 HookEvent::BeforeCreate | HookEvent::AfterCreate => "create",
807 HookEvent::BeforeUpdate | HookEvent::AfterUpdate => "update",
808 HookEvent::BeforeDelete | HookEvent::AfterDelete => "delete",
809 }
810 }
811
812 pub fn phase(self) -> &'static str {
814 if self.is_before() {
815 "before"
816 } else {
817 "after"
818 }
819 }
820
821 pub fn is_before(self) -> bool {
823 matches!(
824 self,
825 HookEvent::BeforeList
826 | HookEvent::BeforeRead
827 | HookEvent::BeforeCreate
828 | HookEvent::BeforeUpdate
829 | HookEvent::BeforeDelete
830 )
831 }
832}
833
834#[derive(Debug, Clone, Default, Deserialize)]
843#[serde(default, deny_unknown_fields)]
844pub struct Hooks {
845 pub before_list: Option<String>,
846 pub after_list: Option<String>,
847 pub before_read: Option<String>,
848 pub after_read: Option<String>,
849 pub before_create: Option<String>,
850 pub after_create: Option<String>,
851 pub before_update: Option<String>,
852 pub after_update: Option<String>,
853 pub before_delete: Option<String>,
854 pub after_delete: Option<String>,
855 pub before_register: Option<String>,
856 pub after_register: Option<String>,
857 pub before_login: Option<String>,
858 pub after_login: Option<String>,
859 pub before_api_key: Option<String>,
860 pub after_api_key: Option<String>,
861}
862
863impl Hooks {
864 pub fn get(&self, event: HookEvent) -> Option<&str> {
866 let slot = match event {
867 HookEvent::BeforeList => &self.before_list,
868 HookEvent::AfterList => &self.after_list,
869 HookEvent::BeforeRead => &self.before_read,
870 HookEvent::AfterRead => &self.after_read,
871 HookEvent::BeforeCreate => &self.before_create,
872 HookEvent::AfterCreate => &self.after_create,
873 HookEvent::BeforeUpdate => &self.before_update,
874 HookEvent::AfterUpdate => &self.after_update,
875 HookEvent::BeforeDelete => &self.before_delete,
876 HookEvent::AfterDelete => &self.after_delete,
877 };
878 slot.as_deref()
879 }
880
881 pub fn iter(&self) -> impl Iterator<Item = (HookEvent, &str)> {
883 HookEvent::ALL
884 .into_iter()
885 .filter_map(|event| self.get(event).map(|name| (event, name)))
886 }
887
888 pub fn is_empty(&self) -> bool {
890 self.iter().next().is_none() && self.auth_iter().next().is_none()
891 }
892}
893
894#[derive(Debug, Clone, Deserialize)]
896#[serde(default)]
897pub struct AuthSpec {
898 pub identity_field: String,
900 pub password_field: String,
902 pub oauth_providers: Vec<String>,
904}
905
906impl Default for AuthSpec {
907 fn default() -> Self {
908 AuthSpec {
909 identity_field: "email".to_string(),
910 password_field: "password_hash".to_string(),
911 oauth_providers: Vec::new(),
912 }
913 }
914}
915
916#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
924pub enum AuthEvent {
925 BeforeRegister,
926 AfterRegister,
927 BeforeLogin,
928 AfterLogin,
929 BeforeApiKey,
930 AfterApiKey,
931}
932
933impl AuthEvent {
934 pub const ALL: [AuthEvent; 6] = [
936 AuthEvent::BeforeRegister,
937 AuthEvent::AfterRegister,
938 AuthEvent::BeforeLogin,
939 AuthEvent::AfterLogin,
940 AuthEvent::BeforeApiKey,
941 AuthEvent::AfterApiKey,
942 ];
943
944 pub fn as_str(self) -> &'static str {
946 match self {
947 AuthEvent::BeforeRegister => "before_register",
948 AuthEvent::AfterRegister => "after_register",
949 AuthEvent::BeforeLogin => "before_login",
950 AuthEvent::AfterLogin => "after_login",
951 AuthEvent::BeforeApiKey => "before_api_key",
952 AuthEvent::AfterApiKey => "after_api_key",
953 }
954 }
955
956 pub fn action(self) -> &'static str {
958 match self {
959 AuthEvent::BeforeRegister | AuthEvent::AfterRegister => "register",
960 AuthEvent::BeforeLogin | AuthEvent::AfterLogin => "login",
961 AuthEvent::BeforeApiKey | AuthEvent::AfterApiKey => "api_key",
962 }
963 }
964
965 pub fn phase(self) -> &'static str {
967 if self.is_before() {
968 "before"
969 } else {
970 "after"
971 }
972 }
973
974 pub fn is_before(self) -> bool {
976 matches!(
977 self,
978 AuthEvent::BeforeRegister | AuthEvent::BeforeLogin | AuthEvent::BeforeApiKey
979 )
980 }
981}
982
983impl Hooks {
984 pub fn get_auth(&self, event: AuthEvent) -> Option<&str> {
986 let slot = match event {
987 AuthEvent::BeforeRegister => &self.before_register,
988 AuthEvent::AfterRegister => &self.after_register,
989 AuthEvent::BeforeLogin => &self.before_login,
990 AuthEvent::AfterLogin => &self.after_login,
991 AuthEvent::BeforeApiKey => &self.before_api_key,
992 AuthEvent::AfterApiKey => &self.after_api_key,
993 };
994 slot.as_deref()
995 }
996
997 pub fn auth_iter(&self) -> impl Iterator<Item = (AuthEvent, &str)> {
999 AuthEvent::ALL
1000 .into_iter()
1001 .filter_map(|event| self.get_auth(event).map(|name| (event, name)))
1002 }
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007 use super::*;
1008
1009 fn parse_resource(src: &str) -> Resource {
1010 let resource: Resource = toml::from_str(src).unwrap();
1011 resource.validate().unwrap();
1012 resource
1013 }
1014
1015 #[test]
1016 fn access_parser_and_permissions_defaults_match_org_membership_model() {
1017 assert_eq!(Access::parse("public"), Access::Public);
1018 assert_eq!(Access::parse("authenticated"), Access::Authenticated);
1019 assert_eq!(Access::parse("member"), Access::Member);
1020 assert_eq!(Access::parse("owner"), Access::Owner);
1021 assert_eq!(Access::parse("role:admin"), Access::Role("admin".into()));
1022 assert_eq!(Access::parse("wat"), Access::Private);
1023
1024 let defaults = Permissions::default();
1025 assert_eq!(defaults.list, Access::Member);
1026 assert_eq!(defaults.read, Access::Member);
1027 assert_eq!(defaults.create, Access::Member);
1028 assert_eq!(defaults.update, Access::Member);
1029 assert_eq!(defaults.delete, Access::Member);
1030 }
1031
1032 #[test]
1033 fn validate_rejects_reserved_id_and_dangling_reference_definition() {
1034 let reserved_id: Resource = toml::from_str(
1035 r#"
1036[resource]
1037name = "bad"
1038
1039[fields.id]
1040type = "string"
1041"#,
1042 )
1043 .unwrap();
1044 assert!(reserved_id.validate().is_err());
1045
1046 let missing_target: Resource = toml::from_str(
1047 r#"
1048[resource]
1049name = "bad_ref"
1050
1051[fields.owner_id]
1052type = "reference"
1053"#,
1054 )
1055 .unwrap();
1056 assert!(missing_target.validate().is_err());
1057 }
1058
1059 #[test]
1060 fn content_format_is_only_allowed_on_text_fields() {
1061 let good: Resource = toml::from_str(
1062 r#"
1063[resource]
1064name = "article"
1065
1066[fields.body]
1067type = "text"
1068
1069[fields.body.admin]
1070format = "markdown"
1071"#,
1072 )
1073 .unwrap();
1074 good.validate().unwrap();
1075 assert_eq!(good.fields["body"].admin.format, ContentFormat::Markdown);
1076
1077 let bad: Resource = toml::from_str(
1078 r#"
1079[resource]
1080name = "article"
1081
1082[fields.published]
1083type = "boolean"
1084
1085[fields.published.admin]
1086format = "html"
1087"#,
1088 )
1089 .unwrap();
1090 assert!(bad.validate().is_err());
1091 }
1092
1093 #[test]
1094 fn references_derive_relation_names_and_default_on_delete() {
1095 let resource = parse_resource(
1096 r#"
1097[resource]
1098name = "comment"
1099
1100[fields.post_id]
1101type = "reference"
1102references = "post"
1103required = true
1104
1105[fields.author_id]
1106type = "reference"
1107references = "user"
1108on_delete = "cascade"
1109"#,
1110 );
1111
1112 assert_eq!(relation_name("owner_id"), "owner");
1113 assert_eq!(relation_name("slug"), "slug");
1114
1115 let refs = resource.references();
1116 assert_eq!(refs.len(), 2);
1117 let post = refs.iter().find(|rf| rf.field == "post_id").unwrap();
1118 assert_eq!(post.target, "post");
1119 assert_eq!(post.relation, "post");
1120 assert_eq!(post.on_delete, OnDelete::Restrict);
1121 assert!(post.required);
1122
1123 let author = resource.reference_by_relation("author").unwrap();
1124 assert_eq!(author.field, "author_id");
1125 assert_eq!(author.on_delete, OnDelete::Cascade);
1126 }
1127
1128 #[test]
1129 fn hooks_parse_per_event_and_iterate_in_lifecycle_order() {
1130 let resource = parse_resource(
1131 r#"
1132[resource]
1133name = "post"
1134
1135[fields.title]
1136type = "string"
1137
1138[hooks]
1139before_create = "validate_post"
1140after_create = "notify"
1141after_list = "redact"
1142"#,
1143 );
1144
1145 assert_eq!(
1146 resource.hook(HookEvent::BeforeCreate),
1147 Some("validate_post")
1148 );
1149 assert_eq!(resource.hook(HookEvent::AfterCreate), Some("notify"));
1150 assert_eq!(resource.hook(HookEvent::AfterList), Some("redact"));
1151 assert_eq!(resource.hook(HookEvent::BeforeUpdate), None);
1152 assert!(!resource.hooks.is_empty());
1153
1154 let declared: Vec<_> = resource.hooks.iter().collect();
1155 assert_eq!(
1156 declared,
1157 vec![
1158 (HookEvent::AfterList, "redact"),
1159 (HookEvent::BeforeCreate, "validate_post"),
1160 (HookEvent::AfterCreate, "notify"),
1161 ]
1162 );
1163 }
1164
1165 #[test]
1166 fn hook_events_expose_wire_names_actions_and_phases() {
1167 assert_eq!(HookEvent::BeforeCreate.as_str(), "before_create");
1168 assert_eq!(HookEvent::BeforeCreate.action(), "create");
1169 assert_eq!(HookEvent::BeforeCreate.phase(), "before");
1170 assert!(HookEvent::BeforeCreate.is_before());
1171
1172 assert_eq!(HookEvent::AfterList.as_str(), "after_list");
1173 assert_eq!(HookEvent::AfterList.action(), "list");
1174 assert_eq!(HookEvent::AfterList.phase(), "after");
1175 assert!(!HookEvent::AfterList.is_before());
1176
1177 for event in HookEvent::ALL {
1179 let resource = parse_resource(&format!(
1180 "[resource]\nname = \"post\"\n\n[hooks]\n{} = \"h\"\n",
1181 event.as_str()
1182 ));
1183 assert_eq!(resource.hook(event), Some("h"), "{}", event.as_str());
1184 assert_eq!(resource.hooks.iter().count(), 1);
1185 }
1186 }
1187
1188 #[test]
1189 fn hooks_reject_typos_and_empty_function_names() {
1190 let typo = toml::from_str::<Resource>(
1191 r#"
1192[resource]
1193name = "post"
1194
1195[hooks]
1196befor_create = "oops"
1197"#,
1198 );
1199 assert!(typo.is_err(), "unknown hook keys must not be ignored");
1200
1201 let empty: Resource = toml::from_str(
1202 r#"
1203[resource]
1204name = "post"
1205
1206[hooks]
1207after_delete = " "
1208"#,
1209 )
1210 .unwrap();
1211 assert!(empty.validate().is_err());
1212 }
1213
1214 #[test]
1215 fn auth_events_expose_wire_names_actions_and_phases() {
1216 assert_eq!(AuthEvent::BeforeLogin.as_str(), "before_login");
1217 assert_eq!(AuthEvent::BeforeLogin.action(), "login");
1218 assert_eq!(AuthEvent::BeforeLogin.phase(), "before");
1219 assert!(AuthEvent::BeforeLogin.is_before());
1220
1221 assert_eq!(AuthEvent::AfterApiKey.action(), "api_key");
1222 assert_eq!(AuthEvent::AfterApiKey.phase(), "after");
1223 assert!(!AuthEvent::AfterApiKey.is_before());
1224
1225 for event in AuthEvent::ALL {
1228 let resource = parse_resource(&format!(
1229 "[resource]\nname = \"user\"\n\n[hooks]\nafter_create = \"c\"\n{} = \"h\"\n",
1230 event.as_str()
1231 ));
1232 assert_eq!(resource.auth_hook(event), Some("h"), "{}", event.as_str());
1233 assert_eq!(resource.hook(HookEvent::AfterCreate), Some("c"));
1234 assert_eq!(resource.hooks.auth_iter().count(), 1);
1235 assert_eq!(resource.hooks.iter().count(), 1);
1238 }
1239 }
1240
1241 #[test]
1242 fn auth_hooks_are_absent_by_default_and_reject_typos_and_empty_names() {
1243 let plain =
1244 parse_resource("[resource]\nname = \"user\"\n\n[hooks]\nafter_create = \"c\"\n");
1245 assert_eq!(plain.auth_hook(AuthEvent::BeforeLogin), None);
1246 assert!(parse_resource("[resource]\nname = \"user\"\n")
1247 .hooks
1248 .is_empty());
1249
1250 let typo = toml::from_str::<Resource>(
1251 "[resource]\nname = \"user\"\n\n[hooks]\nbefore_signin = \"oops\"\n",
1252 );
1253 assert!(typo.is_err(), "unknown auth hook keys must not be ignored");
1254
1255 let empty: Resource =
1256 toml::from_str("[resource]\nname = \"user\"\n\n[hooks]\nafter_login = \" \"\n")
1257 .unwrap();
1258 assert!(empty.validate().is_err());
1259 }
1260
1261 #[test]
1262 fn auth_hooks_are_rejected_on_any_resource_but_user() {
1263 let stray: Resource =
1266 toml::from_str("[resource]\nname = \"post\"\n\n[hooks]\nbefore_login = \"h\"\n")
1267 .unwrap();
1268 let message = stray.validate().unwrap_err().to_string();
1269 assert!(message.contains("before_login"), "{message}");
1270 assert!(message.contains("user"), "{message}");
1271 }
1272
1273 #[test]
1274 fn resources_have_no_hooks_by_default() {
1275 let resource = parse_resource("[resource]\nname = \"post\"\n");
1276 assert!(resource.hooks.is_empty());
1277 assert!(HookEvent::ALL
1278 .into_iter()
1279 .all(|event| resource.hook(event).is_none()));
1280 }
1281
1282 #[test]
1283 fn admin_visibility_defaults_hide_auth_resources_but_nothing_else() {
1284 let post = parse_resource("[resource]\nname = \"post\"\n");
1285 assert!(post.admin.is_visible("post"));
1286
1287 let user = parse_resource(crate::defaults::USER_TOML);
1288 assert!(!user.admin.is_visible("user"));
1289 assert!(!parse_resource(crate::defaults::MEMBERSHIP_TOML)
1290 .admin
1291 .is_visible("membership"));
1292
1293 let replaced = parse_resource(
1295 "[resource]\nname = \"user\"\nscope = \"global\"\n\n[fields.email]\ntype = \"string\"\n",
1296 );
1297 assert!(!replaced.admin.is_visible("user"));
1298
1299 let opted_in = parse_resource(
1301 "[resource]\nname = \"user\"\nscope = \"global\"\n\n[admin]\nvisible = true\n",
1302 );
1303 assert!(opted_in.admin.is_visible("user"));
1304 }
1305
1306 #[test]
1307 fn admin_labels_are_inferred_and_overridable() {
1308 let inferred = parse_resource("[resource]\nname = \"purchase_order\"\n");
1309 assert_eq!(inferred.admin_label(), "Purchase order");
1310 assert_eq!(inferred.admin_plural(), "Purchase orders");
1311
1312 let overridden = parse_resource(
1313 "[resource]\nname = \"person\"\n\n[admin]\nlabel = \"Person\"\nplural = \"People\"\n",
1314 );
1315 assert_eq!(overridden.admin_plural(), "People");
1316
1317 assert_eq!(titleize("owner_id"), "Owner");
1318 assert_eq!(titleize("total_cents"), "Total cents");
1319 assert_eq!(pluralize("Category"), "Categories");
1320 assert_eq!(pluralize("Address"), "Addresses");
1321 assert_eq!(pluralize("Day"), "Days");
1322 assert_eq!(pluralize("Product"), "Products");
1323 }
1324
1325 #[test]
1326 fn display_field_prefers_conventional_names_then_any_string() {
1327 let conventional = parse_resource(
1328 r#"
1329[resource]
1330name = "product"
1331
1332[fields.sku]
1333type = "string"
1334
1335[fields.name]
1336type = "string"
1337"#,
1338 );
1339 assert_eq!(conventional.admin_display_field().as_deref(), Some("name"));
1340
1341 let only_odd_names =
1342 parse_resource("[resource]\nname = \"blob\"\n\n[fields.zzz]\ntype = \"string\"\n");
1343 assert_eq!(only_odd_names.admin_display_field().as_deref(), Some("zzz"));
1344
1345 let nothing_stringy =
1346 parse_resource("[resource]\nname = \"tick\"\n\n[fields.count]\ntype = \"integer\"\n");
1347 assert_eq!(nothing_stringy.admin_display_field(), None);
1348
1349 let declared = parse_resource(
1351 r#"
1352[resource]
1353name = "product"
1354
1355[admin]
1356display_field = "sku"
1357
1358[fields.sku]
1359type = "string"
1360
1361[fields.name]
1362type = "string"
1363"#,
1364 );
1365 assert_eq!(declared.admin_display_field().as_deref(), Some("sku"));
1366 assert_eq!(declared.admin_search_field().as_deref(), Some("sku"));
1368 }
1369
1370 #[test]
1371 fn inferred_columns_skip_blobs_and_dashboard_hidden_fields() {
1372 let resource = parse_resource(
1373 r#"
1374[resource]
1375name = "product"
1376
1377[fields.name]
1378type = "string"
1379
1380[fields.status]
1381type = "string"
1382
1383[fields.description]
1384type = "text"
1385
1386[fields.attributes]
1387type = "json"
1388
1389[fields.secret_ratio]
1390type = "float"
1391
1392[fields.secret_ratio.admin]
1393visible = false
1394"#,
1395 );
1396 assert_eq!(resource.admin_columns(), vec!["name", "status"]);
1397
1398 let declared = parse_resource(
1399 r#"
1400[resource]
1401name = "product"
1402
1403[admin]
1404columns = ["status", "name"]
1405
1406[fields.name]
1407type = "string"
1408
1409[fields.status]
1410type = "string"
1411"#,
1412 );
1413 assert_eq!(declared.admin_columns(), vec!["status", "name"]);
1414 }
1415
1416 #[test]
1417 fn admin_section_rejects_columns_naming_fields_that_do_not_exist() {
1418 let bad_column: Resource = toml::from_str(
1419 "[resource]\nname = \"post\"\n\n[admin]\ncolumns = [\"nope\"]\n\n[fields.title]\ntype = \"string\"\n",
1420 )
1421 .unwrap();
1422 assert!(bad_column.validate().is_err());
1423
1424 let bad_display: Resource = toml::from_str(
1425 "[resource]\nname = \"post\"\n\n[admin]\ndisplay_field = \"nope\"\n\n[fields.title]\ntype = \"string\"\n",
1426 )
1427 .unwrap();
1428 assert!(bad_display.validate().is_err());
1429
1430 let unsearchable: Resource = toml::from_str(
1433 "[resource]\nname = \"tick\"\n\n[admin]\nsearch_field = \"count\"\n\n[fields.count]\ntype = \"integer\"\n",
1434 )
1435 .unwrap();
1436 assert!(unsearchable.validate().is_err());
1437
1438 let named_by_a_number = parse_resource(
1441 "[resource]\nname = \"tick\"\n\n[admin]\ndisplay_field = \"count\"\n\n[fields.count]\ntype = \"integer\"\n",
1442 );
1443 assert_eq!(
1444 named_by_a_number.admin_display_field().as_deref(),
1445 Some("count")
1446 );
1447 assert_eq!(named_by_a_number.admin_search_field(), None);
1448
1449 assert!(toml::from_str::<Resource>(
1451 "[resource]\nname = \"post\"\n\n[admin]\nvisibel = true\n"
1452 )
1453 .is_err());
1454 }
1455
1456 #[test]
1457 fn field_admin_carries_widget_options_and_visibility() {
1458 let resource = parse_resource(
1459 r#"
1460[resource]
1461name = "product"
1462
1463[fields.status]
1464type = "string"
1465
1466[fields.status.admin]
1467label = "Lifecycle"
1468widget = "select"
1469options = ["draft", "active|Live"]
1470readonly = true
1471"#,
1472 );
1473 let status = &resource.fields["status"];
1474 assert_eq!(status.admin.label.as_deref(), Some("Lifecycle"));
1475 assert_eq!(status.admin.widget, Widget::Select);
1476 assert_eq!(status.admin.widget.as_str(), "select");
1477 assert_eq!(status.admin.options, vec!["draft", "active|Live"]);
1478 assert!(status.admin.readonly);
1479 assert!(status.admin.visible);
1481 assert_eq!(resource.fields["status"].admin.help, None);
1482 }
1483
1484 #[test]
1485 fn org_column_reflects_resource_scope() {
1486 let org_scoped = parse_resource(
1487 r#"
1488[resource]
1489name = "post"
1490
1491[fields.title]
1492type = "string"
1493"#,
1494 );
1495 let global = parse_resource(
1496 r#"
1497[resource]
1498name = "plan"
1499scope = "global"
1500
1501[fields.name]
1502type = "string"
1503"#,
1504 );
1505 let organization = parse_resource(crate::defaults::ORGANIZATION_TOML);
1506
1507 assert_eq!(org_scoped.org_column(), Some("organization_id"));
1508 assert_eq!(global.org_column(), None);
1509 assert_eq!(organization.org_column(), Some("id"));
1510 }
1511}