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 for field in &self.admin.search_fields {
174 let Some(declared_field) = self.fields.get(field) else {
175 return Err(crate::Error::Schema {
176 resource: self.meta.name.clone(),
177 message: format!("[admin] search_fields names unknown field `{field}`"),
178 });
179 };
180 if !matches!(declared_field.ty, FieldType::String | FieldType::Text) {
181 return Err(crate::Error::Schema {
182 resource: self.meta.name.clone(),
183 message: format!(
184 "[admin] search_fields names `{field}`, which is not a text field and cannot be searched"
185 ),
186 });
187 }
188 if declared_field.hidden {
189 return Err(crate::Error::Schema {
190 resource: self.meta.name.clone(),
191 message: format!(
192 "[admin] search_fields names `{field}`, which is hidden — searching it would answer questions its own responses refuse"
193 ),
194 });
195 }
196 }
197 Ok(())
198 }
199
200 pub fn admin_label(&self) -> String {
202 self.admin
203 .label
204 .clone()
205 .unwrap_or_else(|| titleize(&self.meta.name))
206 }
207
208 pub fn admin_plural(&self) -> String {
210 self.admin
211 .plural
212 .clone()
213 .unwrap_or_else(|| pluralize(&self.admin_label()))
214 }
215
216 pub fn admin_display_field(&self) -> Option<String> {
222 if let Some(declared) = &self.admin.display_field {
223 if self.fields.contains_key(declared) {
224 return Some(declared.clone());
225 }
226 }
227 const PREFERRED: [&str; 7] = ["name", "title", "label", "slug", "code", "number", "email"];
228 for candidate in PREFERRED {
229 if let Some(field) = self.fields.get(candidate) {
230 if !field.hidden && matches!(field.ty, FieldType::String | FieldType::Text) {
231 return Some(candidate.to_string());
232 }
233 }
234 }
235 self.fields
236 .iter()
237 .find(|(_, field)| !field.hidden && field.ty == FieldType::String)
238 .map(|(name, _)| name.clone())
239 }
240
241 pub fn admin_search_field(&self) -> Option<String> {
247 let candidate = match &self.admin.search_field {
248 Some(declared) if self.fields.contains_key(declared) => Some(declared.clone()),
249 Some(_) | None => self.admin_display_field(),
250 };
251 candidate.filter(|name| {
252 self.fields
253 .get(name)
254 .is_some_and(|field| matches!(field.ty, FieldType::String | FieldType::Text))
255 })
256 }
257
258 pub fn admin_search_fields(&self) -> Vec<String> {
265 let declared: Vec<String> = self
266 .admin
267 .search_fields
268 .iter()
269 .filter(|name| {
270 self.fields.get(*name).is_some_and(|field| {
271 !field.hidden && matches!(field.ty, FieldType::String | FieldType::Text)
272 })
273 })
274 .cloned()
275 .collect();
276 if !declared.is_empty() {
277 return declared;
278 }
279 self.admin_search_field().into_iter().collect()
280 }
281
282 pub fn admin_columns(&self) -> Vec<String> {
288 let declared: Vec<String> = self
289 .admin
290 .columns
291 .iter()
292 .filter(|name| self.fields.contains_key(*name))
293 .cloned()
294 .collect();
295 if !declared.is_empty() {
296 return declared;
297 }
298
299 let display = self.admin_display_field();
300 let mut columns: Vec<String> = display.iter().cloned().collect();
301 for (name, field) in &self.fields {
302 if columns.len() >= 5 {
303 break;
304 }
305 let skip = field.hidden
306 || !field.admin.visible
307 || Some(name) == display.as_ref()
308 || name == "organization_id"
309 || matches!(field.ty, FieldType::Json | FieldType::Text);
310 if !skip {
311 columns.push(name.clone());
312 }
313 }
314 columns
315 }
316
317 pub fn references(&self) -> Vec<Reference> {
319 self.fields
320 .iter()
321 .filter_map(|(name, field)| {
322 if field.ty != FieldType::Reference {
323 return None;
324 }
325 let target = field.references.clone()?;
326 Some(Reference {
327 field: name.clone(),
328 target,
329 relation: relation_name(name).to_string(),
330 on_delete: field.on_delete.unwrap_or(OnDelete::Restrict),
331 required: field.required,
332 })
333 })
334 .collect()
335 }
336
337 pub fn reference_by_relation(&self, relation: &str) -> Option<Reference> {
339 self.references()
340 .into_iter()
341 .find(|r| r.relation == relation)
342 }
343
344 pub fn is_org_scoped(&self) -> bool {
346 self.meta.scope == Scope::Organization
347 }
348
349 pub fn org_column(&self) -> Option<&'static str> {
353 if self.is_org_scoped() {
354 Some("organization_id")
355 } else if self.meta.name == "organization" {
356 Some("id")
357 } else {
358 None
359 }
360 }
361
362 pub fn load(path: &Path) -> crate::Result<Self> {
364 let text = std::fs::read_to_string(path).map_err(|e| crate::Error::Io {
365 path: path.to_path_buf(),
366 source: e,
367 })?;
368 let source = path.file_name().unwrap_or_default().to_string_lossy();
371 let resource: Resource =
372 crate::env::parse_toml(&text, &source).map_err(|e| crate::Error::Toml {
373 path: path.to_path_buf(),
374 source: e,
375 })?;
376 resource.validate()?;
377 Ok(resource)
378 }
379}
380
381#[derive(Debug, Clone, Default, Deserialize)]
389#[serde(default, deny_unknown_fields)]
390pub struct ResourceAdmin {
391 pub visible: Option<bool>,
394 pub roles: Vec<String>,
397 pub label: Option<String>,
399 pub plural: Option<String>,
401 pub group: Option<String>,
403 pub display_field: Option<String>,
406 pub columns: Vec<String>,
408 pub search_field: Option<String>,
410 pub search_fields: Vec<String>,
414 pub order: i64,
416}
417
418impl ResourceAdmin {
419 pub fn is_visible(&self, resource_name: &str) -> bool {
427 self.visible.unwrap_or(!is_auth_resource(resource_name))
428 }
429}
430
431pub fn is_auth_resource(name: &str) -> bool {
434 matches!(
435 name,
436 "user"
437 | "organization"
438 | "membership"
439 | "membership_role"
440 | "api_key"
441 | "oauth_connection"
442 | "invitation"
443 | "auth_token"
444 )
445}
446
447#[derive(Debug, Clone, Deserialize)]
449#[serde(default, deny_unknown_fields)]
450pub struct FieldAdmin {
451 pub visible: bool,
454 pub readonly: bool,
456 pub label: Option<String>,
458 pub help: Option<String>,
460 pub widget: Widget,
462 pub options: Vec<String>,
465 pub placeholder: Option<String>,
467 pub signup: Option<bool>,
476 pub format: ContentFormat,
480}
481
482impl Default for FieldAdmin {
483 fn default() -> Self {
484 FieldAdmin {
485 visible: true,
486 readonly: false,
487 label: None,
488 help: None,
489 widget: Widget::Auto,
490 options: Vec::new(),
491 placeholder: None,
492 format: ContentFormat::Plain,
493 signup: None,
494 }
495 }
496}
497
498impl FieldAdmin {
499 pub fn in_signup(&self, field: &Field) -> bool {
506 self.signup.unwrap_or(field.required)
507 }
508}
509
510#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
515#[serde(rename_all = "snake_case")]
516pub enum ContentFormat {
517 #[default]
519 Plain,
520 Markdown,
521 Html,
522}
523
524impl ContentFormat {
525 pub fn as_str(self) -> &'static str {
526 match self {
527 ContentFormat::Plain => "plain",
528 ContentFormat::Markdown => "markdown",
529 ContentFormat::Html => "html",
530 }
531 }
532}
533
534#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
536#[serde(rename_all = "snake_case")]
537pub enum Widget {
538 Auto,
540 Text,
541 Textarea,
542 Select,
543 Email,
544 Url,
545 Password,
546 Color,
547 Date,
548 DateTime,
549 Json,
550 Switch,
551}
552
553impl Widget {
554 pub fn as_str(self) -> &'static str {
555 match self {
556 Widget::Auto => "auto",
557 Widget::Text => "text",
558 Widget::Textarea => "textarea",
559 Widget::Select => "select",
560 Widget::Email => "email",
561 Widget::Url => "url",
562 Widget::Password => "password",
563 Widget::Color => "color",
564 Widget::Date => "date",
565 Widget::DateTime => "date_time",
566 Widget::Json => "json",
567 Widget::Switch => "switch",
568 }
569 }
570}
571
572#[derive(Debug, Clone, Deserialize)]
574pub struct Field {
575 #[serde(rename = "type")]
576 pub ty: FieldType,
577 #[serde(default)]
579 pub references: Option<String>,
580 #[serde(default)]
581 pub required: bool,
582 #[serde(default)]
583 pub unique: bool,
584 #[serde(default)]
586 pub hidden: bool,
587 #[serde(default)]
589 pub default: Option<serde_json::Value>,
590 pub max_length: Option<u32>,
591 #[serde(default)]
594 pub on_delete: Option<OnDelete>,
595 #[serde(default)]
597 pub admin: FieldAdmin,
598}
599
600#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
602#[serde(rename_all = "snake_case")]
603pub enum FieldType {
604 String,
605 Text,
606 Integer,
607 BigInt,
608 Float,
609 Boolean,
610 Uuid,
611 Timestamp,
612 Json,
613 Reference,
615}
616
617#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
619#[serde(rename_all = "snake_case")]
620pub enum OnDelete {
621 Restrict,
623 SetNull,
625 Cascade,
627 NoAction,
629}
630
631impl OnDelete {
632 pub fn to_sql(self) -> &'static str {
633 match self {
634 OnDelete::Restrict => "RESTRICT",
635 OnDelete::SetNull => "SET NULL",
636 OnDelete::Cascade => "CASCADE",
637 OnDelete::NoAction => "NO ACTION",
638 }
639 }
640}
641
642#[derive(Debug, Clone)]
645pub struct Reference {
646 pub field: String,
647 pub target: String,
648 pub relation: String,
649 pub on_delete: OnDelete,
650 pub required: bool,
651}
652
653pub fn relation_name(field: &str) -> &str {
656 field.strip_suffix("_id").unwrap_or(field)
657}
658
659pub fn titleize(name: &str) -> String {
662 let spaced = name.trim_end_matches("_id").replace('_', " ");
663 let mut chars = spaced.chars();
664 match chars.next() {
665 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
666 None => spaced,
667 }
668}
669
670pub fn pluralize(label: &str) -> String {
673 let lower = label.to_lowercase();
674 if lower.ends_with('s')
675 || lower.ends_with("x")
676 || lower.ends_with("ch")
677 || lower.ends_with("sh")
678 {
679 format!("{label}es")
680 } else if lower.ends_with('y')
681 && !lower.ends_with("ay")
682 && !lower.ends_with("ey")
683 && !lower.ends_with("oy")
684 && !lower.ends_with("uy")
685 {
686 format!("{}ies", &label[..label.len() - 1])
687 } else {
688 format!("{label}s")
689 }
690}
691
692#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
695#[serde(rename_all = "snake_case")]
696pub enum Scope {
697 Organization,
700 Global,
702}
703
704#[derive(Debug, Clone, PartialEq, Eq)]
711pub enum Access {
712 Public,
715 Authenticated,
717 Member,
719 Role(String),
721 Owner,
723 Private,
725}
726
727impl Access {
728 pub fn parse(s: &str) -> Access {
730 match s {
731 "public" => Access::Public,
732 "authenticated" => Access::Authenticated,
733 "member" => Access::Member,
734 "owner" => Access::Owner,
735 "private" => Access::Private,
736 other => other
737 .strip_prefix("role:")
738 .map(|role| Access::Role(role.to_string()))
739 .unwrap_or(Access::Private),
740 }
741 }
742
743 pub fn as_string(&self) -> String {
745 match self {
746 Access::Public => "public".to_string(),
747 Access::Authenticated => "authenticated".to_string(),
748 Access::Member => "member".to_string(),
749 Access::Role(role) => format!("role:{role}"),
750 Access::Owner => "owner".to_string(),
751 Access::Private => "private".to_string(),
752 }
753 }
754}
755
756#[derive(Debug, Clone, Deserialize)]
758#[serde(from = "PermissionsRaw")]
759pub struct Permissions {
760 pub list: Access,
761 pub read: Access,
762 pub create: Access,
763 pub update: Access,
764 pub delete: Access,
765}
766
767impl Default for Permissions {
768 fn default() -> Self {
769 Permissions {
773 list: Access::Member,
774 read: Access::Member,
775 create: Access::Member,
776 update: Access::Member,
777 delete: Access::Member,
778 }
779 }
780}
781
782#[derive(Deserialize)]
783struct PermissionsRaw {
784 list: Option<String>,
785 read: Option<String>,
786 create: Option<String>,
787 update: Option<String>,
788 delete: Option<String>,
789}
790
791impl From<PermissionsRaw> for Permissions {
792 fn from(r: PermissionsRaw) -> Self {
793 let d = Permissions::default();
794 Permissions {
795 list: r.list.map(|s| Access::parse(&s)).unwrap_or(d.list),
796 read: r.read.map(|s| Access::parse(&s)).unwrap_or(d.read),
797 create: r.create.map(|s| Access::parse(&s)).unwrap_or(d.create),
798 update: r.update.map(|s| Access::parse(&s)).unwrap_or(d.update),
799 delete: r.delete.map(|s| Access::parse(&s)).unwrap_or(d.delete),
800 }
801 }
802}
803
804#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
811pub enum HookEvent {
812 BeforeList,
813 AfterList,
814 BeforeRead,
815 AfterRead,
816 BeforeCreate,
817 AfterCreate,
818 BeforeUpdate,
819 AfterUpdate,
820 BeforeDelete,
821 AfterDelete,
822}
823
824impl HookEvent {
825 pub const ALL: [HookEvent; 10] = [
827 HookEvent::BeforeList,
828 HookEvent::AfterList,
829 HookEvent::BeforeRead,
830 HookEvent::AfterRead,
831 HookEvent::BeforeCreate,
832 HookEvent::AfterCreate,
833 HookEvent::BeforeUpdate,
834 HookEvent::AfterUpdate,
835 HookEvent::BeforeDelete,
836 HookEvent::AfterDelete,
837 ];
838
839 pub fn as_str(self) -> &'static str {
841 match self {
842 HookEvent::BeforeList => "before_list",
843 HookEvent::AfterList => "after_list",
844 HookEvent::BeforeRead => "before_read",
845 HookEvent::AfterRead => "after_read",
846 HookEvent::BeforeCreate => "before_create",
847 HookEvent::AfterCreate => "after_create",
848 HookEvent::BeforeUpdate => "before_update",
849 HookEvent::AfterUpdate => "after_update",
850 HookEvent::BeforeDelete => "before_delete",
851 HookEvent::AfterDelete => "after_delete",
852 }
853 }
854
855 pub fn action(self) -> &'static str {
857 match self {
858 HookEvent::BeforeList | HookEvent::AfterList => "list",
859 HookEvent::BeforeRead | HookEvent::AfterRead => "read",
860 HookEvent::BeforeCreate | HookEvent::AfterCreate => "create",
861 HookEvent::BeforeUpdate | HookEvent::AfterUpdate => "update",
862 HookEvent::BeforeDelete | HookEvent::AfterDelete => "delete",
863 }
864 }
865
866 pub fn phase(self) -> &'static str {
868 if self.is_before() {
869 "before"
870 } else {
871 "after"
872 }
873 }
874
875 pub fn is_before(self) -> bool {
877 matches!(
878 self,
879 HookEvent::BeforeList
880 | HookEvent::BeforeRead
881 | HookEvent::BeforeCreate
882 | HookEvent::BeforeUpdate
883 | HookEvent::BeforeDelete
884 )
885 }
886}
887
888#[derive(Debug, Clone, Default, Deserialize)]
897#[serde(default, deny_unknown_fields)]
898pub struct Hooks {
899 pub before_list: Option<String>,
900 pub after_list: Option<String>,
901 pub before_read: Option<String>,
902 pub after_read: Option<String>,
903 pub before_create: Option<String>,
904 pub after_create: Option<String>,
905 pub before_update: Option<String>,
906 pub after_update: Option<String>,
907 pub before_delete: Option<String>,
908 pub after_delete: Option<String>,
909 pub before_register: Option<String>,
910 pub after_register: Option<String>,
911 pub before_login: Option<String>,
912 pub after_login: Option<String>,
913 pub before_api_key: Option<String>,
914 pub after_api_key: Option<String>,
915}
916
917impl Hooks {
918 pub fn get(&self, event: HookEvent) -> Option<&str> {
920 let slot = match event {
921 HookEvent::BeforeList => &self.before_list,
922 HookEvent::AfterList => &self.after_list,
923 HookEvent::BeforeRead => &self.before_read,
924 HookEvent::AfterRead => &self.after_read,
925 HookEvent::BeforeCreate => &self.before_create,
926 HookEvent::AfterCreate => &self.after_create,
927 HookEvent::BeforeUpdate => &self.before_update,
928 HookEvent::AfterUpdate => &self.after_update,
929 HookEvent::BeforeDelete => &self.before_delete,
930 HookEvent::AfterDelete => &self.after_delete,
931 };
932 slot.as_deref()
933 }
934
935 pub fn iter(&self) -> impl Iterator<Item = (HookEvent, &str)> {
937 HookEvent::ALL
938 .into_iter()
939 .filter_map(|event| self.get(event).map(|name| (event, name)))
940 }
941
942 pub fn is_empty(&self) -> bool {
944 self.iter().next().is_none() && self.auth_iter().next().is_none()
945 }
946}
947
948#[derive(Debug, Clone, Deserialize)]
950#[serde(default)]
951pub struct AuthSpec {
952 pub identity_field: String,
954 pub password_field: String,
956 pub oauth_providers: Vec<String>,
958}
959
960impl Default for AuthSpec {
961 fn default() -> Self {
962 AuthSpec {
963 identity_field: "email".to_string(),
964 password_field: "password_hash".to_string(),
965 oauth_providers: Vec::new(),
966 }
967 }
968}
969
970#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
978pub enum AuthEvent {
979 BeforeRegister,
980 AfterRegister,
981 BeforeLogin,
982 AfterLogin,
983 BeforeApiKey,
984 AfterApiKey,
985}
986
987impl AuthEvent {
988 pub const ALL: [AuthEvent; 6] = [
990 AuthEvent::BeforeRegister,
991 AuthEvent::AfterRegister,
992 AuthEvent::BeforeLogin,
993 AuthEvent::AfterLogin,
994 AuthEvent::BeforeApiKey,
995 AuthEvent::AfterApiKey,
996 ];
997
998 pub fn as_str(self) -> &'static str {
1000 match self {
1001 AuthEvent::BeforeRegister => "before_register",
1002 AuthEvent::AfterRegister => "after_register",
1003 AuthEvent::BeforeLogin => "before_login",
1004 AuthEvent::AfterLogin => "after_login",
1005 AuthEvent::BeforeApiKey => "before_api_key",
1006 AuthEvent::AfterApiKey => "after_api_key",
1007 }
1008 }
1009
1010 pub fn action(self) -> &'static str {
1012 match self {
1013 AuthEvent::BeforeRegister | AuthEvent::AfterRegister => "register",
1014 AuthEvent::BeforeLogin | AuthEvent::AfterLogin => "login",
1015 AuthEvent::BeforeApiKey | AuthEvent::AfterApiKey => "api_key",
1016 }
1017 }
1018
1019 pub fn phase(self) -> &'static str {
1021 if self.is_before() {
1022 "before"
1023 } else {
1024 "after"
1025 }
1026 }
1027
1028 pub fn is_before(self) -> bool {
1030 matches!(
1031 self,
1032 AuthEvent::BeforeRegister | AuthEvent::BeforeLogin | AuthEvent::BeforeApiKey
1033 )
1034 }
1035}
1036
1037impl Hooks {
1038 pub fn get_auth(&self, event: AuthEvent) -> Option<&str> {
1040 let slot = match event {
1041 AuthEvent::BeforeRegister => &self.before_register,
1042 AuthEvent::AfterRegister => &self.after_register,
1043 AuthEvent::BeforeLogin => &self.before_login,
1044 AuthEvent::AfterLogin => &self.after_login,
1045 AuthEvent::BeforeApiKey => &self.before_api_key,
1046 AuthEvent::AfterApiKey => &self.after_api_key,
1047 };
1048 slot.as_deref()
1049 }
1050
1051 pub fn auth_iter(&self) -> impl Iterator<Item = (AuthEvent, &str)> {
1053 AuthEvent::ALL
1054 .into_iter()
1055 .filter_map(|event| self.get_auth(event).map(|name| (event, name)))
1056 }
1057}
1058
1059#[cfg(test)]
1060mod tests {
1061 use super::*;
1062
1063 fn parse_resource(src: &str) -> Resource {
1064 let resource: Resource = toml::from_str(src).unwrap();
1065 resource.validate().unwrap();
1066 resource
1067 }
1068
1069 #[test]
1070 fn access_parser_and_permissions_defaults_match_org_membership_model() {
1071 assert_eq!(Access::parse("public"), Access::Public);
1072 assert_eq!(Access::parse("authenticated"), Access::Authenticated);
1073 assert_eq!(Access::parse("member"), Access::Member);
1074 assert_eq!(Access::parse("owner"), Access::Owner);
1075 assert_eq!(Access::parse("role:admin"), Access::Role("admin".into()));
1076 assert_eq!(Access::parse("wat"), Access::Private);
1077
1078 let defaults = Permissions::default();
1079 assert_eq!(defaults.list, Access::Member);
1080 assert_eq!(defaults.read, Access::Member);
1081 assert_eq!(defaults.create, Access::Member);
1082 assert_eq!(defaults.update, Access::Member);
1083 assert_eq!(defaults.delete, Access::Member);
1084 }
1085
1086 #[test]
1087 fn validate_rejects_reserved_id_and_dangling_reference_definition() {
1088 let reserved_id: Resource = toml::from_str(
1089 r#"
1090[resource]
1091name = "bad"
1092
1093[fields.id]
1094type = "string"
1095"#,
1096 )
1097 .unwrap();
1098 assert!(reserved_id.validate().is_err());
1099
1100 let missing_target: Resource = toml::from_str(
1101 r#"
1102[resource]
1103name = "bad_ref"
1104
1105[fields.owner_id]
1106type = "reference"
1107"#,
1108 )
1109 .unwrap();
1110 assert!(missing_target.validate().is_err());
1111 }
1112
1113 #[test]
1114 fn content_format_is_only_allowed_on_text_fields() {
1115 let good: Resource = toml::from_str(
1116 r#"
1117[resource]
1118name = "article"
1119
1120[fields.body]
1121type = "text"
1122
1123[fields.body.admin]
1124format = "markdown"
1125"#,
1126 )
1127 .unwrap();
1128 good.validate().unwrap();
1129 assert_eq!(good.fields["body"].admin.format, ContentFormat::Markdown);
1130
1131 let bad: Resource = toml::from_str(
1132 r#"
1133[resource]
1134name = "article"
1135
1136[fields.published]
1137type = "boolean"
1138
1139[fields.published.admin]
1140format = "html"
1141"#,
1142 )
1143 .unwrap();
1144 assert!(bad.validate().is_err());
1145 }
1146
1147 #[test]
1148 fn references_derive_relation_names_and_default_on_delete() {
1149 let resource = parse_resource(
1150 r#"
1151[resource]
1152name = "comment"
1153
1154[fields.post_id]
1155type = "reference"
1156references = "post"
1157required = true
1158
1159[fields.author_id]
1160type = "reference"
1161references = "user"
1162on_delete = "cascade"
1163"#,
1164 );
1165
1166 assert_eq!(relation_name("owner_id"), "owner");
1167 assert_eq!(relation_name("slug"), "slug");
1168
1169 let refs = resource.references();
1170 assert_eq!(refs.len(), 2);
1171 let post = refs.iter().find(|rf| rf.field == "post_id").unwrap();
1172 assert_eq!(post.target, "post");
1173 assert_eq!(post.relation, "post");
1174 assert_eq!(post.on_delete, OnDelete::Restrict);
1175 assert!(post.required);
1176
1177 let author = resource.reference_by_relation("author").unwrap();
1178 assert_eq!(author.field, "author_id");
1179 assert_eq!(author.on_delete, OnDelete::Cascade);
1180 }
1181
1182 #[test]
1183 fn hooks_parse_per_event_and_iterate_in_lifecycle_order() {
1184 let resource = parse_resource(
1185 r#"
1186[resource]
1187name = "post"
1188
1189[fields.title]
1190type = "string"
1191
1192[hooks]
1193before_create = "validate_post"
1194after_create = "notify"
1195after_list = "redact"
1196"#,
1197 );
1198
1199 assert_eq!(
1200 resource.hook(HookEvent::BeforeCreate),
1201 Some("validate_post")
1202 );
1203 assert_eq!(resource.hook(HookEvent::AfterCreate), Some("notify"));
1204 assert_eq!(resource.hook(HookEvent::AfterList), Some("redact"));
1205 assert_eq!(resource.hook(HookEvent::BeforeUpdate), None);
1206 assert!(!resource.hooks.is_empty());
1207
1208 let declared: Vec<_> = resource.hooks.iter().collect();
1209 assert_eq!(
1210 declared,
1211 vec![
1212 (HookEvent::AfterList, "redact"),
1213 (HookEvent::BeforeCreate, "validate_post"),
1214 (HookEvent::AfterCreate, "notify"),
1215 ]
1216 );
1217 }
1218
1219 #[test]
1220 fn hook_events_expose_wire_names_actions_and_phases() {
1221 assert_eq!(HookEvent::BeforeCreate.as_str(), "before_create");
1222 assert_eq!(HookEvent::BeforeCreate.action(), "create");
1223 assert_eq!(HookEvent::BeforeCreate.phase(), "before");
1224 assert!(HookEvent::BeforeCreate.is_before());
1225
1226 assert_eq!(HookEvent::AfterList.as_str(), "after_list");
1227 assert_eq!(HookEvent::AfterList.action(), "list");
1228 assert_eq!(HookEvent::AfterList.phase(), "after");
1229 assert!(!HookEvent::AfterList.is_before());
1230
1231 for event in HookEvent::ALL {
1233 let resource = parse_resource(&format!(
1234 "[resource]\nname = \"post\"\n\n[hooks]\n{} = \"h\"\n",
1235 event.as_str()
1236 ));
1237 assert_eq!(resource.hook(event), Some("h"), "{}", event.as_str());
1238 assert_eq!(resource.hooks.iter().count(), 1);
1239 }
1240 }
1241
1242 #[test]
1243 fn hooks_reject_typos_and_empty_function_names() {
1244 let typo = toml::from_str::<Resource>(
1245 r#"
1246[resource]
1247name = "post"
1248
1249[hooks]
1250befor_create = "oops"
1251"#,
1252 );
1253 assert!(typo.is_err(), "unknown hook keys must not be ignored");
1254
1255 let empty: Resource = toml::from_str(
1256 r#"
1257[resource]
1258name = "post"
1259
1260[hooks]
1261after_delete = " "
1262"#,
1263 )
1264 .unwrap();
1265 assert!(empty.validate().is_err());
1266 }
1267
1268 #[test]
1269 fn auth_events_expose_wire_names_actions_and_phases() {
1270 assert_eq!(AuthEvent::BeforeLogin.as_str(), "before_login");
1271 assert_eq!(AuthEvent::BeforeLogin.action(), "login");
1272 assert_eq!(AuthEvent::BeforeLogin.phase(), "before");
1273 assert!(AuthEvent::BeforeLogin.is_before());
1274
1275 assert_eq!(AuthEvent::AfterApiKey.action(), "api_key");
1276 assert_eq!(AuthEvent::AfterApiKey.phase(), "after");
1277 assert!(!AuthEvent::AfterApiKey.is_before());
1278
1279 for event in AuthEvent::ALL {
1282 let resource = parse_resource(&format!(
1283 "[resource]\nname = \"user\"\n\n[hooks]\nafter_create = \"c\"\n{} = \"h\"\n",
1284 event.as_str()
1285 ));
1286 assert_eq!(resource.auth_hook(event), Some("h"), "{}", event.as_str());
1287 assert_eq!(resource.hook(HookEvent::AfterCreate), Some("c"));
1288 assert_eq!(resource.hooks.auth_iter().count(), 1);
1289 assert_eq!(resource.hooks.iter().count(), 1);
1292 }
1293 }
1294
1295 #[test]
1296 fn auth_hooks_are_absent_by_default_and_reject_typos_and_empty_names() {
1297 let plain =
1298 parse_resource("[resource]\nname = \"user\"\n\n[hooks]\nafter_create = \"c\"\n");
1299 assert_eq!(plain.auth_hook(AuthEvent::BeforeLogin), None);
1300 assert!(parse_resource("[resource]\nname = \"user\"\n")
1301 .hooks
1302 .is_empty());
1303
1304 let typo = toml::from_str::<Resource>(
1305 "[resource]\nname = \"user\"\n\n[hooks]\nbefore_signin = \"oops\"\n",
1306 );
1307 assert!(typo.is_err(), "unknown auth hook keys must not be ignored");
1308
1309 let empty: Resource =
1310 toml::from_str("[resource]\nname = \"user\"\n\n[hooks]\nafter_login = \" \"\n")
1311 .unwrap();
1312 assert!(empty.validate().is_err());
1313 }
1314
1315 #[test]
1316 fn auth_hooks_are_rejected_on_any_resource_but_user() {
1317 let stray: Resource =
1320 toml::from_str("[resource]\nname = \"post\"\n\n[hooks]\nbefore_login = \"h\"\n")
1321 .unwrap();
1322 let message = stray.validate().unwrap_err().to_string();
1323 assert!(message.contains("before_login"), "{message}");
1324 assert!(message.contains("user"), "{message}");
1325 }
1326
1327 #[test]
1328 fn resources_have_no_hooks_by_default() {
1329 let resource = parse_resource("[resource]\nname = \"post\"\n");
1330 assert!(resource.hooks.is_empty());
1331 assert!(HookEvent::ALL
1332 .into_iter()
1333 .all(|event| resource.hook(event).is_none()));
1334 }
1335
1336 #[test]
1337 fn admin_visibility_defaults_hide_auth_resources_but_nothing_else() {
1338 let post = parse_resource("[resource]\nname = \"post\"\n");
1339 assert!(post.admin.is_visible("post"));
1340
1341 let user = parse_resource(crate::defaults::USER_TOML);
1342 assert!(!user.admin.is_visible("user"));
1343 assert!(!parse_resource(crate::defaults::MEMBERSHIP_TOML)
1344 .admin
1345 .is_visible("membership"));
1346
1347 let replaced = parse_resource(
1349 "[resource]\nname = \"user\"\nscope = \"global\"\n\n[fields.email]\ntype = \"string\"\n",
1350 );
1351 assert!(!replaced.admin.is_visible("user"));
1352
1353 let opted_in = parse_resource(
1355 "[resource]\nname = \"user\"\nscope = \"global\"\n\n[admin]\nvisible = true\n",
1356 );
1357 assert!(opted_in.admin.is_visible("user"));
1358 }
1359
1360 #[test]
1361 fn admin_labels_are_inferred_and_overridable() {
1362 let inferred = parse_resource("[resource]\nname = \"purchase_order\"\n");
1363 assert_eq!(inferred.admin_label(), "Purchase order");
1364 assert_eq!(inferred.admin_plural(), "Purchase orders");
1365
1366 let overridden = parse_resource(
1367 "[resource]\nname = \"person\"\n\n[admin]\nlabel = \"Person\"\nplural = \"People\"\n",
1368 );
1369 assert_eq!(overridden.admin_plural(), "People");
1370
1371 assert_eq!(titleize("owner_id"), "Owner");
1372 assert_eq!(titleize("total_cents"), "Total cents");
1373 assert_eq!(pluralize("Category"), "Categories");
1374 assert_eq!(pluralize("Address"), "Addresses");
1375 assert_eq!(pluralize("Day"), "Days");
1376 assert_eq!(pluralize("Product"), "Products");
1377 }
1378
1379 #[test]
1380 fn display_field_prefers_conventional_names_then_any_string() {
1381 let conventional = parse_resource(
1382 r#"
1383[resource]
1384name = "product"
1385
1386[fields.sku]
1387type = "string"
1388
1389[fields.name]
1390type = "string"
1391"#,
1392 );
1393 assert_eq!(conventional.admin_display_field().as_deref(), Some("name"));
1394
1395 let only_odd_names =
1396 parse_resource("[resource]\nname = \"blob\"\n\n[fields.zzz]\ntype = \"string\"\n");
1397 assert_eq!(only_odd_names.admin_display_field().as_deref(), Some("zzz"));
1398
1399 let nothing_stringy =
1400 parse_resource("[resource]\nname = \"tick\"\n\n[fields.count]\ntype = \"integer\"\n");
1401 assert_eq!(nothing_stringy.admin_display_field(), None);
1402
1403 let declared = parse_resource(
1405 r#"
1406[resource]
1407name = "product"
1408
1409[admin]
1410display_field = "sku"
1411
1412[fields.sku]
1413type = "string"
1414
1415[fields.name]
1416type = "string"
1417"#,
1418 );
1419 assert_eq!(declared.admin_display_field().as_deref(), Some("sku"));
1420 assert_eq!(declared.admin_search_field().as_deref(), Some("sku"));
1422 }
1423
1424 #[test]
1425 fn inferred_columns_skip_blobs_and_dashboard_hidden_fields() {
1426 let resource = parse_resource(
1427 r#"
1428[resource]
1429name = "product"
1430
1431[fields.name]
1432type = "string"
1433
1434[fields.status]
1435type = "string"
1436
1437[fields.description]
1438type = "text"
1439
1440[fields.attributes]
1441type = "json"
1442
1443[fields.secret_ratio]
1444type = "float"
1445
1446[fields.secret_ratio.admin]
1447visible = false
1448"#,
1449 );
1450 assert_eq!(resource.admin_columns(), vec!["name", "status"]);
1451
1452 let declared = parse_resource(
1453 r#"
1454[resource]
1455name = "product"
1456
1457[admin]
1458columns = ["status", "name"]
1459
1460[fields.name]
1461type = "string"
1462
1463[fields.status]
1464type = "string"
1465"#,
1466 );
1467 assert_eq!(declared.admin_columns(), vec!["status", "name"]);
1468 }
1469
1470 #[test]
1471 fn admin_section_rejects_columns_naming_fields_that_do_not_exist() {
1472 let bad_column: Resource = toml::from_str(
1473 "[resource]\nname = \"post\"\n\n[admin]\ncolumns = [\"nope\"]\n\n[fields.title]\ntype = \"string\"\n",
1474 )
1475 .unwrap();
1476 assert!(bad_column.validate().is_err());
1477
1478 let bad_display: Resource = toml::from_str(
1479 "[resource]\nname = \"post\"\n\n[admin]\ndisplay_field = \"nope\"\n\n[fields.title]\ntype = \"string\"\n",
1480 )
1481 .unwrap();
1482 assert!(bad_display.validate().is_err());
1483
1484 let unsearchable: Resource = toml::from_str(
1487 "[resource]\nname = \"tick\"\n\n[admin]\nsearch_field = \"count\"\n\n[fields.count]\ntype = \"integer\"\n",
1488 )
1489 .unwrap();
1490 assert!(unsearchable.validate().is_err());
1491
1492 let named_by_a_number = parse_resource(
1495 "[resource]\nname = \"tick\"\n\n[admin]\ndisplay_field = \"count\"\n\n[fields.count]\ntype = \"integer\"\n",
1496 );
1497 assert_eq!(
1498 named_by_a_number.admin_display_field().as_deref(),
1499 Some("count")
1500 );
1501 assert_eq!(named_by_a_number.admin_search_field(), None);
1502
1503 let multi = parse_resource(
1506 "[resource]\nname = \"post\"\n\n[admin]\nsearch_fields = [\"title\", \"body\"]\n\n[fields.title]\ntype = \"string\"\n\n[fields.body]\ntype = \"text\"\n",
1507 );
1508 assert_eq!(multi.admin_search_fields(), vec!["title", "body"]);
1509
1510 let single =
1513 parse_resource("[resource]\nname = \"post\"\n\n[fields.title]\ntype = \"string\"\n");
1514 assert_eq!(single.admin_search_fields(), vec!["title"]);
1515
1516 for bad in [
1517 "[resource]\nname = \"post\"\n\n[admin]\nsearch_fields = [\"nope\"]\n\n[fields.title]\ntype = \"string\"\n",
1518 "[resource]\nname = \"post\"\n\n[admin]\nsearch_fields = [\"views\"]\n\n[fields.views]\ntype = \"integer\"\n",
1519 "[resource]\nname = \"post\"\n\n[admin]\nsearch_fields = [\"secret\"]\n\n[fields.secret]\ntype = \"string\"\nhidden = true\n",
1520 ] {
1521 let resource: Resource = toml::from_str(bad).unwrap();
1522 assert!(resource.validate().is_err(), "{bad} should not load");
1523 }
1524
1525 assert!(toml::from_str::<Resource>(
1527 "[resource]\nname = \"post\"\n\n[admin]\nvisibel = true\n"
1528 )
1529 .is_err());
1530 }
1531
1532 #[test]
1533 fn field_admin_carries_widget_options_and_visibility() {
1534 let resource = parse_resource(
1535 r#"
1536[resource]
1537name = "product"
1538
1539[fields.status]
1540type = "string"
1541
1542[fields.status.admin]
1543label = "Lifecycle"
1544widget = "select"
1545options = ["draft", "active|Live"]
1546readonly = true
1547"#,
1548 );
1549 let status = &resource.fields["status"];
1550 assert_eq!(status.admin.label.as_deref(), Some("Lifecycle"));
1551 assert_eq!(status.admin.widget, Widget::Select);
1552 assert_eq!(status.admin.widget.as_str(), "select");
1553 assert_eq!(status.admin.options, vec!["draft", "active|Live"]);
1554 assert!(status.admin.readonly);
1555 assert!(status.admin.visible);
1557 assert_eq!(resource.fields["status"].admin.help, None);
1558 }
1559
1560 #[test]
1561 fn org_column_reflects_resource_scope() {
1562 let org_scoped = parse_resource(
1563 r#"
1564[resource]
1565name = "post"
1566
1567[fields.title]
1568type = "string"
1569"#,
1570 );
1571 let global = parse_resource(
1572 r#"
1573[resource]
1574name = "plan"
1575scope = "global"
1576
1577[fields.name]
1578type = "string"
1579"#,
1580 );
1581 let organization = parse_resource(crate::defaults::ORGANIZATION_TOML);
1582
1583 assert_eq!(org_scoped.org_column(), Some("organization_id"));
1584 assert_eq!(global.org_column(), None);
1585 assert_eq!(organization.org_column(), Some("id"));
1586 }
1587}