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 publish: Publishes,
28 #[serde(default)]
30 pub auth: Option<AuthSpec>,
31 #[serde(default)]
33 pub admin: ResourceAdmin,
34}
35
36#[derive(Debug, Clone, Deserialize)]
37pub struct ResourceMeta {
38 pub name: String,
39 pub table: Option<String>,
41 #[serde(default = "yes")]
43 pub timestamps: bool,
44 #[serde(default = "default_owner_field")]
46 pub owner_field: String,
47 #[serde(default = "default_scope")]
50 pub scope: Scope,
51}
52
53fn yes() -> bool {
54 true
55}
56fn default_owner_field() -> String {
57 "owner_id".to_string()
58}
59fn default_scope() -> Scope {
60 Scope::Organization
61}
62
63impl Resource {
64 pub fn table_name(&self) -> String {
66 self.meta
67 .table
68 .clone()
69 .unwrap_or_else(|| format!("apiplant_{}", self.meta.name))
70 }
71
72 pub fn hook(&self, event: HookEvent) -> Option<&str> {
74 self.hooks.get(event)
75 }
76
77 pub fn auth_hook(&self, event: AuthEvent) -> Option<&str> {
80 self.hooks.get_auth(event)
81 }
82
83 pub fn validate(&self) -> crate::Result<()> {
85 for (event, function) in self.hooks.iter() {
86 if function.trim().is_empty() {
87 return Err(crate::Error::Schema {
88 resource: self.meta.name.clone(),
89 message: format!("hook `{}` names an empty function", event.as_str()),
90 });
91 }
92 }
93 for (event, function) in self.hooks.auth_iter() {
94 if function.trim().is_empty() {
95 return Err(crate::Error::Schema {
96 resource: self.meta.name.clone(),
97 message: format!("hook `{}` names an empty function", event.as_str()),
98 });
99 }
100 if self.meta.name != "user" {
103 return Err(crate::Error::Schema {
104 resource: self.meta.name.clone(),
105 message: format!(
106 "hook `{}` only exists on the `user` resource, which owns the auth endpoints",
107 event.as_str()
108 ),
109 });
110 }
111 }
112 for (event, topic) in self.publish.iter() {
116 if !crate::QueuesConfig::valid_topic(topic) {
117 return Err(crate::Error::Schema {
118 resource: self.meta.name.clone(),
119 message: format!(
120 "`publish.{}` names `{topic}`, which is not a topic: use letters, \
121 digits, `.`, `_`, `-` or `:`",
122 event.as_str()
123 ),
124 });
125 }
126 }
127 for (fname, field) in &self.fields {
128 if fname == "id" {
129 return Err(crate::Error::Schema {
130 resource: self.meta.name.clone(),
131 message: "`id` is reserved and added automatically".into(),
132 });
133 }
134 if field.ty == FieldType::Reference && field.references.is_none() {
135 return Err(crate::Error::Schema {
136 resource: self.meta.name.clone(),
137 message: format!("field `{fname}` is a reference without `references`"),
138 });
139 }
140 if field.admin.format != ContentFormat::Plain
141 && !matches!(field.ty, FieldType::Text | FieldType::String)
142 {
143 return Err(crate::Error::Schema {
144 resource: self.meta.name.clone(),
145 message: format!(
146 "field `{fname}` sets [admin] format = \"{}\" but is not a text field",
147 field.admin.format.as_str()
148 ),
149 });
150 }
151 }
152 for column in &self.admin.columns {
156 if !self.fields.contains_key(column) && column != "id" {
157 return Err(crate::Error::Schema {
158 resource: self.meta.name.clone(),
159 message: format!("[admin] columns names unknown field `{column}`"),
160 });
161 }
162 }
163 for (key, declared) in [
164 ("display_field", &self.admin.display_field),
165 ("search_field", &self.admin.search_field),
166 ] {
167 if let Some(field) = declared {
168 let Some(declared_field) = self.fields.get(field) else {
169 return Err(crate::Error::Schema {
170 resource: self.meta.name.clone(),
171 message: format!("[admin] {key} names unknown field `{field}`"),
172 });
173 };
174 if key == "search_field"
178 && !matches!(declared_field.ty, FieldType::String | FieldType::Text)
179 {
180 return Err(crate::Error::Schema {
181 resource: self.meta.name.clone(),
182 message: format!(
183 "[admin] search_field names `{field}`, which is not a text field and cannot be searched"
184 ),
185 });
186 }
187 }
188 }
189 for field in &self.admin.search_fields {
192 let Some(declared_field) = self.fields.get(field) else {
193 return Err(crate::Error::Schema {
194 resource: self.meta.name.clone(),
195 message: format!("[admin] search_fields names unknown field `{field}`"),
196 });
197 };
198 if !matches!(declared_field.ty, FieldType::String | FieldType::Text) {
199 return Err(crate::Error::Schema {
200 resource: self.meta.name.clone(),
201 message: format!(
202 "[admin] search_fields names `{field}`, which is not a text field and cannot be searched"
203 ),
204 });
205 }
206 if declared_field.hidden {
207 return Err(crate::Error::Schema {
208 resource: self.meta.name.clone(),
209 message: format!(
210 "[admin] search_fields names `{field}`, which is hidden — searching it would answer questions its own responses refuse"
211 ),
212 });
213 }
214 }
215 Ok(())
216 }
217
218 pub fn admin_label(&self) -> String {
220 self.admin
221 .label
222 .clone()
223 .unwrap_or_else(|| titleize(&self.meta.name))
224 }
225
226 pub fn admin_plural(&self) -> String {
228 self.admin
229 .plural
230 .clone()
231 .unwrap_or_else(|| pluralize(&self.admin_label()))
232 }
233
234 pub fn admin_display_field(&self) -> Option<String> {
240 if let Some(declared) = &self.admin.display_field {
241 if self.fields.contains_key(declared) {
242 return Some(declared.clone());
243 }
244 }
245 const PREFERRED: [&str; 7] = ["name", "title", "label", "slug", "code", "number", "email"];
246 for candidate in PREFERRED {
247 if let Some(field) = self.fields.get(candidate) {
248 if !field.hidden && matches!(field.ty, FieldType::String | FieldType::Text) {
249 return Some(candidate.to_string());
250 }
251 }
252 }
253 self.fields
254 .iter()
255 .find(|(_, field)| !field.hidden && field.ty == FieldType::String)
256 .map(|(name, _)| name.clone())
257 }
258
259 pub fn admin_search_field(&self) -> Option<String> {
265 let candidate = match &self.admin.search_field {
266 Some(declared) if self.fields.contains_key(declared) => Some(declared.clone()),
267 Some(_) | None => self.admin_display_field(),
268 };
269 candidate.filter(|name| {
270 self.fields
271 .get(name)
272 .is_some_and(|field| matches!(field.ty, FieldType::String | FieldType::Text))
273 })
274 }
275
276 pub fn admin_search_fields(&self) -> Vec<String> {
283 let declared: Vec<String> = self
284 .admin
285 .search_fields
286 .iter()
287 .filter(|name| {
288 self.fields.get(*name).is_some_and(|field| {
289 !field.hidden && matches!(field.ty, FieldType::String | FieldType::Text)
290 })
291 })
292 .cloned()
293 .collect();
294 if !declared.is_empty() {
295 return declared;
296 }
297 self.admin_search_field().into_iter().collect()
298 }
299
300 pub fn admin_columns(&self) -> Vec<String> {
306 let declared: Vec<String> = self
307 .admin
308 .columns
309 .iter()
310 .filter(|name| self.fields.contains_key(*name))
311 .cloned()
312 .collect();
313 if !declared.is_empty() {
314 return declared;
315 }
316
317 let display = self.admin_display_field();
318 let mut columns: Vec<String> = display.iter().cloned().collect();
319 for (name, field) in &self.fields {
320 if columns.len() >= 5 {
321 break;
322 }
323 let skip = field.hidden
324 || !field.admin.visible
325 || Some(name) == display.as_ref()
326 || name == "organization_id"
327 || matches!(field.ty, FieldType::Json | FieldType::Text);
328 if !skip {
329 columns.push(name.clone());
330 }
331 }
332 columns
333 }
334
335 pub fn references(&self) -> Vec<Reference> {
337 self.fields
338 .iter()
339 .filter_map(|(name, field)| {
340 if field.ty != FieldType::Reference {
341 return None;
342 }
343 let target = field.references.clone()?;
344 Some(Reference {
345 field: name.clone(),
346 target,
347 relation: relation_name(name).to_string(),
348 on_delete: field.on_delete.unwrap_or(OnDelete::Restrict),
349 required: field.required,
350 })
351 })
352 .collect()
353 }
354
355 pub fn reference_by_relation(&self, relation: &str) -> Option<Reference> {
357 self.references()
358 .into_iter()
359 .find(|r| r.relation == relation)
360 }
361
362 pub fn is_org_scoped(&self) -> bool {
364 self.meta.scope == Scope::Organization
365 }
366
367 pub fn org_column(&self) -> Option<&'static str> {
371 if self.is_org_scoped() {
372 Some("organization_id")
373 } else if self.meta.name == "organization" {
374 Some("id")
375 } else {
376 None
377 }
378 }
379
380 pub fn load(path: &Path) -> crate::Result<Self> {
382 let text = std::fs::read_to_string(path).map_err(|e| crate::Error::Io {
383 path: path.to_path_buf(),
384 source: e,
385 })?;
386 let source = path.file_name().unwrap_or_default().to_string_lossy();
389 let resource: Resource =
390 crate::env::parse_toml(&text, &source).map_err(|e| crate::Error::Toml {
391 path: path.to_path_buf(),
392 source: e,
393 })?;
394 resource.validate()?;
395 Ok(resource)
396 }
397}
398
399#[derive(Debug, Clone, Default, Deserialize)]
407#[serde(default, deny_unknown_fields)]
408pub struct ResourceAdmin {
409 pub visible: Option<bool>,
412 pub roles: Vec<String>,
415 pub label: Option<String>,
417 pub plural: Option<String>,
419 pub group: Option<String>,
421 pub display_field: Option<String>,
424 pub columns: Vec<String>,
426 pub search_field: Option<String>,
428 pub search_fields: Vec<String>,
432 pub order: i64,
434}
435
436impl ResourceAdmin {
437 pub fn is_visible(&self, resource_name: &str) -> bool {
445 self.visible.unwrap_or(!is_auth_resource(resource_name))
446 }
447}
448
449pub fn is_auth_resource(name: &str) -> bool {
452 matches!(
453 name,
454 "user"
455 | "organization"
456 | "membership"
457 | "membership_role"
458 | "api_key"
459 | "oauth_connection"
460 | "invitation"
461 | "auth_token"
462 )
463}
464
465#[derive(Debug, Clone, Deserialize)]
467#[serde(default, deny_unknown_fields)]
468pub struct FieldAdmin {
469 pub visible: bool,
472 pub readonly: bool,
474 pub label: Option<String>,
476 pub help: Option<String>,
478 pub widget: Widget,
480 pub options: Vec<String>,
483 pub placeholder: Option<String>,
485 pub signup: Option<bool>,
494 pub format: ContentFormat,
498}
499
500impl Default for FieldAdmin {
501 fn default() -> Self {
502 FieldAdmin {
503 visible: true,
504 readonly: false,
505 label: None,
506 help: None,
507 widget: Widget::Auto,
508 options: Vec::new(),
509 placeholder: None,
510 format: ContentFormat::Plain,
511 signup: None,
512 }
513 }
514}
515
516impl FieldAdmin {
517 pub fn in_signup(&self, field: &Field) -> bool {
524 self.signup.unwrap_or(field.required)
525 }
526}
527
528#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
533#[serde(rename_all = "snake_case")]
534pub enum ContentFormat {
535 #[default]
537 Plain,
538 Markdown,
539 Html,
540}
541
542impl ContentFormat {
543 pub fn as_str(self) -> &'static str {
544 match self {
545 ContentFormat::Plain => "plain",
546 ContentFormat::Markdown => "markdown",
547 ContentFormat::Html => "html",
548 }
549 }
550}
551
552#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
554#[serde(rename_all = "snake_case")]
555pub enum Widget {
556 Auto,
558 Text,
559 Textarea,
560 Select,
561 Email,
562 Url,
563 Password,
564 Color,
565 Date,
566 DateTime,
567 Json,
568 Switch,
569 File,
571}
572
573impl Widget {
574 pub fn as_str(self) -> &'static str {
575 match self {
576 Widget::Auto => "auto",
577 Widget::Text => "text",
578 Widget::Textarea => "textarea",
579 Widget::Select => "select",
580 Widget::Email => "email",
581 Widget::Url => "url",
582 Widget::Password => "password",
583 Widget::Color => "color",
584 Widget::Date => "date",
585 Widget::DateTime => "date_time",
586 Widget::Json => "json",
587 Widget::Switch => "switch",
588 Widget::File => "file",
589 }
590 }
591}
592
593#[derive(Debug, Clone, Deserialize)]
595pub struct Field {
596 #[serde(rename = "type")]
597 pub ty: FieldType,
598 #[serde(default)]
600 pub references: Option<String>,
601 #[serde(default)]
602 pub required: bool,
603 #[serde(default)]
604 pub unique: bool,
605 #[serde(default)]
607 pub hidden: bool,
608 #[serde(default)]
610 pub default: Option<serde_json::Value>,
611 pub max_length: Option<u32>,
612 #[serde(default)]
615 pub on_delete: Option<OnDelete>,
616 #[serde(default)]
618 pub admin: FieldAdmin,
619}
620
621#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
623#[serde(rename_all = "snake_case")]
624pub enum FieldType {
625 String,
626 Text,
627 Integer,
628 BigInt,
629 Float,
630 Boolean,
631 Uuid,
632 Timestamp,
633 Json,
634 File,
643 Reference,
645}
646
647#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
649#[serde(rename_all = "snake_case")]
650pub enum OnDelete {
651 Restrict,
653 SetNull,
655 Cascade,
657 NoAction,
659}
660
661impl OnDelete {
662 pub fn to_sql(self) -> &'static str {
663 match self {
664 OnDelete::Restrict => "RESTRICT",
665 OnDelete::SetNull => "SET NULL",
666 OnDelete::Cascade => "CASCADE",
667 OnDelete::NoAction => "NO ACTION",
668 }
669 }
670}
671
672#[derive(Debug, Clone)]
675pub struct Reference {
676 pub field: String,
677 pub target: String,
678 pub relation: String,
679 pub on_delete: OnDelete,
680 pub required: bool,
681}
682
683pub fn relation_name(field: &str) -> &str {
686 field.strip_suffix("_id").unwrap_or(field)
687}
688
689pub fn titleize(name: &str) -> String {
692 let spaced = name.trim_end_matches("_id").replace('_', " ");
693 let mut chars = spaced.chars();
694 match chars.next() {
695 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
696 None => spaced,
697 }
698}
699
700pub fn pluralize(label: &str) -> String {
703 let lower = label.to_lowercase();
704 if lower.ends_with('s')
705 || lower.ends_with("x")
706 || lower.ends_with("ch")
707 || lower.ends_with("sh")
708 {
709 format!("{label}es")
710 } else if lower.ends_with('y')
711 && !lower.ends_with("ay")
712 && !lower.ends_with("ey")
713 && !lower.ends_with("oy")
714 && !lower.ends_with("uy")
715 {
716 format!("{}ies", &label[..label.len() - 1])
717 } else {
718 format!("{label}s")
719 }
720}
721
722#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
725#[serde(rename_all = "snake_case")]
726pub enum Scope {
727 Organization,
730 Global,
732}
733
734#[derive(Debug, Clone, PartialEq, Eq)]
741pub enum Access {
742 Public,
745 Authenticated,
747 Member,
749 Role(String),
751 Owner,
753 Private,
755}
756
757impl Access {
758 pub fn parse(s: &str) -> Access {
760 match s {
761 "public" => Access::Public,
762 "authenticated" => Access::Authenticated,
763 "member" => Access::Member,
764 "owner" => Access::Owner,
765 "private" => Access::Private,
766 other => other
767 .strip_prefix("role:")
768 .map(|role| Access::Role(role.to_string()))
769 .unwrap_or(Access::Private),
770 }
771 }
772
773 pub fn as_string(&self) -> String {
775 match self {
776 Access::Public => "public".to_string(),
777 Access::Authenticated => "authenticated".to_string(),
778 Access::Member => "member".to_string(),
779 Access::Role(role) => format!("role:{role}"),
780 Access::Owner => "owner".to_string(),
781 Access::Private => "private".to_string(),
782 }
783 }
784}
785
786#[derive(Debug, Clone, Deserialize)]
788#[serde(from = "PermissionsRaw")]
789pub struct Permissions {
790 pub list: Access,
791 pub read: Access,
792 pub create: Access,
793 pub update: Access,
794 pub delete: Access,
795}
796
797impl Default for Permissions {
798 fn default() -> Self {
799 Permissions {
803 list: Access::Member,
804 read: Access::Member,
805 create: Access::Member,
806 update: Access::Member,
807 delete: Access::Member,
808 }
809 }
810}
811
812#[derive(Deserialize)]
813struct PermissionsRaw {
814 list: Option<String>,
815 read: Option<String>,
816 create: Option<String>,
817 update: Option<String>,
818 delete: Option<String>,
819}
820
821impl From<PermissionsRaw> for Permissions {
822 fn from(r: PermissionsRaw) -> Self {
823 let d = Permissions::default();
824 Permissions {
825 list: r.list.map(|s| Access::parse(&s)).unwrap_or(d.list),
826 read: r.read.map(|s| Access::parse(&s)).unwrap_or(d.read),
827 create: r.create.map(|s| Access::parse(&s)).unwrap_or(d.create),
828 update: r.update.map(|s| Access::parse(&s)).unwrap_or(d.update),
829 delete: r.delete.map(|s| Access::parse(&s)).unwrap_or(d.delete),
830 }
831 }
832}
833
834#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
841pub enum HookEvent {
842 BeforeList,
843 AfterList,
844 BeforeRead,
845 AfterRead,
846 BeforeCreate,
847 AfterCreate,
848 BeforeUpdate,
849 AfterUpdate,
850 BeforeDelete,
851 AfterDelete,
852}
853
854impl HookEvent {
855 pub const ALL: [HookEvent; 10] = [
857 HookEvent::BeforeList,
858 HookEvent::AfterList,
859 HookEvent::BeforeRead,
860 HookEvent::AfterRead,
861 HookEvent::BeforeCreate,
862 HookEvent::AfterCreate,
863 HookEvent::BeforeUpdate,
864 HookEvent::AfterUpdate,
865 HookEvent::BeforeDelete,
866 HookEvent::AfterDelete,
867 ];
868
869 pub fn as_str(self) -> &'static str {
871 match self {
872 HookEvent::BeforeList => "before_list",
873 HookEvent::AfterList => "after_list",
874 HookEvent::BeforeRead => "before_read",
875 HookEvent::AfterRead => "after_read",
876 HookEvent::BeforeCreate => "before_create",
877 HookEvent::AfterCreate => "after_create",
878 HookEvent::BeforeUpdate => "before_update",
879 HookEvent::AfterUpdate => "after_update",
880 HookEvent::BeforeDelete => "before_delete",
881 HookEvent::AfterDelete => "after_delete",
882 }
883 }
884
885 pub fn action(self) -> &'static str {
887 match self {
888 HookEvent::BeforeList | HookEvent::AfterList => "list",
889 HookEvent::BeforeRead | HookEvent::AfterRead => "read",
890 HookEvent::BeforeCreate | HookEvent::AfterCreate => "create",
891 HookEvent::BeforeUpdate | HookEvent::AfterUpdate => "update",
892 HookEvent::BeforeDelete | HookEvent::AfterDelete => "delete",
893 }
894 }
895
896 pub fn phase(self) -> &'static str {
898 if self.is_before() {
899 "before"
900 } else {
901 "after"
902 }
903 }
904
905 pub fn is_before(self) -> bool {
907 matches!(
908 self,
909 HookEvent::BeforeList
910 | HookEvent::BeforeRead
911 | HookEvent::BeforeCreate
912 | HookEvent::BeforeUpdate
913 | HookEvent::BeforeDelete
914 )
915 }
916}
917
918#[derive(Debug, Clone, Default, Deserialize)]
940#[serde(default, deny_unknown_fields)]
941pub struct Publishes {
942 pub after_create: Option<String>,
943 pub after_update: Option<String>,
944 pub after_delete: Option<String>,
945}
946
947impl Publishes {
948 pub fn get(&self, event: HookEvent) -> Option<&str> {
950 let slot = match event {
951 HookEvent::AfterCreate => &self.after_create,
952 HookEvent::AfterUpdate => &self.after_update,
953 HookEvent::AfterDelete => &self.after_delete,
954 _ => &None,
955 };
956 slot.as_deref().map(str::trim).filter(|t| !t.is_empty())
957 }
958
959 pub fn iter(&self) -> impl Iterator<Item = (HookEvent, &str)> {
961 [
962 HookEvent::AfterCreate,
963 HookEvent::AfterUpdate,
964 HookEvent::AfterDelete,
965 ]
966 .into_iter()
967 .filter_map(|event| self.get(event).map(|topic| (event, topic)))
968 }
969
970 pub fn is_empty(&self) -> bool {
971 self.iter().next().is_none()
972 }
973}
974
975#[derive(Debug, Clone, Default, Deserialize)]
984#[serde(default, deny_unknown_fields)]
985pub struct Hooks {
986 pub before_list: Option<String>,
987 pub after_list: Option<String>,
988 pub before_read: Option<String>,
989 pub after_read: Option<String>,
990 pub before_create: Option<String>,
991 pub after_create: Option<String>,
992 pub before_update: Option<String>,
993 pub after_update: Option<String>,
994 pub before_delete: Option<String>,
995 pub after_delete: Option<String>,
996 pub before_register: Option<String>,
997 pub after_register: Option<String>,
998 pub before_login: Option<String>,
999 pub after_login: Option<String>,
1000 pub before_api_key: Option<String>,
1001 pub after_api_key: Option<String>,
1002}
1003
1004impl Hooks {
1005 pub fn get(&self, event: HookEvent) -> Option<&str> {
1007 let slot = match event {
1008 HookEvent::BeforeList => &self.before_list,
1009 HookEvent::AfterList => &self.after_list,
1010 HookEvent::BeforeRead => &self.before_read,
1011 HookEvent::AfterRead => &self.after_read,
1012 HookEvent::BeforeCreate => &self.before_create,
1013 HookEvent::AfterCreate => &self.after_create,
1014 HookEvent::BeforeUpdate => &self.before_update,
1015 HookEvent::AfterUpdate => &self.after_update,
1016 HookEvent::BeforeDelete => &self.before_delete,
1017 HookEvent::AfterDelete => &self.after_delete,
1018 };
1019 slot.as_deref()
1020 }
1021
1022 pub fn iter(&self) -> impl Iterator<Item = (HookEvent, &str)> {
1024 HookEvent::ALL
1025 .into_iter()
1026 .filter_map(|event| self.get(event).map(|name| (event, name)))
1027 }
1028
1029 pub fn is_empty(&self) -> bool {
1031 self.iter().next().is_none() && self.auth_iter().next().is_none()
1032 }
1033}
1034
1035#[derive(Debug, Clone, Deserialize)]
1037#[serde(default)]
1038pub struct AuthSpec {
1039 pub identity_field: String,
1041 pub password_field: String,
1043 pub oauth_providers: Vec<String>,
1045}
1046
1047impl Default for AuthSpec {
1048 fn default() -> Self {
1049 AuthSpec {
1050 identity_field: "email".to_string(),
1051 password_field: "password_hash".to_string(),
1052 oauth_providers: Vec::new(),
1053 }
1054 }
1055}
1056
1057#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1065pub enum AuthEvent {
1066 BeforeRegister,
1067 AfterRegister,
1068 BeforeLogin,
1069 AfterLogin,
1070 BeforeApiKey,
1071 AfterApiKey,
1072}
1073
1074impl AuthEvent {
1075 pub const ALL: [AuthEvent; 6] = [
1077 AuthEvent::BeforeRegister,
1078 AuthEvent::AfterRegister,
1079 AuthEvent::BeforeLogin,
1080 AuthEvent::AfterLogin,
1081 AuthEvent::BeforeApiKey,
1082 AuthEvent::AfterApiKey,
1083 ];
1084
1085 pub fn as_str(self) -> &'static str {
1087 match self {
1088 AuthEvent::BeforeRegister => "before_register",
1089 AuthEvent::AfterRegister => "after_register",
1090 AuthEvent::BeforeLogin => "before_login",
1091 AuthEvent::AfterLogin => "after_login",
1092 AuthEvent::BeforeApiKey => "before_api_key",
1093 AuthEvent::AfterApiKey => "after_api_key",
1094 }
1095 }
1096
1097 pub fn action(self) -> &'static str {
1099 match self {
1100 AuthEvent::BeforeRegister | AuthEvent::AfterRegister => "register",
1101 AuthEvent::BeforeLogin | AuthEvent::AfterLogin => "login",
1102 AuthEvent::BeforeApiKey | AuthEvent::AfterApiKey => "api_key",
1103 }
1104 }
1105
1106 pub fn phase(self) -> &'static str {
1108 if self.is_before() {
1109 "before"
1110 } else {
1111 "after"
1112 }
1113 }
1114
1115 pub fn is_before(self) -> bool {
1117 matches!(
1118 self,
1119 AuthEvent::BeforeRegister | AuthEvent::BeforeLogin | AuthEvent::BeforeApiKey
1120 )
1121 }
1122}
1123
1124impl Hooks {
1125 pub fn get_auth(&self, event: AuthEvent) -> Option<&str> {
1127 let slot = match event {
1128 AuthEvent::BeforeRegister => &self.before_register,
1129 AuthEvent::AfterRegister => &self.after_register,
1130 AuthEvent::BeforeLogin => &self.before_login,
1131 AuthEvent::AfterLogin => &self.after_login,
1132 AuthEvent::BeforeApiKey => &self.before_api_key,
1133 AuthEvent::AfterApiKey => &self.after_api_key,
1134 };
1135 slot.as_deref()
1136 }
1137
1138 pub fn auth_iter(&self) -> impl Iterator<Item = (AuthEvent, &str)> {
1140 AuthEvent::ALL
1141 .into_iter()
1142 .filter_map(|event| self.get_auth(event).map(|name| (event, name)))
1143 }
1144}
1145
1146#[cfg(test)]
1147mod tests {
1148 use super::*;
1149
1150 fn parse_resource(src: &str) -> Resource {
1151 let resource: Resource = toml::from_str(src).unwrap();
1152 resource.validate().unwrap();
1153 resource
1154 }
1155
1156 #[test]
1157 fn access_parser_and_permissions_defaults_match_org_membership_model() {
1158 assert_eq!(Access::parse("public"), Access::Public);
1159 assert_eq!(Access::parse("authenticated"), Access::Authenticated);
1160 assert_eq!(Access::parse("member"), Access::Member);
1161 assert_eq!(Access::parse("owner"), Access::Owner);
1162 assert_eq!(Access::parse("role:admin"), Access::Role("admin".into()));
1163 assert_eq!(Access::parse("wat"), Access::Private);
1164
1165 let defaults = Permissions::default();
1166 assert_eq!(defaults.list, Access::Member);
1167 assert_eq!(defaults.read, Access::Member);
1168 assert_eq!(defaults.create, Access::Member);
1169 assert_eq!(defaults.update, Access::Member);
1170 assert_eq!(defaults.delete, Access::Member);
1171 }
1172
1173 #[test]
1174 fn validate_rejects_reserved_id_and_dangling_reference_definition() {
1175 let reserved_id: Resource = toml::from_str(
1176 r#"
1177[resource]
1178name = "bad"
1179
1180[fields.id]
1181type = "string"
1182"#,
1183 )
1184 .unwrap();
1185 assert!(reserved_id.validate().is_err());
1186
1187 let missing_target: Resource = toml::from_str(
1188 r#"
1189[resource]
1190name = "bad_ref"
1191
1192[fields.owner_id]
1193type = "reference"
1194"#,
1195 )
1196 .unwrap();
1197 assert!(missing_target.validate().is_err());
1198 }
1199
1200 #[test]
1201 fn content_format_is_only_allowed_on_text_fields() {
1202 let good: Resource = toml::from_str(
1203 r#"
1204[resource]
1205name = "article"
1206
1207[fields.body]
1208type = "text"
1209
1210[fields.body.admin]
1211format = "markdown"
1212"#,
1213 )
1214 .unwrap();
1215 good.validate().unwrap();
1216 assert_eq!(good.fields["body"].admin.format, ContentFormat::Markdown);
1217
1218 let bad: Resource = toml::from_str(
1219 r#"
1220[resource]
1221name = "article"
1222
1223[fields.published]
1224type = "boolean"
1225
1226[fields.published.admin]
1227format = "html"
1228"#,
1229 )
1230 .unwrap();
1231 assert!(bad.validate().is_err());
1232 }
1233
1234 #[test]
1235 fn references_derive_relation_names_and_default_on_delete() {
1236 let resource = parse_resource(
1237 r#"
1238[resource]
1239name = "comment"
1240
1241[fields.post_id]
1242type = "reference"
1243references = "post"
1244required = true
1245
1246[fields.author_id]
1247type = "reference"
1248references = "user"
1249on_delete = "cascade"
1250"#,
1251 );
1252
1253 assert_eq!(relation_name("owner_id"), "owner");
1254 assert_eq!(relation_name("slug"), "slug");
1255
1256 let refs = resource.references();
1257 assert_eq!(refs.len(), 2);
1258 let post = refs.iter().find(|rf| rf.field == "post_id").unwrap();
1259 assert_eq!(post.target, "post");
1260 assert_eq!(post.relation, "post");
1261 assert_eq!(post.on_delete, OnDelete::Restrict);
1262 assert!(post.required);
1263
1264 let author = resource.reference_by_relation("author").unwrap();
1265 assert_eq!(author.field, "author_id");
1266 assert_eq!(author.on_delete, OnDelete::Cascade);
1267 }
1268
1269 #[test]
1270 fn hooks_parse_per_event_and_iterate_in_lifecycle_order() {
1271 let resource = parse_resource(
1272 r#"
1273[resource]
1274name = "post"
1275
1276[fields.title]
1277type = "string"
1278
1279[hooks]
1280before_create = "validate_post"
1281after_create = "notify"
1282after_list = "redact"
1283"#,
1284 );
1285
1286 assert_eq!(
1287 resource.hook(HookEvent::BeforeCreate),
1288 Some("validate_post")
1289 );
1290 assert_eq!(resource.hook(HookEvent::AfterCreate), Some("notify"));
1291 assert_eq!(resource.hook(HookEvent::AfterList), Some("redact"));
1292 assert_eq!(resource.hook(HookEvent::BeforeUpdate), None);
1293 assert!(!resource.hooks.is_empty());
1294
1295 let declared: Vec<_> = resource.hooks.iter().collect();
1296 assert_eq!(
1297 declared,
1298 vec![
1299 (HookEvent::AfterList, "redact"),
1300 (HookEvent::BeforeCreate, "validate_post"),
1301 (HookEvent::AfterCreate, "notify"),
1302 ]
1303 );
1304 }
1305
1306 #[test]
1307 fn hook_events_expose_wire_names_actions_and_phases() {
1308 assert_eq!(HookEvent::BeforeCreate.as_str(), "before_create");
1309 assert_eq!(HookEvent::BeforeCreate.action(), "create");
1310 assert_eq!(HookEvent::BeforeCreate.phase(), "before");
1311 assert!(HookEvent::BeforeCreate.is_before());
1312
1313 assert_eq!(HookEvent::AfterList.as_str(), "after_list");
1314 assert_eq!(HookEvent::AfterList.action(), "list");
1315 assert_eq!(HookEvent::AfterList.phase(), "after");
1316 assert!(!HookEvent::AfterList.is_before());
1317
1318 for event in HookEvent::ALL {
1320 let resource = parse_resource(&format!(
1321 "[resource]\nname = \"post\"\n\n[hooks]\n{} = \"h\"\n",
1322 event.as_str()
1323 ));
1324 assert_eq!(resource.hook(event), Some("h"), "{}", event.as_str());
1325 assert_eq!(resource.hooks.iter().count(), 1);
1326 }
1327 }
1328
1329 #[test]
1330 fn hooks_reject_typos_and_empty_function_names() {
1331 let typo = toml::from_str::<Resource>(
1332 r#"
1333[resource]
1334name = "post"
1335
1336[hooks]
1337befor_create = "oops"
1338"#,
1339 );
1340 assert!(typo.is_err(), "unknown hook keys must not be ignored");
1341
1342 let empty: Resource = toml::from_str(
1343 r#"
1344[resource]
1345name = "post"
1346
1347[hooks]
1348after_delete = " "
1349"#,
1350 )
1351 .unwrap();
1352 assert!(empty.validate().is_err());
1353 }
1354
1355 #[test]
1356 fn auth_events_expose_wire_names_actions_and_phases() {
1357 assert_eq!(AuthEvent::BeforeLogin.as_str(), "before_login");
1358 assert_eq!(AuthEvent::BeforeLogin.action(), "login");
1359 assert_eq!(AuthEvent::BeforeLogin.phase(), "before");
1360 assert!(AuthEvent::BeforeLogin.is_before());
1361
1362 assert_eq!(AuthEvent::AfterApiKey.action(), "api_key");
1363 assert_eq!(AuthEvent::AfterApiKey.phase(), "after");
1364 assert!(!AuthEvent::AfterApiKey.is_before());
1365
1366 for event in AuthEvent::ALL {
1369 let resource = parse_resource(&format!(
1370 "[resource]\nname = \"user\"\n\n[hooks]\nafter_create = \"c\"\n{} = \"h\"\n",
1371 event.as_str()
1372 ));
1373 assert_eq!(resource.auth_hook(event), Some("h"), "{}", event.as_str());
1374 assert_eq!(resource.hook(HookEvent::AfterCreate), Some("c"));
1375 assert_eq!(resource.hooks.auth_iter().count(), 1);
1376 assert_eq!(resource.hooks.iter().count(), 1);
1379 }
1380 }
1381
1382 #[test]
1383 fn auth_hooks_are_absent_by_default_and_reject_typos_and_empty_names() {
1384 let plain =
1385 parse_resource("[resource]\nname = \"user\"\n\n[hooks]\nafter_create = \"c\"\n");
1386 assert_eq!(plain.auth_hook(AuthEvent::BeforeLogin), None);
1387 assert!(parse_resource("[resource]\nname = \"user\"\n")
1388 .hooks
1389 .is_empty());
1390
1391 let typo = toml::from_str::<Resource>(
1392 "[resource]\nname = \"user\"\n\n[hooks]\nbefore_signin = \"oops\"\n",
1393 );
1394 assert!(typo.is_err(), "unknown auth hook keys must not be ignored");
1395
1396 let empty: Resource =
1397 toml::from_str("[resource]\nname = \"user\"\n\n[hooks]\nafter_login = \" \"\n")
1398 .unwrap();
1399 assert!(empty.validate().is_err());
1400 }
1401
1402 #[test]
1403 fn auth_hooks_are_rejected_on_any_resource_but_user() {
1404 let stray: Resource =
1407 toml::from_str("[resource]\nname = \"post\"\n\n[hooks]\nbefore_login = \"h\"\n")
1408 .unwrap();
1409 let message = stray.validate().unwrap_err().to_string();
1410 assert!(message.contains("before_login"), "{message}");
1411 assert!(message.contains("user"), "{message}");
1412 }
1413
1414 #[test]
1415 fn resources_have_no_hooks_by_default() {
1416 let resource = parse_resource("[resource]\nname = \"post\"\n");
1417 assert!(resource.hooks.is_empty());
1418 assert!(HookEvent::ALL
1419 .into_iter()
1420 .all(|event| resource.hook(event).is_none()));
1421 }
1422
1423 #[test]
1424 fn admin_visibility_defaults_hide_auth_resources_but_nothing_else() {
1425 let post = parse_resource("[resource]\nname = \"post\"\n");
1426 assert!(post.admin.is_visible("post"));
1427
1428 let user = parse_resource(crate::defaults::USER_TOML);
1429 assert!(!user.admin.is_visible("user"));
1430 assert!(!parse_resource(crate::defaults::MEMBERSHIP_TOML)
1431 .admin
1432 .is_visible("membership"));
1433
1434 let replaced = parse_resource(
1436 "[resource]\nname = \"user\"\nscope = \"global\"\n\n[fields.email]\ntype = \"string\"\n",
1437 );
1438 assert!(!replaced.admin.is_visible("user"));
1439
1440 let opted_in = parse_resource(
1442 "[resource]\nname = \"user\"\nscope = \"global\"\n\n[admin]\nvisible = true\n",
1443 );
1444 assert!(opted_in.admin.is_visible("user"));
1445 }
1446
1447 #[test]
1448 fn admin_labels_are_inferred_and_overridable() {
1449 let inferred = parse_resource("[resource]\nname = \"purchase_order\"\n");
1450 assert_eq!(inferred.admin_label(), "Purchase order");
1451 assert_eq!(inferred.admin_plural(), "Purchase orders");
1452
1453 let overridden = parse_resource(
1454 "[resource]\nname = \"person\"\n\n[admin]\nlabel = \"Person\"\nplural = \"People\"\n",
1455 );
1456 assert_eq!(overridden.admin_plural(), "People");
1457
1458 assert_eq!(titleize("owner_id"), "Owner");
1459 assert_eq!(titleize("total_cents"), "Total cents");
1460 assert_eq!(pluralize("Category"), "Categories");
1461 assert_eq!(pluralize("Address"), "Addresses");
1462 assert_eq!(pluralize("Day"), "Days");
1463 assert_eq!(pluralize("Product"), "Products");
1464 }
1465
1466 #[test]
1467 fn display_field_prefers_conventional_names_then_any_string() {
1468 let conventional = parse_resource(
1469 r#"
1470[resource]
1471name = "product"
1472
1473[fields.sku]
1474type = "string"
1475
1476[fields.name]
1477type = "string"
1478"#,
1479 );
1480 assert_eq!(conventional.admin_display_field().as_deref(), Some("name"));
1481
1482 let only_odd_names =
1483 parse_resource("[resource]\nname = \"blob\"\n\n[fields.zzz]\ntype = \"string\"\n");
1484 assert_eq!(only_odd_names.admin_display_field().as_deref(), Some("zzz"));
1485
1486 let nothing_stringy =
1487 parse_resource("[resource]\nname = \"tick\"\n\n[fields.count]\ntype = \"integer\"\n");
1488 assert_eq!(nothing_stringy.admin_display_field(), None);
1489
1490 let declared = parse_resource(
1492 r#"
1493[resource]
1494name = "product"
1495
1496[admin]
1497display_field = "sku"
1498
1499[fields.sku]
1500type = "string"
1501
1502[fields.name]
1503type = "string"
1504"#,
1505 );
1506 assert_eq!(declared.admin_display_field().as_deref(), Some("sku"));
1507 assert_eq!(declared.admin_search_field().as_deref(), Some("sku"));
1509 }
1510
1511 #[test]
1512 fn inferred_columns_skip_blobs_and_dashboard_hidden_fields() {
1513 let resource = parse_resource(
1514 r#"
1515[resource]
1516name = "product"
1517
1518[fields.name]
1519type = "string"
1520
1521[fields.status]
1522type = "string"
1523
1524[fields.description]
1525type = "text"
1526
1527[fields.attributes]
1528type = "json"
1529
1530[fields.secret_ratio]
1531type = "float"
1532
1533[fields.secret_ratio.admin]
1534visible = false
1535"#,
1536 );
1537 assert_eq!(resource.admin_columns(), vec!["name", "status"]);
1538
1539 let declared = parse_resource(
1540 r#"
1541[resource]
1542name = "product"
1543
1544[admin]
1545columns = ["status", "name"]
1546
1547[fields.name]
1548type = "string"
1549
1550[fields.status]
1551type = "string"
1552"#,
1553 );
1554 assert_eq!(declared.admin_columns(), vec!["status", "name"]);
1555 }
1556
1557 #[test]
1558 fn admin_section_rejects_columns_naming_fields_that_do_not_exist() {
1559 let bad_column: Resource = toml::from_str(
1560 "[resource]\nname = \"post\"\n\n[admin]\ncolumns = [\"nope\"]\n\n[fields.title]\ntype = \"string\"\n",
1561 )
1562 .unwrap();
1563 assert!(bad_column.validate().is_err());
1564
1565 let bad_display: Resource = toml::from_str(
1566 "[resource]\nname = \"post\"\n\n[admin]\ndisplay_field = \"nope\"\n\n[fields.title]\ntype = \"string\"\n",
1567 )
1568 .unwrap();
1569 assert!(bad_display.validate().is_err());
1570
1571 let unsearchable: Resource = toml::from_str(
1574 "[resource]\nname = \"tick\"\n\n[admin]\nsearch_field = \"count\"\n\n[fields.count]\ntype = \"integer\"\n",
1575 )
1576 .unwrap();
1577 assert!(unsearchable.validate().is_err());
1578
1579 let named_by_a_number = parse_resource(
1582 "[resource]\nname = \"tick\"\n\n[admin]\ndisplay_field = \"count\"\n\n[fields.count]\ntype = \"integer\"\n",
1583 );
1584 assert_eq!(
1585 named_by_a_number.admin_display_field().as_deref(),
1586 Some("count")
1587 );
1588 assert_eq!(named_by_a_number.admin_search_field(), None);
1589
1590 let multi = parse_resource(
1593 "[resource]\nname = \"post\"\n\n[admin]\nsearch_fields = [\"title\", \"body\"]\n\n[fields.title]\ntype = \"string\"\n\n[fields.body]\ntype = \"text\"\n",
1594 );
1595 assert_eq!(multi.admin_search_fields(), vec!["title", "body"]);
1596
1597 let single =
1600 parse_resource("[resource]\nname = \"post\"\n\n[fields.title]\ntype = \"string\"\n");
1601 assert_eq!(single.admin_search_fields(), vec!["title"]);
1602
1603 for bad in [
1604 "[resource]\nname = \"post\"\n\n[admin]\nsearch_fields = [\"nope\"]\n\n[fields.title]\ntype = \"string\"\n",
1605 "[resource]\nname = \"post\"\n\n[admin]\nsearch_fields = [\"views\"]\n\n[fields.views]\ntype = \"integer\"\n",
1606 "[resource]\nname = \"post\"\n\n[admin]\nsearch_fields = [\"secret\"]\n\n[fields.secret]\ntype = \"string\"\nhidden = true\n",
1607 ] {
1608 let resource: Resource = toml::from_str(bad).unwrap();
1609 assert!(resource.validate().is_err(), "{bad} should not load");
1610 }
1611
1612 assert!(toml::from_str::<Resource>(
1614 "[resource]\nname = \"post\"\n\n[admin]\nvisibel = true\n"
1615 )
1616 .is_err());
1617 }
1618
1619 #[test]
1620 fn field_admin_carries_widget_options_and_visibility() {
1621 let resource = parse_resource(
1622 r#"
1623[resource]
1624name = "product"
1625
1626[fields.status]
1627type = "string"
1628
1629[fields.status.admin]
1630label = "Lifecycle"
1631widget = "select"
1632options = ["draft", "active|Live"]
1633readonly = true
1634"#,
1635 );
1636 let status = &resource.fields["status"];
1637 assert_eq!(status.admin.label.as_deref(), Some("Lifecycle"));
1638 assert_eq!(status.admin.widget, Widget::Select);
1639 assert_eq!(status.admin.widget.as_str(), "select");
1640 assert_eq!(status.admin.options, vec!["draft", "active|Live"]);
1641 assert!(status.admin.readonly);
1642 assert!(status.admin.visible);
1644 assert_eq!(resource.fields["status"].admin.help, None);
1645 }
1646
1647 #[test]
1648 fn org_column_reflects_resource_scope() {
1649 let org_scoped = parse_resource(
1650 r#"
1651[resource]
1652name = "post"
1653
1654[fields.title]
1655type = "string"
1656"#,
1657 );
1658 let global = parse_resource(
1659 r#"
1660[resource]
1661name = "plan"
1662scope = "global"
1663
1664[fields.name]
1665type = "string"
1666"#,
1667 );
1668 let organization = parse_resource(crate::defaults::ORGANIZATION_TOML);
1669
1670 assert_eq!(org_scoped.org_column(), Some("organization_id"));
1671 assert_eq!(global.org_column(), None);
1672 assert_eq!(organization.org_column(), Some("id"));
1673 }
1674}