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 rate_limit: RateLimits,
25 #[serde(default)]
27 pub hooks: Hooks,
28 #[serde(default)]
30 pub publish: Publishes,
31 #[serde(default)]
33 pub auth: Option<AuthSpec>,
34 #[serde(default)]
36 pub admin: ResourceAdmin,
37}
38
39#[derive(Debug, Clone, Deserialize)]
40pub struct ResourceMeta {
41 pub name: String,
42 pub table: Option<String>,
44 #[serde(default = "yes")]
46 pub timestamps: bool,
47 #[serde(default = "default_owner_field")]
49 pub owner_field: String,
50 #[serde(default = "default_scope")]
53 pub scope: Scope,
54}
55
56fn yes() -> bool {
57 true
58}
59fn default_owner_field() -> String {
60 "owner_id".to_string()
61}
62fn default_scope() -> Scope {
63 Scope::Organization
64}
65
66impl Field {
67 pub fn text_case(&self) -> Option<TextCase> {
73 match self.ty {
74 FieldType::String | FieldType::Text => self.case,
75 _ => None,
76 }
77 }
78}
79
80impl Resource {
81 pub fn cased_fields(&self) -> impl Iterator<Item = (&String, TextCase)> {
84 self.fields
85 .iter()
86 .filter_map(|(name, field)| Some((name, field.text_case()?)))
87 }
88
89 pub fn apply_text_case(&self, row: &mut serde_json::Value) {
96 let Some(object) = row.as_object_mut() else {
97 return;
98 };
99 for (name, case) in self.cased_fields() {
100 if let Some(serde_json::Value::String(value)) = object.get(name) {
101 let cased = case.apply(value);
102 if &cased != value {
103 object.insert(name.clone(), serde_json::Value::String(cased));
104 }
105 }
106 }
107 }
108
109 pub fn table_name(&self) -> String {
111 self.meta
112 .table
113 .clone()
114 .unwrap_or_else(|| format!("apiplant_{}", self.meta.name))
115 }
116
117 pub fn hook(&self, event: HookEvent) -> Option<&str> {
119 self.hooks.get(event)
120 }
121
122 pub fn auth_hook(&self, event: AuthEvent) -> Option<&str> {
125 self.hooks.get_auth(event)
126 }
127
128 pub fn validate(&self) -> crate::Result<()> {
130 for (event, function) in self.hooks.iter() {
131 if function.trim().is_empty() {
132 return Err(crate::Error::Schema {
133 resource: self.meta.name.clone(),
134 message: format!("hook `{}` names an empty function", event.as_str()),
135 });
136 }
137 }
138 for (event, function) in self.hooks.auth_iter() {
139 if function.trim().is_empty() {
140 return Err(crate::Error::Schema {
141 resource: self.meta.name.clone(),
142 message: format!("hook `{}` names an empty function", event.as_str()),
143 });
144 }
145 if self.meta.name != "user" {
148 return Err(crate::Error::Schema {
149 resource: self.meta.name.clone(),
150 message: format!(
151 "hook `{}` only exists on the `user` resource, which owns the auth endpoints",
152 event.as_str()
153 ),
154 });
155 }
156 }
157 for (event, topic) in self.publish.iter() {
161 if !crate::QueuesConfig::valid_topic(topic) {
162 return Err(crate::Error::Schema {
163 resource: self.meta.name.clone(),
164 message: format!(
165 "`publish.{}` names `{topic}`, which is not a topic: use letters, \
166 digits, `.`, `_`, `-` or `:`",
167 event.as_str()
168 ),
169 });
170 }
171 }
172 for (fname, field) in &self.fields {
173 if fname == "id" {
174 return Err(crate::Error::Schema {
175 resource: self.meta.name.clone(),
176 message: "`id` is reserved and added automatically".into(),
177 });
178 }
179 if field.ty == FieldType::Reference && field.references.is_none() {
180 return Err(crate::Error::Schema {
181 resource: self.meta.name.clone(),
182 message: format!("field `{fname}` is a reference without `references`"),
183 });
184 }
185 if field.admin.format != ContentFormat::Plain
186 && !matches!(field.ty, FieldType::Text | FieldType::String)
187 {
188 return Err(crate::Error::Schema {
189 resource: self.meta.name.clone(),
190 message: format!(
191 "field `{fname}` sets [admin] format = \"{}\" but is not a text field",
192 field.admin.format.as_str()
193 ),
194 });
195 }
196 }
197 for column in &self.admin.columns {
201 if !self.fields.contains_key(column) && column != "id" {
202 return Err(crate::Error::Schema {
203 resource: self.meta.name.clone(),
204 message: format!("[admin] columns names unknown field `{column}`"),
205 });
206 }
207 }
208 for (key, declared) in [
209 ("display_field", &self.admin.display_field),
210 ("search_field", &self.admin.search_field),
211 ] {
212 if let Some(field) = declared {
213 let Some(declared_field) = self.fields.get(field) else {
214 return Err(crate::Error::Schema {
215 resource: self.meta.name.clone(),
216 message: format!("[admin] {key} names unknown field `{field}`"),
217 });
218 };
219 if key == "search_field"
223 && !matches!(declared_field.ty, FieldType::String | FieldType::Text)
224 {
225 return Err(crate::Error::Schema {
226 resource: self.meta.name.clone(),
227 message: format!(
228 "[admin] search_field names `{field}`, which is not a text field and cannot be searched"
229 ),
230 });
231 }
232 }
233 }
234 for field in &self.admin.search_fields {
237 let Some(declared_field) = self.fields.get(field) else {
238 return Err(crate::Error::Schema {
239 resource: self.meta.name.clone(),
240 message: format!("[admin] search_fields names unknown field `{field}`"),
241 });
242 };
243 if !matches!(declared_field.ty, FieldType::String | FieldType::Text) {
244 return Err(crate::Error::Schema {
245 resource: self.meta.name.clone(),
246 message: format!(
247 "[admin] search_fields names `{field}`, which is not a text field and cannot be searched"
248 ),
249 });
250 }
251 if declared_field.hidden {
252 return Err(crate::Error::Schema {
253 resource: self.meta.name.clone(),
254 message: format!(
255 "[admin] search_fields names `{field}`, which is hidden — searching it would answer questions its own responses refuse"
256 ),
257 });
258 }
259 }
260 Ok(())
261 }
262
263 pub fn admin_label(&self) -> String {
265 self.admin
266 .label
267 .clone()
268 .unwrap_or_else(|| titleize(&self.meta.name))
269 }
270
271 pub fn admin_plural(&self) -> String {
273 self.admin
274 .plural
275 .clone()
276 .unwrap_or_else(|| pluralize(&self.admin_label()))
277 }
278
279 pub fn admin_display_field(&self) -> Option<String> {
285 if let Some(declared) = &self.admin.display_field {
286 if self.fields.contains_key(declared) {
287 return Some(declared.clone());
288 }
289 }
290 const PREFERRED: [&str; 7] = ["name", "title", "label", "slug", "code", "number", "email"];
291 for candidate in PREFERRED {
292 if let Some(field) = self.fields.get(candidate) {
293 if !field.hidden && matches!(field.ty, FieldType::String | FieldType::Text) {
294 return Some(candidate.to_string());
295 }
296 }
297 }
298 self.fields
299 .iter()
300 .find(|(_, field)| !field.hidden && field.ty == FieldType::String)
301 .map(|(name, _)| name.clone())
302 }
303
304 pub fn admin_search_field(&self) -> Option<String> {
310 let candidate = match &self.admin.search_field {
311 Some(declared) if self.fields.contains_key(declared) => Some(declared.clone()),
312 Some(_) | None => self.admin_display_field(),
313 };
314 candidate.filter(|name| {
315 self.fields
316 .get(name)
317 .is_some_and(|field| matches!(field.ty, FieldType::String | FieldType::Text))
318 })
319 }
320
321 pub fn admin_search_fields(&self) -> Vec<String> {
328 let declared: Vec<String> = self
329 .admin
330 .search_fields
331 .iter()
332 .filter(|name| {
333 self.fields.get(*name).is_some_and(|field| {
334 !field.hidden && matches!(field.ty, FieldType::String | FieldType::Text)
335 })
336 })
337 .cloned()
338 .collect();
339 if !declared.is_empty() {
340 return declared;
341 }
342 self.admin_search_field().into_iter().collect()
343 }
344
345 pub fn admin_columns(&self) -> Vec<String> {
351 let declared: Vec<String> = self
352 .admin
353 .columns
354 .iter()
355 .filter(|name| self.fields.contains_key(*name))
356 .cloned()
357 .collect();
358 if !declared.is_empty() {
359 return declared;
360 }
361
362 let display = self.admin_display_field();
363 let mut columns: Vec<String> = display.iter().cloned().collect();
364 for (name, field) in &self.fields {
365 if columns.len() >= 5 {
366 break;
367 }
368 let skip = field.hidden
369 || !field.admin.visible
370 || Some(name) == display.as_ref()
371 || name == "organization_id"
372 || matches!(field.ty, FieldType::Json | FieldType::Text);
373 if !skip {
374 columns.push(name.clone());
375 }
376 }
377 columns
378 }
379
380 pub fn references(&self) -> Vec<Reference> {
382 self.fields
383 .iter()
384 .filter_map(|(name, field)| {
385 if field.ty != FieldType::Reference {
386 return None;
387 }
388 let target = field.references.clone()?;
389 Some(Reference {
390 field: name.clone(),
391 target,
392 relation: relation_name(name).to_string(),
393 on_delete: field.on_delete.unwrap_or(OnDelete::Restrict),
394 required: field.required,
395 })
396 })
397 .collect()
398 }
399
400 pub fn reference_by_relation(&self, relation: &str) -> Option<Reference> {
402 self.references()
403 .into_iter()
404 .find(|r| r.relation == relation)
405 }
406
407 pub fn is_org_scoped(&self) -> bool {
409 self.meta.scope == Scope::Organization
410 }
411
412 pub fn org_column(&self) -> Option<&'static str> {
416 if self.is_org_scoped() {
417 Some("organization_id")
418 } else if self.meta.name == "organization" {
419 Some("id")
420 } else {
421 None
422 }
423 }
424
425 pub fn load(path: &Path) -> crate::Result<Self> {
427 let text = std::fs::read_to_string(path).map_err(|e| crate::Error::Io {
428 path: path.to_path_buf(),
429 source: e,
430 })?;
431 let source = path.file_name().unwrap_or_default().to_string_lossy();
434 let resource: Resource =
435 crate::env::parse_toml(&text, &source).map_err(|e| crate::Error::Toml {
436 path: path.to_path_buf(),
437 source: e,
438 })?;
439 resource.validate()?;
440 Ok(resource)
441 }
442}
443
444#[derive(Debug, Clone, Default, Deserialize)]
452#[serde(default, deny_unknown_fields)]
453pub struct ResourceAdmin {
454 pub visible: Option<bool>,
457 pub roles: Vec<String>,
460 pub label: Option<String>,
462 pub plural: Option<String>,
464 pub group: Option<String>,
466 pub display_field: Option<String>,
469 pub columns: Vec<String>,
471 pub search_field: Option<String>,
473 pub search_fields: Vec<String>,
477 pub order: i64,
479}
480
481impl ResourceAdmin {
482 pub fn is_visible(&self, resource_name: &str) -> bool {
490 self.visible.unwrap_or(!is_auth_resource(resource_name))
491 }
492}
493
494pub fn is_auth_resource(name: &str) -> bool {
497 matches!(
498 name,
499 "user"
500 | "organization"
501 | "membership"
502 | "membership_role"
503 | "api_key"
504 | "oauth_connection"
505 | "invitation"
506 | "auth_token"
507 )
508}
509
510#[derive(Debug, Clone, Deserialize)]
512#[serde(default, deny_unknown_fields)]
513pub struct FieldAdmin {
514 pub visible: bool,
517 pub readonly: bool,
519 pub label: Option<String>,
521 pub help: Option<String>,
523 pub widget: Widget,
525 pub options: Vec<String>,
528 pub placeholder: Option<String>,
530 pub signup: Option<bool>,
539 pub format: ContentFormat,
543}
544
545impl Default for FieldAdmin {
546 fn default() -> Self {
547 FieldAdmin {
548 visible: true,
549 readonly: false,
550 label: None,
551 help: None,
552 widget: Widget::Auto,
553 options: Vec::new(),
554 placeholder: None,
555 format: ContentFormat::Plain,
556 signup: None,
557 }
558 }
559}
560
561impl FieldAdmin {
562 pub fn in_signup(&self, field: &Field) -> bool {
569 self.signup.unwrap_or(field.required)
570 }
571}
572
573#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
578#[serde(rename_all = "snake_case")]
579pub enum ContentFormat {
580 #[default]
582 Plain,
583 Markdown,
584 Html,
585}
586
587impl ContentFormat {
588 pub fn as_str(self) -> &'static str {
589 match self {
590 ContentFormat::Plain => "plain",
591 ContentFormat::Markdown => "markdown",
592 ContentFormat::Html => "html",
593 }
594 }
595}
596
597#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
599#[serde(rename_all = "snake_case")]
600pub enum Widget {
601 Auto,
603 Text,
604 Textarea,
605 Select,
606 Email,
607 Url,
608 Password,
609 Color,
610 Date,
611 DateTime,
612 Json,
613 Switch,
614 File,
616}
617
618impl Widget {
619 pub fn as_str(self) -> &'static str {
620 match self {
621 Widget::Auto => "auto",
622 Widget::Text => "text",
623 Widget::Textarea => "textarea",
624 Widget::Select => "select",
625 Widget::Email => "email",
626 Widget::Url => "url",
627 Widget::Password => "password",
628 Widget::Color => "color",
629 Widget::Date => "date",
630 Widget::DateTime => "date_time",
631 Widget::Json => "json",
632 Widget::Switch => "switch",
633 Widget::File => "file",
634 }
635 }
636}
637
638#[derive(Debug, Clone, Deserialize)]
640pub struct Field {
641 #[serde(rename = "type")]
642 pub ty: FieldType,
643 #[serde(default)]
645 pub references: Option<String>,
646 #[serde(default)]
647 pub required: bool,
648 #[serde(default)]
649 pub unique: bool,
650 #[serde(default)]
652 pub hidden: bool,
653 #[serde(default)]
655 pub default: Option<serde_json::Value>,
656 pub max_length: Option<u32>,
657 #[serde(default)]
668 pub case: Option<TextCase>,
669 #[serde(default)]
672 pub on_delete: Option<OnDelete>,
673 #[serde(default)]
675 pub admin: FieldAdmin,
676}
677
678#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
685#[serde(rename_all = "snake_case")]
686pub enum TextCase {
687 Lower,
689 Upper,
691}
692
693impl TextCase {
694 pub fn apply(self, value: &str) -> String {
699 match self {
700 TextCase::Lower => value.to_ascii_lowercase(),
701 TextCase::Upper => value.to_ascii_uppercase(),
702 }
703 }
704
705 pub fn as_str(self) -> &'static str {
706 match self {
707 TextCase::Lower => "lower",
708 TextCase::Upper => "upper",
709 }
710 }
711}
712
713#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
715#[serde(rename_all = "snake_case")]
716pub enum FieldType {
717 String,
718 Text,
719 Integer,
720 BigInt,
721 Float,
722 Boolean,
723 Uuid,
724 Timestamp,
725 Json,
726 File,
735 Reference,
737}
738
739#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
741#[serde(rename_all = "snake_case")]
742pub enum OnDelete {
743 Restrict,
745 SetNull,
747 Cascade,
749 NoAction,
751}
752
753impl OnDelete {
754 pub fn to_sql(self) -> &'static str {
755 match self {
756 OnDelete::Restrict => "RESTRICT",
757 OnDelete::SetNull => "SET NULL",
758 OnDelete::Cascade => "CASCADE",
759 OnDelete::NoAction => "NO ACTION",
760 }
761 }
762}
763
764#[derive(Debug, Clone)]
767pub struct Reference {
768 pub field: String,
769 pub target: String,
770 pub relation: String,
771 pub on_delete: OnDelete,
772 pub required: bool,
773}
774
775pub fn relation_name(field: &str) -> &str {
778 field.strip_suffix("_id").unwrap_or(field)
779}
780
781pub fn titleize(name: &str) -> String {
784 let spaced = name.trim_end_matches("_id").replace('_', " ");
785 let mut chars = spaced.chars();
786 match chars.next() {
787 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
788 None => spaced,
789 }
790}
791
792pub fn pluralize(label: &str) -> String {
795 let lower = label.to_lowercase();
796 if lower.ends_with('s')
797 || lower.ends_with("x")
798 || lower.ends_with("ch")
799 || lower.ends_with("sh")
800 {
801 format!("{label}es")
802 } else if lower.ends_with('y')
803 && !lower.ends_with("ay")
804 && !lower.ends_with("ey")
805 && !lower.ends_with("oy")
806 && !lower.ends_with("uy")
807 {
808 format!("{}ies", &label[..label.len() - 1])
809 } else {
810 format!("{label}s")
811 }
812}
813
814#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
817#[serde(rename_all = "snake_case")]
818pub enum Scope {
819 Organization,
822 Global,
824}
825
826#[derive(Debug, Clone, PartialEq, Eq)]
833pub enum Access {
834 Public,
837 Authenticated,
839 Member,
841 Role(String),
843 Owner,
845 Private,
847}
848
849impl Access {
850 pub fn parse(s: &str) -> Access {
852 match s {
853 "public" => Access::Public,
854 "authenticated" => Access::Authenticated,
855 "member" => Access::Member,
856 "owner" => Access::Owner,
857 "private" => Access::Private,
858 other => other
859 .strip_prefix("role:")
860 .map(|role| Access::Role(role.to_string()))
861 .unwrap_or(Access::Private),
862 }
863 }
864
865 pub fn as_string(&self) -> String {
867 match self {
868 Access::Public => "public".to_string(),
869 Access::Authenticated => "authenticated".to_string(),
870 Access::Member => "member".to_string(),
871 Access::Role(role) => format!("role:{role}"),
872 Access::Owner => "owner".to_string(),
873 Access::Private => "private".to_string(),
874 }
875 }
876}
877
878#[derive(Debug, Clone, PartialEq, Eq)]
896pub struct Policy {
897 pub level: Access,
899 pub org_class: Option<String>,
901}
902
903impl Policy {
904 pub fn parse(s: &str) -> Policy {
910 let s = s.trim();
911 match s.split_once(ORG_CLASS_SUFFIX) {
912 Some((level, class)) => {
913 let class = class.trim();
914 if class.is_empty() {
915 return Policy::from(Access::Private);
916 }
917 Policy {
918 level: Access::parse(level.trim()),
919 org_class: Some(class.to_string()),
920 }
921 }
922 None => Policy::from(Access::parse(s)),
923 }
924 }
925
926 pub fn as_string(&self) -> String {
928 match &self.org_class {
929 Some(class) => format!("{}{ORG_CLASS_SUFFIX}{class}", self.level.as_string()),
930 None => self.level.as_string(),
931 }
932 }
933
934 pub fn matches_org_class(&self, class: Option<&str>) -> bool {
938 match &self.org_class {
939 None => true,
940 Some(required) => class.is_some_and(|c| c == required),
941 }
942 }
943}
944
945impl From<Access> for Policy {
946 fn from(level: Access) -> Policy {
947 Policy {
948 level,
949 org_class: None,
950 }
951 }
952}
953
954pub const ORG_CLASS_SUFFIX: &str = "@org_class=";
956
957pub const ORG_CLASS_FIELD: &str = "org_class";
960
961#[derive(Debug, Clone, Deserialize)]
963#[serde(from = "PermissionsRaw")]
964pub struct Permissions {
965 pub list: Policy,
966 pub read: Policy,
967 pub create: Policy,
968 pub update: Policy,
969 pub delete: Policy,
970}
971
972impl Default for Permissions {
973 fn default() -> Self {
974 Permissions {
978 list: Access::Member.into(),
979 read: Access::Member.into(),
980 create: Access::Member.into(),
981 update: Access::Member.into(),
982 delete: Access::Member.into(),
983 }
984 }
985}
986
987#[derive(Deserialize)]
988struct PermissionsRaw {
989 list: Option<String>,
990 read: Option<String>,
991 create: Option<String>,
992 update: Option<String>,
993 delete: Option<String>,
994}
995
996impl From<PermissionsRaw> for Permissions {
997 fn from(r: PermissionsRaw) -> Self {
998 let d = Permissions::default();
999 Permissions {
1000 list: r.list.map(|s| Policy::parse(&s)).unwrap_or(d.list),
1001 read: r.read.map(|s| Policy::parse(&s)).unwrap_or(d.read),
1002 create: r.create.map(|s| Policy::parse(&s)).unwrap_or(d.create),
1003 update: r.update.map(|s| Policy::parse(&s)).unwrap_or(d.update),
1004 delete: r.delete.map(|s| Policy::parse(&s)).unwrap_or(d.delete),
1005 }
1006 }
1007}
1008
1009#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1011pub enum CrudAction {
1012 List,
1013 Read,
1014 Create,
1015 Update,
1016 Delete,
1017}
1018
1019impl CrudAction {
1020 pub const ALL: [CrudAction; 5] = [
1022 CrudAction::List,
1023 CrudAction::Read,
1024 CrudAction::Create,
1025 CrudAction::Update,
1026 CrudAction::Delete,
1027 ];
1028
1029 pub fn as_str(self) -> &'static str {
1031 match self {
1032 CrudAction::List => "list",
1033 CrudAction::Read => "read",
1034 CrudAction::Create => "create",
1035 CrudAction::Update => "update",
1036 CrudAction::Delete => "delete",
1037 }
1038 }
1039}
1040
1041#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1054pub enum RateLimitRule {
1055 #[default]
1057 Inherit,
1058 Off,
1060 Limit { requests: u32, window_secs: u64 },
1062}
1063
1064impl RateLimitRule {
1065 pub fn parse(s: &str) -> Option<RateLimitRule> {
1069 let s = s.trim();
1070 match s.to_ascii_lowercase().as_str() {
1071 "" | "inherit" | "default" => return Some(RateLimitRule::Inherit),
1072 "off" | "none" | "unlimited" => return Some(RateLimitRule::Off),
1073 _ => {}
1074 }
1075
1076 let (requests, window) = s.split_once('/')?;
1077 let requests: u32 = requests.trim().parse().ok()?;
1078 if requests == 0 {
1081 return None;
1082 }
1083 Some(RateLimitRule::Limit {
1084 requests,
1085 window_secs: parse_window(window.trim())?,
1086 })
1087 }
1088
1089 pub fn as_string(&self) -> String {
1091 match self {
1092 RateLimitRule::Inherit => "inherit".to_string(),
1093 RateLimitRule::Off => "off".to_string(),
1094 RateLimitRule::Limit {
1095 requests,
1096 window_secs,
1097 } => format!("{requests}/{}", format_window(*window_secs)),
1098 }
1099 }
1100
1101 pub fn or(self, fallback: RateLimitRule) -> RateLimitRule {
1103 match self {
1104 RateLimitRule::Inherit => fallback,
1105 decided => decided,
1106 }
1107 }
1108
1109 pub fn limit(self) -> Option<(u32, u64)> {
1111 match self {
1112 RateLimitRule::Limit {
1113 requests,
1114 window_secs,
1115 } => Some((requests, window_secs)),
1116 _ => None,
1117 }
1118 }
1119}
1120
1121fn parse_window(window: &str) -> Option<u64> {
1124 let window = window.to_ascii_lowercase();
1125 if window.is_empty() {
1128 return None;
1129 }
1130 let unit = |n: u64| match window.trim_end_matches(char::is_alphabetic) {
1131 "" => Some(n),
1132 digits => digits.parse::<u64>().ok().map(|count| count * n),
1133 };
1134 let seconds = match window.trim_matches(char::is_numeric) {
1135 "" | "s" | "sec" | "secs" | "second" | "seconds" => unit(1),
1136 "m" | "min" | "mins" | "minute" | "minutes" => unit(60),
1137 "h" | "hr" | "hrs" | "hour" | "hours" => unit(3600),
1138 "d" | "day" | "days" => unit(86_400),
1139 _ => None,
1140 }?;
1141 (seconds > 0).then_some(seconds)
1144}
1145
1146fn format_window(seconds: u64) -> String {
1149 for (size, suffix) in [(86_400, 'd'), (3600, 'h'), (60, 'm')] {
1150 if seconds % size == 0 {
1151 return format!("{}{suffix}", seconds / size);
1152 }
1153 }
1154 format!("{seconds}s")
1155}
1156
1157impl<'de> Deserialize<'de> for RateLimitRule {
1158 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
1159 let raw = String::deserialize(de)?;
1160 RateLimitRule::parse(&raw).ok_or_else(|| {
1161 serde::de::Error::custom(format!(
1162 "`{raw}` is not a rate limit: write `<requests>/<window>` \
1163 (`100/1m`, `30/30s`, `1000/1h`), `off`, or `inherit`"
1164 ))
1165 })
1166 }
1167}
1168
1169#[derive(Debug, Clone, Default, Deserialize)]
1179#[serde(default, deny_unknown_fields)]
1180pub struct RateLimits {
1181 pub all: RateLimitRule,
1183 pub list: RateLimitRule,
1184 pub read: RateLimitRule,
1185 pub create: RateLimitRule,
1186 pub update: RateLimitRule,
1187 pub delete: RateLimitRule,
1188}
1189
1190impl RateLimits {
1191 pub fn for_action(&self, action: CrudAction) -> RateLimitRule {
1194 let own = match action {
1195 CrudAction::List => self.list,
1196 CrudAction::Read => self.read,
1197 CrudAction::Create => self.create,
1198 CrudAction::Update => self.update,
1199 CrudAction::Delete => self.delete,
1200 };
1201 own.or(self.all)
1202 }
1203
1204 pub fn is_empty(&self) -> bool {
1206 CrudAction::ALL
1207 .into_iter()
1208 .all(|action| self.for_action(action) == RateLimitRule::Inherit)
1209 }
1210}
1211
1212#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1219pub enum HookEvent {
1220 BeforeList,
1221 AfterList,
1222 BeforeRead,
1223 AfterRead,
1224 BeforeCreate,
1225 AfterCreate,
1226 BeforeUpdate,
1227 AfterUpdate,
1228 BeforeDelete,
1229 AfterDelete,
1230}
1231
1232impl HookEvent {
1233 pub const ALL: [HookEvent; 10] = [
1235 HookEvent::BeforeList,
1236 HookEvent::AfterList,
1237 HookEvent::BeforeRead,
1238 HookEvent::AfterRead,
1239 HookEvent::BeforeCreate,
1240 HookEvent::AfterCreate,
1241 HookEvent::BeforeUpdate,
1242 HookEvent::AfterUpdate,
1243 HookEvent::BeforeDelete,
1244 HookEvent::AfterDelete,
1245 ];
1246
1247 pub fn as_str(self) -> &'static str {
1249 match self {
1250 HookEvent::BeforeList => "before_list",
1251 HookEvent::AfterList => "after_list",
1252 HookEvent::BeforeRead => "before_read",
1253 HookEvent::AfterRead => "after_read",
1254 HookEvent::BeforeCreate => "before_create",
1255 HookEvent::AfterCreate => "after_create",
1256 HookEvent::BeforeUpdate => "before_update",
1257 HookEvent::AfterUpdate => "after_update",
1258 HookEvent::BeforeDelete => "before_delete",
1259 HookEvent::AfterDelete => "after_delete",
1260 }
1261 }
1262
1263 pub fn action(self) -> &'static str {
1265 match self {
1266 HookEvent::BeforeList | HookEvent::AfterList => "list",
1267 HookEvent::BeforeRead | HookEvent::AfterRead => "read",
1268 HookEvent::BeforeCreate | HookEvent::AfterCreate => "create",
1269 HookEvent::BeforeUpdate | HookEvent::AfterUpdate => "update",
1270 HookEvent::BeforeDelete | HookEvent::AfterDelete => "delete",
1271 }
1272 }
1273
1274 pub fn phase(self) -> &'static str {
1276 if self.is_before() {
1277 "before"
1278 } else {
1279 "after"
1280 }
1281 }
1282
1283 pub fn is_before(self) -> bool {
1285 matches!(
1286 self,
1287 HookEvent::BeforeList
1288 | HookEvent::BeforeRead
1289 | HookEvent::BeforeCreate
1290 | HookEvent::BeforeUpdate
1291 | HookEvent::BeforeDelete
1292 )
1293 }
1294}
1295
1296#[derive(Debug, Clone, Default, Deserialize)]
1318#[serde(default, deny_unknown_fields)]
1319pub struct Publishes {
1320 pub after_create: Option<String>,
1321 pub after_update: Option<String>,
1322 pub after_delete: Option<String>,
1323}
1324
1325impl Publishes {
1326 pub fn get(&self, event: HookEvent) -> Option<&str> {
1328 let slot = match event {
1329 HookEvent::AfterCreate => &self.after_create,
1330 HookEvent::AfterUpdate => &self.after_update,
1331 HookEvent::AfterDelete => &self.after_delete,
1332 _ => &None,
1333 };
1334 slot.as_deref().map(str::trim).filter(|t| !t.is_empty())
1335 }
1336
1337 pub fn iter(&self) -> impl Iterator<Item = (HookEvent, &str)> {
1339 [
1340 HookEvent::AfterCreate,
1341 HookEvent::AfterUpdate,
1342 HookEvent::AfterDelete,
1343 ]
1344 .into_iter()
1345 .filter_map(|event| self.get(event).map(|topic| (event, topic)))
1346 }
1347
1348 pub fn is_empty(&self) -> bool {
1349 self.iter().next().is_none()
1350 }
1351}
1352
1353#[derive(Debug, Clone, Default, Deserialize)]
1362#[serde(default, deny_unknown_fields)]
1363pub struct Hooks {
1364 pub before_list: Option<String>,
1365 pub after_list: Option<String>,
1366 pub before_read: Option<String>,
1367 pub after_read: Option<String>,
1368 pub before_create: Option<String>,
1369 pub after_create: Option<String>,
1370 pub before_update: Option<String>,
1371 pub after_update: Option<String>,
1372 pub before_delete: Option<String>,
1373 pub after_delete: Option<String>,
1374 pub before_register: Option<String>,
1375 pub after_register: Option<String>,
1376 pub before_login: Option<String>,
1377 pub after_login: Option<String>,
1378 pub before_api_key: Option<String>,
1379 pub after_api_key: Option<String>,
1380}
1381
1382impl Hooks {
1383 pub fn get(&self, event: HookEvent) -> Option<&str> {
1385 let slot = match event {
1386 HookEvent::BeforeList => &self.before_list,
1387 HookEvent::AfterList => &self.after_list,
1388 HookEvent::BeforeRead => &self.before_read,
1389 HookEvent::AfterRead => &self.after_read,
1390 HookEvent::BeforeCreate => &self.before_create,
1391 HookEvent::AfterCreate => &self.after_create,
1392 HookEvent::BeforeUpdate => &self.before_update,
1393 HookEvent::AfterUpdate => &self.after_update,
1394 HookEvent::BeforeDelete => &self.before_delete,
1395 HookEvent::AfterDelete => &self.after_delete,
1396 };
1397 slot.as_deref()
1398 }
1399
1400 pub fn iter(&self) -> impl Iterator<Item = (HookEvent, &str)> {
1402 HookEvent::ALL
1403 .into_iter()
1404 .filter_map(|event| self.get(event).map(|name| (event, name)))
1405 }
1406
1407 pub fn is_empty(&self) -> bool {
1409 self.iter().next().is_none() && self.auth_iter().next().is_none()
1410 }
1411}
1412
1413#[derive(Debug, Clone, Deserialize)]
1415#[serde(default)]
1416pub struct AuthSpec {
1417 pub identity_field: String,
1419 pub password_field: String,
1421 pub oauth_providers: Vec<String>,
1423}
1424
1425impl Default for AuthSpec {
1426 fn default() -> Self {
1427 AuthSpec {
1428 identity_field: "email".to_string(),
1429 password_field: "password_hash".to_string(),
1430 oauth_providers: Vec::new(),
1431 }
1432 }
1433}
1434
1435#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1443pub enum AuthEvent {
1444 BeforeRegister,
1445 AfterRegister,
1446 BeforeLogin,
1447 AfterLogin,
1448 BeforeApiKey,
1449 AfterApiKey,
1450}
1451
1452impl AuthEvent {
1453 pub const ALL: [AuthEvent; 6] = [
1455 AuthEvent::BeforeRegister,
1456 AuthEvent::AfterRegister,
1457 AuthEvent::BeforeLogin,
1458 AuthEvent::AfterLogin,
1459 AuthEvent::BeforeApiKey,
1460 AuthEvent::AfterApiKey,
1461 ];
1462
1463 pub fn as_str(self) -> &'static str {
1465 match self {
1466 AuthEvent::BeforeRegister => "before_register",
1467 AuthEvent::AfterRegister => "after_register",
1468 AuthEvent::BeforeLogin => "before_login",
1469 AuthEvent::AfterLogin => "after_login",
1470 AuthEvent::BeforeApiKey => "before_api_key",
1471 AuthEvent::AfterApiKey => "after_api_key",
1472 }
1473 }
1474
1475 pub fn action(self) -> &'static str {
1477 match self {
1478 AuthEvent::BeforeRegister | AuthEvent::AfterRegister => "register",
1479 AuthEvent::BeforeLogin | AuthEvent::AfterLogin => "login",
1480 AuthEvent::BeforeApiKey | AuthEvent::AfterApiKey => "api_key",
1481 }
1482 }
1483
1484 pub fn phase(self) -> &'static str {
1486 if self.is_before() {
1487 "before"
1488 } else {
1489 "after"
1490 }
1491 }
1492
1493 pub fn is_before(self) -> bool {
1495 matches!(
1496 self,
1497 AuthEvent::BeforeRegister | AuthEvent::BeforeLogin | AuthEvent::BeforeApiKey
1498 )
1499 }
1500}
1501
1502impl Hooks {
1503 pub fn get_auth(&self, event: AuthEvent) -> Option<&str> {
1505 let slot = match event {
1506 AuthEvent::BeforeRegister => &self.before_register,
1507 AuthEvent::AfterRegister => &self.after_register,
1508 AuthEvent::BeforeLogin => &self.before_login,
1509 AuthEvent::AfterLogin => &self.after_login,
1510 AuthEvent::BeforeApiKey => &self.before_api_key,
1511 AuthEvent::AfterApiKey => &self.after_api_key,
1512 };
1513 slot.as_deref()
1514 }
1515
1516 pub fn auth_iter(&self) -> impl Iterator<Item = (AuthEvent, &str)> {
1518 AuthEvent::ALL
1519 .into_iter()
1520 .filter_map(|event| self.get_auth(event).map(|name| (event, name)))
1521 }
1522}
1523
1524#[cfg(test)]
1525mod tests {
1526 use super::*;
1527
1528 fn parse_resource(src: &str) -> Resource {
1529 let resource: Resource = toml::from_str(src).unwrap();
1530 resource.validate().unwrap();
1531 resource
1532 }
1533
1534 #[test]
1536 fn a_case_is_only_forced_on_text_fields() {
1537 let resource = parse_resource(
1538 r#"
1539[resource]
1540name = "order"
1541
1542[fields.currency]
1543type = "string"
1544case = "upper"
1545
1546[fields.note]
1547type = "text"
1548case = "lower"
1549
1550[fields.reference]
1551type = "string"
1552
1553# Nonsense, and quietly ignored rather than upper-casing the digits.
1554[fields.amount]
1555type = "big_int"
1556case = "upper"
1557"#,
1558 );
1559
1560 let case = |name: &str| resource.fields.get(name).unwrap().text_case();
1561 assert_eq!(case("currency"), Some(TextCase::Upper));
1562 assert_eq!(case("note"), Some(TextCase::Lower));
1563 assert_eq!(case("reference"), None);
1564 assert_eq!(case("amount"), None, "a number has no case to force");
1565
1566 let mut cased: Vec<&str> = resource
1567 .cased_fields()
1568 .map(|(name, _)| name.as_str())
1569 .collect();
1570 cased.sort();
1571 assert_eq!(cased, ["currency", "note"]);
1572 }
1573
1574 #[test]
1577 fn a_row_is_returned_in_the_case_its_fields_declare() {
1578 let resource = parse_resource(
1579 r#"
1580[resource]
1581name = "order"
1582
1583[fields.currency]
1584type = "string"
1585case = "upper"
1586
1587[fields.note]
1588type = "text"
1589case = "lower"
1590
1591[fields.reference]
1592type = "string"
1593"#,
1594 );
1595
1596 let mut row = serde_json::json!({
1597 "currency": "eur",
1598 "note": "SHIPPED LATE",
1599 "reference": "Left Alone",
1600 "missing": null,
1601 });
1602 resource.apply_text_case(&mut row);
1603
1604 assert_eq!(row["currency"], "EUR");
1605 assert_eq!(row["note"], "shipped late");
1606 assert_eq!(row["reference"], "Left Alone", "no case declared");
1607 assert!(row["missing"].is_null());
1609 }
1610
1611 #[test]
1613 fn a_resource_with_no_cased_fields_is_untouched() {
1614 let resource = parse_resource(
1615 r#"
1616[resource]
1617name = "note"
1618
1619[fields.title]
1620type = "string"
1621"#,
1622 );
1623 assert_eq!(resource.cased_fields().count(), 0);
1624
1625 let before = serde_json::json!({ "title": "Mixed Case Stays" });
1626 let mut row = before.clone();
1627 resource.apply_text_case(&mut row);
1628 assert_eq!(row, before);
1629 }
1630
1631 #[test]
1632 fn a_rate_limit_is_a_count_over_a_window_however_the_window_is_spelled() {
1633 let limit = |requests, window_secs| {
1634 Some(RateLimitRule::Limit {
1635 requests,
1636 window_secs,
1637 })
1638 };
1639 assert_eq!(RateLimitRule::parse("100/60"), limit(100, 60));
1641 assert_eq!(RateLimitRule::parse("100/60s"), limit(100, 60));
1642 assert_eq!(RateLimitRule::parse("100/1m"), limit(100, 60));
1643 assert_eq!(RateLimitRule::parse("100/minute"), limit(100, 60));
1644 assert_eq!(RateLimitRule::parse("5/2h"), limit(5, 7200));
1645 assert_eq!(RateLimitRule::parse("5/1d"), limit(5, 86_400));
1646 assert_eq!(RateLimitRule::parse(" 30 / 30S "), limit(30, 30));
1648
1649 assert_eq!(RateLimitRule::parse("off"), Some(RateLimitRule::Off));
1650 assert_eq!(RateLimitRule::parse("none"), Some(RateLimitRule::Off));
1651 assert_eq!(RateLimitRule::parse(""), Some(RateLimitRule::Inherit));
1652 assert_eq!(
1653 RateLimitRule::parse("inherit"),
1654 Some(RateLimitRule::Inherit)
1655 );
1656 }
1657
1658 #[test]
1659 fn a_rate_limit_that_says_nothing_enforceable_is_refused_rather_than_guessed() {
1660 for spelling in ["100", "/60", "abc/60", "100/", "100/0", "0/60", "100/2x"] {
1663 assert_eq!(
1664 RateLimitRule::parse(spelling),
1665 None,
1666 "`{spelling}` should not parse as a rate limit"
1667 );
1668 }
1669 let error = toml::from_str::<RateLimits>("all = \"100\"").unwrap_err();
1671 assert!(
1672 error.to_string().contains("not a rate limit"),
1673 "unhelpful error: {error}"
1674 );
1675 }
1676
1677 #[test]
1678 fn a_rate_limit_reads_back_in_the_units_it_was_written_in() {
1679 for spelling in ["100/1m", "5/30s", "10/2h", "1/1d"] {
1680 assert_eq!(
1681 RateLimitRule::parse(spelling).unwrap().as_string(),
1682 spelling
1683 );
1684 }
1685 }
1686
1687 #[test]
1688 fn a_per_action_rate_limit_beats_the_resource_wide_one() {
1689 let resource = parse_resource(
1690 r#"
1691[resource]
1692name = "post"
1693
1694[fields.title]
1695type = "string"
1696
1697[rate_limit]
1698all = "100/1m"
1699create = "5/1m"
1700delete = "off"
1701"#,
1702 );
1703 let limits = &resource.rate_limit;
1704 assert_eq!(
1707 limits.for_action(CrudAction::Create),
1708 RateLimitRule::parse("5/1m").unwrap()
1709 );
1710 assert_eq!(
1711 limits.for_action(CrudAction::List),
1712 RateLimitRule::parse("100/1m").unwrap()
1713 );
1714 assert_eq!(limits.for_action(CrudAction::Delete), RateLimitRule::Off);
1715 assert!(!limits.is_empty());
1716
1717 let quiet = parse_resource(
1719 r#"
1720[resource]
1721name = "page"
1722
1723[fields.title]
1724type = "string"
1725"#,
1726 );
1727 assert!(quiet.rate_limit.is_empty());
1728 assert_eq!(
1729 quiet.rate_limit.for_action(CrudAction::List),
1730 RateLimitRule::Inherit
1731 );
1732 }
1733
1734 #[test]
1735 fn access_parser_and_permissions_defaults_match_org_membership_resource() {
1736 assert_eq!(Access::parse("public"), Access::Public);
1737 assert_eq!(Access::parse("authenticated"), Access::Authenticated);
1738 assert_eq!(Access::parse("member"), Access::Member);
1739 assert_eq!(Access::parse("owner"), Access::Owner);
1740 assert_eq!(Access::parse("role:admin"), Access::Role("admin".into()));
1741 assert_eq!(Access::parse("wat"), Access::Private);
1742
1743 let defaults = Permissions::default();
1744 assert_eq!(defaults.list, Access::Member.into());
1745 assert_eq!(defaults.read, Access::Member.into());
1746 assert_eq!(defaults.create, Access::Member.into());
1747 assert_eq!(defaults.update, Access::Member.into());
1748 assert_eq!(defaults.delete, Access::Member.into());
1749 }
1750
1751 #[test]
1752 fn a_policy_may_be_narrowed_to_an_organisation_class() {
1753 let plain = Policy::parse("role:admin");
1754 assert_eq!(plain.level, Access::Role("admin".into()));
1755 assert_eq!(plain.org_class, None);
1756 assert!(plain.matches_org_class(None));
1759 assert!(plain.matches_org_class(Some("school")));
1760
1761 let schools = Policy::parse("role:admin@org_class=school");
1762 assert_eq!(schools.level, Access::Role("admin".into()));
1763 assert_eq!(schools.org_class.as_deref(), Some("school"));
1764 assert!(schools.matches_org_class(Some("school")));
1765 assert!(!schools.matches_org_class(Some("staff")));
1766 assert!(!schools.matches_org_class(None));
1767
1768 assert_eq!(
1770 Policy::parse("member@org_class=staff"),
1771 Policy {
1772 level: Access::Member,
1773 org_class: Some("staff".into()),
1774 }
1775 );
1776
1777 for written in ["public", "role:admin", "member@org_class=staff"] {
1779 assert_eq!(Policy::parse(written).as_string(), written);
1780 }
1781
1782 assert_eq!(
1785 Policy::parse("role:admin@org_class=").level,
1786 Access::Private
1787 );
1788 assert_eq!(Policy::parse("role:admin@org_class= ").org_class, None);
1789 assert_eq!(Policy::parse("wat@org_class=school").level, Access::Private);
1792 }
1793
1794 #[test]
1795 fn a_class_qualified_permission_parses_from_toml() {
1796 let resource: Resource = toml::from_str(
1797 r#"
1798[resource]
1799name = "report"
1800
1801[permissions]
1802list = "member"
1803update = "role:admin@org_class=school"
1804
1805[fields.title]
1806type = "string"
1807"#,
1808 )
1809 .unwrap();
1810 assert_eq!(resource.permissions.list, Access::Member.into());
1811 assert_eq!(
1812 resource.permissions.update.org_class.as_deref(),
1813 Some("school")
1814 );
1815 assert_eq!(resource.permissions.delete, Access::Member.into());
1817 }
1818
1819 #[test]
1820 fn validate_rejects_reserved_id_and_dangling_reference_definition() {
1821 let reserved_id: Resource = toml::from_str(
1822 r#"
1823[resource]
1824name = "bad"
1825
1826[fields.id]
1827type = "string"
1828"#,
1829 )
1830 .unwrap();
1831 assert!(reserved_id.validate().is_err());
1832
1833 let missing_target: Resource = toml::from_str(
1834 r#"
1835[resource]
1836name = "bad_ref"
1837
1838[fields.owner_id]
1839type = "reference"
1840"#,
1841 )
1842 .unwrap();
1843 assert!(missing_target.validate().is_err());
1844 }
1845
1846 #[test]
1847 fn content_format_is_only_allowed_on_text_fields() {
1848 let good: Resource = toml::from_str(
1849 r#"
1850[resource]
1851name = "article"
1852
1853[fields.body]
1854type = "text"
1855
1856[fields.body.admin]
1857format = "markdown"
1858"#,
1859 )
1860 .unwrap();
1861 good.validate().unwrap();
1862 assert_eq!(good.fields["body"].admin.format, ContentFormat::Markdown);
1863
1864 let bad: Resource = toml::from_str(
1865 r#"
1866[resource]
1867name = "article"
1868
1869[fields.published]
1870type = "boolean"
1871
1872[fields.published.admin]
1873format = "html"
1874"#,
1875 )
1876 .unwrap();
1877 assert!(bad.validate().is_err());
1878 }
1879
1880 #[test]
1881 fn references_derive_relation_names_and_default_on_delete() {
1882 let resource = parse_resource(
1883 r#"
1884[resource]
1885name = "comment"
1886
1887[fields.post_id]
1888type = "reference"
1889references = "post"
1890required = true
1891
1892[fields.author_id]
1893type = "reference"
1894references = "user"
1895on_delete = "cascade"
1896"#,
1897 );
1898
1899 assert_eq!(relation_name("owner_id"), "owner");
1900 assert_eq!(relation_name("slug"), "slug");
1901
1902 let refs = resource.references();
1903 assert_eq!(refs.len(), 2);
1904 let post = refs.iter().find(|rf| rf.field == "post_id").unwrap();
1905 assert_eq!(post.target, "post");
1906 assert_eq!(post.relation, "post");
1907 assert_eq!(post.on_delete, OnDelete::Restrict);
1908 assert!(post.required);
1909
1910 let author = resource.reference_by_relation("author").unwrap();
1911 assert_eq!(author.field, "author_id");
1912 assert_eq!(author.on_delete, OnDelete::Cascade);
1913 }
1914
1915 #[test]
1916 fn hooks_parse_per_event_and_iterate_in_lifecycle_order() {
1917 let resource = parse_resource(
1918 r#"
1919[resource]
1920name = "post"
1921
1922[fields.title]
1923type = "string"
1924
1925[hooks]
1926before_create = "validate_post"
1927after_create = "notify"
1928after_list = "redact"
1929"#,
1930 );
1931
1932 assert_eq!(
1933 resource.hook(HookEvent::BeforeCreate),
1934 Some("validate_post")
1935 );
1936 assert_eq!(resource.hook(HookEvent::AfterCreate), Some("notify"));
1937 assert_eq!(resource.hook(HookEvent::AfterList), Some("redact"));
1938 assert_eq!(resource.hook(HookEvent::BeforeUpdate), None);
1939 assert!(!resource.hooks.is_empty());
1940
1941 let declared: Vec<_> = resource.hooks.iter().collect();
1942 assert_eq!(
1943 declared,
1944 vec![
1945 (HookEvent::AfterList, "redact"),
1946 (HookEvent::BeforeCreate, "validate_post"),
1947 (HookEvent::AfterCreate, "notify"),
1948 ]
1949 );
1950 }
1951
1952 #[test]
1953 fn hook_events_expose_wire_names_actions_and_phases() {
1954 assert_eq!(HookEvent::BeforeCreate.as_str(), "before_create");
1955 assert_eq!(HookEvent::BeforeCreate.action(), "create");
1956 assert_eq!(HookEvent::BeforeCreate.phase(), "before");
1957 assert!(HookEvent::BeforeCreate.is_before());
1958
1959 assert_eq!(HookEvent::AfterList.as_str(), "after_list");
1960 assert_eq!(HookEvent::AfterList.action(), "list");
1961 assert_eq!(HookEvent::AfterList.phase(), "after");
1962 assert!(!HookEvent::AfterList.is_before());
1963
1964 for event in HookEvent::ALL {
1966 let resource = parse_resource(&format!(
1967 "[resource]\nname = \"post\"\n\n[hooks]\n{} = \"h\"\n",
1968 event.as_str()
1969 ));
1970 assert_eq!(resource.hook(event), Some("h"), "{}", event.as_str());
1971 assert_eq!(resource.hooks.iter().count(), 1);
1972 }
1973 }
1974
1975 #[test]
1976 fn hooks_reject_typos_and_empty_function_names() {
1977 let typo = toml::from_str::<Resource>(
1978 r#"
1979[resource]
1980name = "post"
1981
1982[hooks]
1983befor_create = "oops"
1984"#,
1985 );
1986 assert!(typo.is_err(), "unknown hook keys must not be ignored");
1987
1988 let empty: Resource = toml::from_str(
1989 r#"
1990[resource]
1991name = "post"
1992
1993[hooks]
1994after_delete = " "
1995"#,
1996 )
1997 .unwrap();
1998 assert!(empty.validate().is_err());
1999 }
2000
2001 #[test]
2002 fn auth_events_expose_wire_names_actions_and_phases() {
2003 assert_eq!(AuthEvent::BeforeLogin.as_str(), "before_login");
2004 assert_eq!(AuthEvent::BeforeLogin.action(), "login");
2005 assert_eq!(AuthEvent::BeforeLogin.phase(), "before");
2006 assert!(AuthEvent::BeforeLogin.is_before());
2007
2008 assert_eq!(AuthEvent::AfterApiKey.action(), "api_key");
2009 assert_eq!(AuthEvent::AfterApiKey.phase(), "after");
2010 assert!(!AuthEvent::AfterApiKey.is_before());
2011
2012 for event in AuthEvent::ALL {
2015 let resource = parse_resource(&format!(
2016 "[resource]\nname = \"user\"\n\n[hooks]\nafter_create = \"c\"\n{} = \"h\"\n",
2017 event.as_str()
2018 ));
2019 assert_eq!(resource.auth_hook(event), Some("h"), "{}", event.as_str());
2020 assert_eq!(resource.hook(HookEvent::AfterCreate), Some("c"));
2021 assert_eq!(resource.hooks.auth_iter().count(), 1);
2022 assert_eq!(resource.hooks.iter().count(), 1);
2025 }
2026 }
2027
2028 #[test]
2029 fn auth_hooks_are_absent_by_default_and_reject_typos_and_empty_names() {
2030 let plain =
2031 parse_resource("[resource]\nname = \"user\"\n\n[hooks]\nafter_create = \"c\"\n");
2032 assert_eq!(plain.auth_hook(AuthEvent::BeforeLogin), None);
2033 assert!(parse_resource("[resource]\nname = \"user\"\n")
2034 .hooks
2035 .is_empty());
2036
2037 let typo = toml::from_str::<Resource>(
2038 "[resource]\nname = \"user\"\n\n[hooks]\nbefore_signin = \"oops\"\n",
2039 );
2040 assert!(typo.is_err(), "unknown auth hook keys must not be ignored");
2041
2042 let empty: Resource =
2043 toml::from_str("[resource]\nname = \"user\"\n\n[hooks]\nafter_login = \" \"\n")
2044 .unwrap();
2045 assert!(empty.validate().is_err());
2046 }
2047
2048 #[test]
2049 fn auth_hooks_are_rejected_on_any_resource_but_user() {
2050 let stray: Resource =
2053 toml::from_str("[resource]\nname = \"post\"\n\n[hooks]\nbefore_login = \"h\"\n")
2054 .unwrap();
2055 let message = stray.validate().unwrap_err().to_string();
2056 assert!(message.contains("before_login"), "{message}");
2057 assert!(message.contains("user"), "{message}");
2058 }
2059
2060 #[test]
2061 fn resources_have_no_hooks_by_default() {
2062 let resource = parse_resource("[resource]\nname = \"post\"\n");
2063 assert!(resource.hooks.is_empty());
2064 assert!(HookEvent::ALL
2065 .into_iter()
2066 .all(|event| resource.hook(event).is_none()));
2067 }
2068
2069 #[test]
2070 fn admin_visibility_defaults_hide_auth_resources_but_nothing_else() {
2071 let post = parse_resource("[resource]\nname = \"post\"\n");
2072 assert!(post.admin.is_visible("post"));
2073
2074 let user = parse_resource(crate::defaults::USER_TOML);
2075 assert!(!user.admin.is_visible("user"));
2076 assert!(!parse_resource(crate::defaults::MEMBERSHIP_TOML)
2077 .admin
2078 .is_visible("membership"));
2079
2080 let replaced = parse_resource(
2082 "[resource]\nname = \"user\"\nscope = \"global\"\n\n[fields.email]\ntype = \"string\"\n",
2083 );
2084 assert!(!replaced.admin.is_visible("user"));
2085
2086 let opted_in = parse_resource(
2088 "[resource]\nname = \"user\"\nscope = \"global\"\n\n[admin]\nvisible = true\n",
2089 );
2090 assert!(opted_in.admin.is_visible("user"));
2091 }
2092
2093 #[test]
2094 fn admin_labels_are_inferred_and_overridable() {
2095 let inferred = parse_resource("[resource]\nname = \"purchase_order\"\n");
2096 assert_eq!(inferred.admin_label(), "Purchase order");
2097 assert_eq!(inferred.admin_plural(), "Purchase orders");
2098
2099 let overridden = parse_resource(
2100 "[resource]\nname = \"person\"\n\n[admin]\nlabel = \"Person\"\nplural = \"People\"\n",
2101 );
2102 assert_eq!(overridden.admin_plural(), "People");
2103
2104 assert_eq!(titleize("owner_id"), "Owner");
2105 assert_eq!(titleize("total_cents"), "Total cents");
2106 assert_eq!(pluralize("Category"), "Categories");
2107 assert_eq!(pluralize("Address"), "Addresses");
2108 assert_eq!(pluralize("Day"), "Days");
2109 assert_eq!(pluralize("Product"), "Products");
2110 }
2111
2112 #[test]
2113 fn display_field_prefers_conventional_names_then_any_string() {
2114 let conventional = parse_resource(
2115 r#"
2116[resource]
2117name = "product"
2118
2119[fields.sku]
2120type = "string"
2121
2122[fields.name]
2123type = "string"
2124"#,
2125 );
2126 assert_eq!(conventional.admin_display_field().as_deref(), Some("name"));
2127
2128 let only_odd_names =
2129 parse_resource("[resource]\nname = \"blob\"\n\n[fields.zzz]\ntype = \"string\"\n");
2130 assert_eq!(only_odd_names.admin_display_field().as_deref(), Some("zzz"));
2131
2132 let nothing_stringy =
2133 parse_resource("[resource]\nname = \"tick\"\n\n[fields.count]\ntype = \"integer\"\n");
2134 assert_eq!(nothing_stringy.admin_display_field(), None);
2135
2136 let declared = parse_resource(
2138 r#"
2139[resource]
2140name = "product"
2141
2142[admin]
2143display_field = "sku"
2144
2145[fields.sku]
2146type = "string"
2147
2148[fields.name]
2149type = "string"
2150"#,
2151 );
2152 assert_eq!(declared.admin_display_field().as_deref(), Some("sku"));
2153 assert_eq!(declared.admin_search_field().as_deref(), Some("sku"));
2155 }
2156
2157 #[test]
2158 fn inferred_columns_skip_blobs_and_dashboard_hidden_fields() {
2159 let resource = parse_resource(
2160 r#"
2161[resource]
2162name = "product"
2163
2164[fields.name]
2165type = "string"
2166
2167[fields.status]
2168type = "string"
2169
2170[fields.description]
2171type = "text"
2172
2173[fields.attributes]
2174type = "json"
2175
2176[fields.secret_ratio]
2177type = "float"
2178
2179[fields.secret_ratio.admin]
2180visible = false
2181"#,
2182 );
2183 assert_eq!(resource.admin_columns(), vec!["name", "status"]);
2184
2185 let declared = parse_resource(
2186 r#"
2187[resource]
2188name = "product"
2189
2190[admin]
2191columns = ["status", "name"]
2192
2193[fields.name]
2194type = "string"
2195
2196[fields.status]
2197type = "string"
2198"#,
2199 );
2200 assert_eq!(declared.admin_columns(), vec!["status", "name"]);
2201 }
2202
2203 #[test]
2204 fn admin_section_rejects_columns_naming_fields_that_do_not_exist() {
2205 let bad_column: Resource = toml::from_str(
2206 "[resource]\nname = \"post\"\n\n[admin]\ncolumns = [\"nope\"]\n\n[fields.title]\ntype = \"string\"\n",
2207 )
2208 .unwrap();
2209 assert!(bad_column.validate().is_err());
2210
2211 let bad_display: Resource = toml::from_str(
2212 "[resource]\nname = \"post\"\n\n[admin]\ndisplay_field = \"nope\"\n\n[fields.title]\ntype = \"string\"\n",
2213 )
2214 .unwrap();
2215 assert!(bad_display.validate().is_err());
2216
2217 let unsearchable: Resource = toml::from_str(
2220 "[resource]\nname = \"tick\"\n\n[admin]\nsearch_field = \"count\"\n\n[fields.count]\ntype = \"integer\"\n",
2221 )
2222 .unwrap();
2223 assert!(unsearchable.validate().is_err());
2224
2225 let named_by_a_number = parse_resource(
2228 "[resource]\nname = \"tick\"\n\n[admin]\ndisplay_field = \"count\"\n\n[fields.count]\ntype = \"integer\"\n",
2229 );
2230 assert_eq!(
2231 named_by_a_number.admin_display_field().as_deref(),
2232 Some("count")
2233 );
2234 assert_eq!(named_by_a_number.admin_search_field(), None);
2235
2236 let multi = parse_resource(
2239 "[resource]\nname = \"post\"\n\n[admin]\nsearch_fields = [\"title\", \"body\"]\n\n[fields.title]\ntype = \"string\"\n\n[fields.body]\ntype = \"text\"\n",
2240 );
2241 assert_eq!(multi.admin_search_fields(), vec!["title", "body"]);
2242
2243 let single =
2246 parse_resource("[resource]\nname = \"post\"\n\n[fields.title]\ntype = \"string\"\n");
2247 assert_eq!(single.admin_search_fields(), vec!["title"]);
2248
2249 for bad in [
2250 "[resource]\nname = \"post\"\n\n[admin]\nsearch_fields = [\"nope\"]\n\n[fields.title]\ntype = \"string\"\n",
2251 "[resource]\nname = \"post\"\n\n[admin]\nsearch_fields = [\"views\"]\n\n[fields.views]\ntype = \"integer\"\n",
2252 "[resource]\nname = \"post\"\n\n[admin]\nsearch_fields = [\"secret\"]\n\n[fields.secret]\ntype = \"string\"\nhidden = true\n",
2253 ] {
2254 let resource: Resource = toml::from_str(bad).unwrap();
2255 assert!(resource.validate().is_err(), "{bad} should not load");
2256 }
2257
2258 assert!(toml::from_str::<Resource>(
2260 "[resource]\nname = \"post\"\n\n[admin]\nvisibel = true\n"
2261 )
2262 .is_err());
2263 }
2264
2265 #[test]
2266 fn field_admin_carries_widget_options_and_visibility() {
2267 let resource = parse_resource(
2268 r#"
2269[resource]
2270name = "product"
2271
2272[fields.status]
2273type = "string"
2274
2275[fields.status.admin]
2276label = "Lifecycle"
2277widget = "select"
2278options = ["draft", "active|Live"]
2279readonly = true
2280"#,
2281 );
2282 let status = &resource.fields["status"];
2283 assert_eq!(status.admin.label.as_deref(), Some("Lifecycle"));
2284 assert_eq!(status.admin.widget, Widget::Select);
2285 assert_eq!(status.admin.widget.as_str(), "select");
2286 assert_eq!(status.admin.options, vec!["draft", "active|Live"]);
2287 assert!(status.admin.readonly);
2288 assert!(status.admin.visible);
2290 assert_eq!(resource.fields["status"].admin.help, None);
2291 }
2292
2293 #[test]
2294 fn org_column_reflects_resource_scope() {
2295 let org_scoped = parse_resource(
2296 r#"
2297[resource]
2298name = "post"
2299
2300[fields.title]
2301type = "string"
2302"#,
2303 );
2304 let global = parse_resource(
2305 r#"
2306[resource]
2307name = "plan"
2308scope = "global"
2309
2310[fields.name]
2311type = "string"
2312"#,
2313 );
2314 let organization = parse_resource(crate::defaults::ORGANIZATION_TOML);
2315
2316 assert_eq!(org_scoped.org_column(), Some("organization_id"));
2317 assert_eq!(global.org_column(), None);
2318 assert_eq!(organization.org_column(), Some("id"));
2319 }
2320}