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 Resource {
67 pub fn table_name(&self) -> String {
69 self.meta
70 .table
71 .clone()
72 .unwrap_or_else(|| format!("apiplant_{}", self.meta.name))
73 }
74
75 pub fn hook(&self, event: HookEvent) -> Option<&str> {
77 self.hooks.get(event)
78 }
79
80 pub fn auth_hook(&self, event: AuthEvent) -> Option<&str> {
83 self.hooks.get_auth(event)
84 }
85
86 pub fn validate(&self) -> crate::Result<()> {
88 for (event, function) in self.hooks.iter() {
89 if function.trim().is_empty() {
90 return Err(crate::Error::Schema {
91 resource: self.meta.name.clone(),
92 message: format!("hook `{}` names an empty function", event.as_str()),
93 });
94 }
95 }
96 for (event, function) in self.hooks.auth_iter() {
97 if function.trim().is_empty() {
98 return Err(crate::Error::Schema {
99 resource: self.meta.name.clone(),
100 message: format!("hook `{}` names an empty function", event.as_str()),
101 });
102 }
103 if self.meta.name != "user" {
106 return Err(crate::Error::Schema {
107 resource: self.meta.name.clone(),
108 message: format!(
109 "hook `{}` only exists on the `user` resource, which owns the auth endpoints",
110 event.as_str()
111 ),
112 });
113 }
114 }
115 for (event, topic) in self.publish.iter() {
119 if !crate::QueuesConfig::valid_topic(topic) {
120 return Err(crate::Error::Schema {
121 resource: self.meta.name.clone(),
122 message: format!(
123 "`publish.{}` names `{topic}`, which is not a topic: use letters, \
124 digits, `.`, `_`, `-` or `:`",
125 event.as_str()
126 ),
127 });
128 }
129 }
130 for (fname, field) in &self.fields {
131 if fname == "id" {
132 return Err(crate::Error::Schema {
133 resource: self.meta.name.clone(),
134 message: "`id` is reserved and added automatically".into(),
135 });
136 }
137 if field.ty == FieldType::Reference && field.references.is_none() {
138 return Err(crate::Error::Schema {
139 resource: self.meta.name.clone(),
140 message: format!("field `{fname}` is a reference without `references`"),
141 });
142 }
143 if field.admin.format != ContentFormat::Plain
144 && !matches!(field.ty, FieldType::Text | FieldType::String)
145 {
146 return Err(crate::Error::Schema {
147 resource: self.meta.name.clone(),
148 message: format!(
149 "field `{fname}` sets [admin] format = \"{}\" but is not a text field",
150 field.admin.format.as_str()
151 ),
152 });
153 }
154 }
155 for column in &self.admin.columns {
159 if !self.fields.contains_key(column) && column != "id" {
160 return Err(crate::Error::Schema {
161 resource: self.meta.name.clone(),
162 message: format!("[admin] columns names unknown field `{column}`"),
163 });
164 }
165 }
166 for (key, declared) in [
167 ("display_field", &self.admin.display_field),
168 ("search_field", &self.admin.search_field),
169 ] {
170 if let Some(field) = declared {
171 let Some(declared_field) = self.fields.get(field) else {
172 return Err(crate::Error::Schema {
173 resource: self.meta.name.clone(),
174 message: format!("[admin] {key} names unknown field `{field}`"),
175 });
176 };
177 if key == "search_field"
181 && !matches!(declared_field.ty, FieldType::String | FieldType::Text)
182 {
183 return Err(crate::Error::Schema {
184 resource: self.meta.name.clone(),
185 message: format!(
186 "[admin] search_field names `{field}`, which is not a text field and cannot be searched"
187 ),
188 });
189 }
190 }
191 }
192 for field in &self.admin.search_fields {
195 let Some(declared_field) = self.fields.get(field) else {
196 return Err(crate::Error::Schema {
197 resource: self.meta.name.clone(),
198 message: format!("[admin] search_fields names unknown field `{field}`"),
199 });
200 };
201 if !matches!(declared_field.ty, FieldType::String | FieldType::Text) {
202 return Err(crate::Error::Schema {
203 resource: self.meta.name.clone(),
204 message: format!(
205 "[admin] search_fields names `{field}`, which is not a text field and cannot be searched"
206 ),
207 });
208 }
209 if declared_field.hidden {
210 return Err(crate::Error::Schema {
211 resource: self.meta.name.clone(),
212 message: format!(
213 "[admin] search_fields names `{field}`, which is hidden — searching it would answer questions its own responses refuse"
214 ),
215 });
216 }
217 }
218 Ok(())
219 }
220
221 pub fn admin_label(&self) -> String {
223 self.admin
224 .label
225 .clone()
226 .unwrap_or_else(|| titleize(&self.meta.name))
227 }
228
229 pub fn admin_plural(&self) -> String {
231 self.admin
232 .plural
233 .clone()
234 .unwrap_or_else(|| pluralize(&self.admin_label()))
235 }
236
237 pub fn admin_display_field(&self) -> Option<String> {
243 if let Some(declared) = &self.admin.display_field {
244 if self.fields.contains_key(declared) {
245 return Some(declared.clone());
246 }
247 }
248 const PREFERRED: [&str; 7] = ["name", "title", "label", "slug", "code", "number", "email"];
249 for candidate in PREFERRED {
250 if let Some(field) = self.fields.get(candidate) {
251 if !field.hidden && matches!(field.ty, FieldType::String | FieldType::Text) {
252 return Some(candidate.to_string());
253 }
254 }
255 }
256 self.fields
257 .iter()
258 .find(|(_, field)| !field.hidden && field.ty == FieldType::String)
259 .map(|(name, _)| name.clone())
260 }
261
262 pub fn admin_search_field(&self) -> Option<String> {
268 let candidate = match &self.admin.search_field {
269 Some(declared) if self.fields.contains_key(declared) => Some(declared.clone()),
270 Some(_) | None => self.admin_display_field(),
271 };
272 candidate.filter(|name| {
273 self.fields
274 .get(name)
275 .is_some_and(|field| matches!(field.ty, FieldType::String | FieldType::Text))
276 })
277 }
278
279 pub fn admin_search_fields(&self) -> Vec<String> {
286 let declared: Vec<String> = self
287 .admin
288 .search_fields
289 .iter()
290 .filter(|name| {
291 self.fields.get(*name).is_some_and(|field| {
292 !field.hidden && matches!(field.ty, FieldType::String | FieldType::Text)
293 })
294 })
295 .cloned()
296 .collect();
297 if !declared.is_empty() {
298 return declared;
299 }
300 self.admin_search_field().into_iter().collect()
301 }
302
303 pub fn admin_columns(&self) -> Vec<String> {
309 let declared: Vec<String> = self
310 .admin
311 .columns
312 .iter()
313 .filter(|name| self.fields.contains_key(*name))
314 .cloned()
315 .collect();
316 if !declared.is_empty() {
317 return declared;
318 }
319
320 let display = self.admin_display_field();
321 let mut columns: Vec<String> = display.iter().cloned().collect();
322 for (name, field) in &self.fields {
323 if columns.len() >= 5 {
324 break;
325 }
326 let skip = field.hidden
327 || !field.admin.visible
328 || Some(name) == display.as_ref()
329 || name == "organization_id"
330 || matches!(field.ty, FieldType::Json | FieldType::Text);
331 if !skip {
332 columns.push(name.clone());
333 }
334 }
335 columns
336 }
337
338 pub fn references(&self) -> Vec<Reference> {
340 self.fields
341 .iter()
342 .filter_map(|(name, field)| {
343 if field.ty != FieldType::Reference {
344 return None;
345 }
346 let target = field.references.clone()?;
347 Some(Reference {
348 field: name.clone(),
349 target,
350 relation: relation_name(name).to_string(),
351 on_delete: field.on_delete.unwrap_or(OnDelete::Restrict),
352 required: field.required,
353 })
354 })
355 .collect()
356 }
357
358 pub fn reference_by_relation(&self, relation: &str) -> Option<Reference> {
360 self.references()
361 .into_iter()
362 .find(|r| r.relation == relation)
363 }
364
365 pub fn is_org_scoped(&self) -> bool {
367 self.meta.scope == Scope::Organization
368 }
369
370 pub fn org_column(&self) -> Option<&'static str> {
374 if self.is_org_scoped() {
375 Some("organization_id")
376 } else if self.meta.name == "organization" {
377 Some("id")
378 } else {
379 None
380 }
381 }
382
383 pub fn load(path: &Path) -> crate::Result<Self> {
385 let text = std::fs::read_to_string(path).map_err(|e| crate::Error::Io {
386 path: path.to_path_buf(),
387 source: e,
388 })?;
389 let source = path.file_name().unwrap_or_default().to_string_lossy();
392 let resource: Resource =
393 crate::env::parse_toml(&text, &source).map_err(|e| crate::Error::Toml {
394 path: path.to_path_buf(),
395 source: e,
396 })?;
397 resource.validate()?;
398 Ok(resource)
399 }
400}
401
402#[derive(Debug, Clone, Default, Deserialize)]
410#[serde(default, deny_unknown_fields)]
411pub struct ResourceAdmin {
412 pub visible: Option<bool>,
415 pub roles: Vec<String>,
418 pub label: Option<String>,
420 pub plural: Option<String>,
422 pub group: Option<String>,
424 pub display_field: Option<String>,
427 pub columns: Vec<String>,
429 pub search_field: Option<String>,
431 pub search_fields: Vec<String>,
435 pub order: i64,
437}
438
439impl ResourceAdmin {
440 pub fn is_visible(&self, resource_name: &str) -> bool {
448 self.visible.unwrap_or(!is_auth_resource(resource_name))
449 }
450}
451
452pub fn is_auth_resource(name: &str) -> bool {
455 matches!(
456 name,
457 "user"
458 | "organization"
459 | "membership"
460 | "membership_role"
461 | "api_key"
462 | "oauth_connection"
463 | "invitation"
464 | "auth_token"
465 )
466}
467
468#[derive(Debug, Clone, Deserialize)]
470#[serde(default, deny_unknown_fields)]
471pub struct FieldAdmin {
472 pub visible: bool,
475 pub readonly: bool,
477 pub label: Option<String>,
479 pub help: Option<String>,
481 pub widget: Widget,
483 pub options: Vec<String>,
486 pub placeholder: Option<String>,
488 pub signup: Option<bool>,
497 pub format: ContentFormat,
501}
502
503impl Default for FieldAdmin {
504 fn default() -> Self {
505 FieldAdmin {
506 visible: true,
507 readonly: false,
508 label: None,
509 help: None,
510 widget: Widget::Auto,
511 options: Vec::new(),
512 placeholder: None,
513 format: ContentFormat::Plain,
514 signup: None,
515 }
516 }
517}
518
519impl FieldAdmin {
520 pub fn in_signup(&self, field: &Field) -> bool {
527 self.signup.unwrap_or(field.required)
528 }
529}
530
531#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
536#[serde(rename_all = "snake_case")]
537pub enum ContentFormat {
538 #[default]
540 Plain,
541 Markdown,
542 Html,
543}
544
545impl ContentFormat {
546 pub fn as_str(self) -> &'static str {
547 match self {
548 ContentFormat::Plain => "plain",
549 ContentFormat::Markdown => "markdown",
550 ContentFormat::Html => "html",
551 }
552 }
553}
554
555#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
557#[serde(rename_all = "snake_case")]
558pub enum Widget {
559 Auto,
561 Text,
562 Textarea,
563 Select,
564 Email,
565 Url,
566 Password,
567 Color,
568 Date,
569 DateTime,
570 Json,
571 Switch,
572 File,
574}
575
576impl Widget {
577 pub fn as_str(self) -> &'static str {
578 match self {
579 Widget::Auto => "auto",
580 Widget::Text => "text",
581 Widget::Textarea => "textarea",
582 Widget::Select => "select",
583 Widget::Email => "email",
584 Widget::Url => "url",
585 Widget::Password => "password",
586 Widget::Color => "color",
587 Widget::Date => "date",
588 Widget::DateTime => "date_time",
589 Widget::Json => "json",
590 Widget::Switch => "switch",
591 Widget::File => "file",
592 }
593 }
594}
595
596#[derive(Debug, Clone, Deserialize)]
598pub struct Field {
599 #[serde(rename = "type")]
600 pub ty: FieldType,
601 #[serde(default)]
603 pub references: Option<String>,
604 #[serde(default)]
605 pub required: bool,
606 #[serde(default)]
607 pub unique: bool,
608 #[serde(default)]
610 pub hidden: bool,
611 #[serde(default)]
613 pub default: Option<serde_json::Value>,
614 pub max_length: Option<u32>,
615 #[serde(default)]
618 pub on_delete: Option<OnDelete>,
619 #[serde(default)]
621 pub admin: FieldAdmin,
622}
623
624#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
626#[serde(rename_all = "snake_case")]
627pub enum FieldType {
628 String,
629 Text,
630 Integer,
631 BigInt,
632 Float,
633 Boolean,
634 Uuid,
635 Timestamp,
636 Json,
637 File,
646 Reference,
648}
649
650#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
652#[serde(rename_all = "snake_case")]
653pub enum OnDelete {
654 Restrict,
656 SetNull,
658 Cascade,
660 NoAction,
662}
663
664impl OnDelete {
665 pub fn to_sql(self) -> &'static str {
666 match self {
667 OnDelete::Restrict => "RESTRICT",
668 OnDelete::SetNull => "SET NULL",
669 OnDelete::Cascade => "CASCADE",
670 OnDelete::NoAction => "NO ACTION",
671 }
672 }
673}
674
675#[derive(Debug, Clone)]
678pub struct Reference {
679 pub field: String,
680 pub target: String,
681 pub relation: String,
682 pub on_delete: OnDelete,
683 pub required: bool,
684}
685
686pub fn relation_name(field: &str) -> &str {
689 field.strip_suffix("_id").unwrap_or(field)
690}
691
692pub fn titleize(name: &str) -> String {
695 let spaced = name.trim_end_matches("_id").replace('_', " ");
696 let mut chars = spaced.chars();
697 match chars.next() {
698 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
699 None => spaced,
700 }
701}
702
703pub fn pluralize(label: &str) -> String {
706 let lower = label.to_lowercase();
707 if lower.ends_with('s')
708 || lower.ends_with("x")
709 || lower.ends_with("ch")
710 || lower.ends_with("sh")
711 {
712 format!("{label}es")
713 } else if lower.ends_with('y')
714 && !lower.ends_with("ay")
715 && !lower.ends_with("ey")
716 && !lower.ends_with("oy")
717 && !lower.ends_with("uy")
718 {
719 format!("{}ies", &label[..label.len() - 1])
720 } else {
721 format!("{label}s")
722 }
723}
724
725#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
728#[serde(rename_all = "snake_case")]
729pub enum Scope {
730 Organization,
733 Global,
735}
736
737#[derive(Debug, Clone, PartialEq, Eq)]
744pub enum Access {
745 Public,
748 Authenticated,
750 Member,
752 Role(String),
754 Owner,
756 Private,
758}
759
760impl Access {
761 pub fn parse(s: &str) -> Access {
763 match s {
764 "public" => Access::Public,
765 "authenticated" => Access::Authenticated,
766 "member" => Access::Member,
767 "owner" => Access::Owner,
768 "private" => Access::Private,
769 other => other
770 .strip_prefix("role:")
771 .map(|role| Access::Role(role.to_string()))
772 .unwrap_or(Access::Private),
773 }
774 }
775
776 pub fn as_string(&self) -> String {
778 match self {
779 Access::Public => "public".to_string(),
780 Access::Authenticated => "authenticated".to_string(),
781 Access::Member => "member".to_string(),
782 Access::Role(role) => format!("role:{role}"),
783 Access::Owner => "owner".to_string(),
784 Access::Private => "private".to_string(),
785 }
786 }
787}
788
789#[derive(Debug, Clone, Deserialize)]
791#[serde(from = "PermissionsRaw")]
792pub struct Permissions {
793 pub list: Access,
794 pub read: Access,
795 pub create: Access,
796 pub update: Access,
797 pub delete: Access,
798}
799
800impl Default for Permissions {
801 fn default() -> Self {
802 Permissions {
806 list: Access::Member,
807 read: Access::Member,
808 create: Access::Member,
809 update: Access::Member,
810 delete: Access::Member,
811 }
812 }
813}
814
815#[derive(Deserialize)]
816struct PermissionsRaw {
817 list: Option<String>,
818 read: Option<String>,
819 create: Option<String>,
820 update: Option<String>,
821 delete: Option<String>,
822}
823
824impl From<PermissionsRaw> for Permissions {
825 fn from(r: PermissionsRaw) -> Self {
826 let d = Permissions::default();
827 Permissions {
828 list: r.list.map(|s| Access::parse(&s)).unwrap_or(d.list),
829 read: r.read.map(|s| Access::parse(&s)).unwrap_or(d.read),
830 create: r.create.map(|s| Access::parse(&s)).unwrap_or(d.create),
831 update: r.update.map(|s| Access::parse(&s)).unwrap_or(d.update),
832 delete: r.delete.map(|s| Access::parse(&s)).unwrap_or(d.delete),
833 }
834 }
835}
836
837#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
839pub enum CrudAction {
840 List,
841 Read,
842 Create,
843 Update,
844 Delete,
845}
846
847impl CrudAction {
848 pub const ALL: [CrudAction; 5] = [
850 CrudAction::List,
851 CrudAction::Read,
852 CrudAction::Create,
853 CrudAction::Update,
854 CrudAction::Delete,
855 ];
856
857 pub fn as_str(self) -> &'static str {
859 match self {
860 CrudAction::List => "list",
861 CrudAction::Read => "read",
862 CrudAction::Create => "create",
863 CrudAction::Update => "update",
864 CrudAction::Delete => "delete",
865 }
866 }
867}
868
869#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
882pub enum RateLimitRule {
883 #[default]
885 Inherit,
886 Off,
888 Limit { requests: u32, window_secs: u64 },
890}
891
892impl RateLimitRule {
893 pub fn parse(s: &str) -> Option<RateLimitRule> {
897 let s = s.trim();
898 match s.to_ascii_lowercase().as_str() {
899 "" | "inherit" | "default" => return Some(RateLimitRule::Inherit),
900 "off" | "none" | "unlimited" => return Some(RateLimitRule::Off),
901 _ => {}
902 }
903
904 let (requests, window) = s.split_once('/')?;
905 let requests: u32 = requests.trim().parse().ok()?;
906 if requests == 0 {
909 return None;
910 }
911 Some(RateLimitRule::Limit {
912 requests,
913 window_secs: parse_window(window.trim())?,
914 })
915 }
916
917 pub fn as_string(&self) -> String {
919 match self {
920 RateLimitRule::Inherit => "inherit".to_string(),
921 RateLimitRule::Off => "off".to_string(),
922 RateLimitRule::Limit {
923 requests,
924 window_secs,
925 } => format!("{requests}/{}", format_window(*window_secs)),
926 }
927 }
928
929 pub fn or(self, fallback: RateLimitRule) -> RateLimitRule {
931 match self {
932 RateLimitRule::Inherit => fallback,
933 decided => decided,
934 }
935 }
936
937 pub fn limit(self) -> Option<(u32, u64)> {
939 match self {
940 RateLimitRule::Limit {
941 requests,
942 window_secs,
943 } => Some((requests, window_secs)),
944 _ => None,
945 }
946 }
947}
948
949fn parse_window(window: &str) -> Option<u64> {
952 let window = window.to_ascii_lowercase();
953 if window.is_empty() {
956 return None;
957 }
958 let unit = |n: u64| match window.trim_end_matches(char::is_alphabetic) {
959 "" => Some(n),
960 digits => digits.parse::<u64>().ok().map(|count| count * n),
961 };
962 let seconds = match window.trim_matches(char::is_numeric) {
963 "" | "s" | "sec" | "secs" | "second" | "seconds" => unit(1),
964 "m" | "min" | "mins" | "minute" | "minutes" => unit(60),
965 "h" | "hr" | "hrs" | "hour" | "hours" => unit(3600),
966 "d" | "day" | "days" => unit(86_400),
967 _ => None,
968 }?;
969 (seconds > 0).then_some(seconds)
972}
973
974fn format_window(seconds: u64) -> String {
977 for (size, suffix) in [(86_400, 'd'), (3600, 'h'), (60, 'm')] {
978 if seconds % size == 0 {
979 return format!("{}{suffix}", seconds / size);
980 }
981 }
982 format!("{seconds}s")
983}
984
985impl<'de> Deserialize<'de> for RateLimitRule {
986 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
987 let raw = String::deserialize(de)?;
988 RateLimitRule::parse(&raw).ok_or_else(|| {
989 serde::de::Error::custom(format!(
990 "`{raw}` is not a rate limit: write `<requests>/<window>` \
991 (`100/1m`, `30/30s`, `1000/1h`), `off`, or `inherit`"
992 ))
993 })
994 }
995}
996
997#[derive(Debug, Clone, Default, Deserialize)]
1007#[serde(default, deny_unknown_fields)]
1008pub struct RateLimits {
1009 pub all: RateLimitRule,
1011 pub list: RateLimitRule,
1012 pub read: RateLimitRule,
1013 pub create: RateLimitRule,
1014 pub update: RateLimitRule,
1015 pub delete: RateLimitRule,
1016}
1017
1018impl RateLimits {
1019 pub fn for_action(&self, action: CrudAction) -> RateLimitRule {
1022 let own = match action {
1023 CrudAction::List => self.list,
1024 CrudAction::Read => self.read,
1025 CrudAction::Create => self.create,
1026 CrudAction::Update => self.update,
1027 CrudAction::Delete => self.delete,
1028 };
1029 own.or(self.all)
1030 }
1031
1032 pub fn is_empty(&self) -> bool {
1034 CrudAction::ALL
1035 .into_iter()
1036 .all(|action| self.for_action(action) == RateLimitRule::Inherit)
1037 }
1038}
1039
1040#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1047pub enum HookEvent {
1048 BeforeList,
1049 AfterList,
1050 BeforeRead,
1051 AfterRead,
1052 BeforeCreate,
1053 AfterCreate,
1054 BeforeUpdate,
1055 AfterUpdate,
1056 BeforeDelete,
1057 AfterDelete,
1058}
1059
1060impl HookEvent {
1061 pub const ALL: [HookEvent; 10] = [
1063 HookEvent::BeforeList,
1064 HookEvent::AfterList,
1065 HookEvent::BeforeRead,
1066 HookEvent::AfterRead,
1067 HookEvent::BeforeCreate,
1068 HookEvent::AfterCreate,
1069 HookEvent::BeforeUpdate,
1070 HookEvent::AfterUpdate,
1071 HookEvent::BeforeDelete,
1072 HookEvent::AfterDelete,
1073 ];
1074
1075 pub fn as_str(self) -> &'static str {
1077 match self {
1078 HookEvent::BeforeList => "before_list",
1079 HookEvent::AfterList => "after_list",
1080 HookEvent::BeforeRead => "before_read",
1081 HookEvent::AfterRead => "after_read",
1082 HookEvent::BeforeCreate => "before_create",
1083 HookEvent::AfterCreate => "after_create",
1084 HookEvent::BeforeUpdate => "before_update",
1085 HookEvent::AfterUpdate => "after_update",
1086 HookEvent::BeforeDelete => "before_delete",
1087 HookEvent::AfterDelete => "after_delete",
1088 }
1089 }
1090
1091 pub fn action(self) -> &'static str {
1093 match self {
1094 HookEvent::BeforeList | HookEvent::AfterList => "list",
1095 HookEvent::BeforeRead | HookEvent::AfterRead => "read",
1096 HookEvent::BeforeCreate | HookEvent::AfterCreate => "create",
1097 HookEvent::BeforeUpdate | HookEvent::AfterUpdate => "update",
1098 HookEvent::BeforeDelete | HookEvent::AfterDelete => "delete",
1099 }
1100 }
1101
1102 pub fn phase(self) -> &'static str {
1104 if self.is_before() {
1105 "before"
1106 } else {
1107 "after"
1108 }
1109 }
1110
1111 pub fn is_before(self) -> bool {
1113 matches!(
1114 self,
1115 HookEvent::BeforeList
1116 | HookEvent::BeforeRead
1117 | HookEvent::BeforeCreate
1118 | HookEvent::BeforeUpdate
1119 | HookEvent::BeforeDelete
1120 )
1121 }
1122}
1123
1124#[derive(Debug, Clone, Default, Deserialize)]
1146#[serde(default, deny_unknown_fields)]
1147pub struct Publishes {
1148 pub after_create: Option<String>,
1149 pub after_update: Option<String>,
1150 pub after_delete: Option<String>,
1151}
1152
1153impl Publishes {
1154 pub fn get(&self, event: HookEvent) -> Option<&str> {
1156 let slot = match event {
1157 HookEvent::AfterCreate => &self.after_create,
1158 HookEvent::AfterUpdate => &self.after_update,
1159 HookEvent::AfterDelete => &self.after_delete,
1160 _ => &None,
1161 };
1162 slot.as_deref().map(str::trim).filter(|t| !t.is_empty())
1163 }
1164
1165 pub fn iter(&self) -> impl Iterator<Item = (HookEvent, &str)> {
1167 [
1168 HookEvent::AfterCreate,
1169 HookEvent::AfterUpdate,
1170 HookEvent::AfterDelete,
1171 ]
1172 .into_iter()
1173 .filter_map(|event| self.get(event).map(|topic| (event, topic)))
1174 }
1175
1176 pub fn is_empty(&self) -> bool {
1177 self.iter().next().is_none()
1178 }
1179}
1180
1181#[derive(Debug, Clone, Default, Deserialize)]
1190#[serde(default, deny_unknown_fields)]
1191pub struct Hooks {
1192 pub before_list: Option<String>,
1193 pub after_list: Option<String>,
1194 pub before_read: Option<String>,
1195 pub after_read: Option<String>,
1196 pub before_create: Option<String>,
1197 pub after_create: Option<String>,
1198 pub before_update: Option<String>,
1199 pub after_update: Option<String>,
1200 pub before_delete: Option<String>,
1201 pub after_delete: Option<String>,
1202 pub before_register: Option<String>,
1203 pub after_register: Option<String>,
1204 pub before_login: Option<String>,
1205 pub after_login: Option<String>,
1206 pub before_api_key: Option<String>,
1207 pub after_api_key: Option<String>,
1208}
1209
1210impl Hooks {
1211 pub fn get(&self, event: HookEvent) -> Option<&str> {
1213 let slot = match event {
1214 HookEvent::BeforeList => &self.before_list,
1215 HookEvent::AfterList => &self.after_list,
1216 HookEvent::BeforeRead => &self.before_read,
1217 HookEvent::AfterRead => &self.after_read,
1218 HookEvent::BeforeCreate => &self.before_create,
1219 HookEvent::AfterCreate => &self.after_create,
1220 HookEvent::BeforeUpdate => &self.before_update,
1221 HookEvent::AfterUpdate => &self.after_update,
1222 HookEvent::BeforeDelete => &self.before_delete,
1223 HookEvent::AfterDelete => &self.after_delete,
1224 };
1225 slot.as_deref()
1226 }
1227
1228 pub fn iter(&self) -> impl Iterator<Item = (HookEvent, &str)> {
1230 HookEvent::ALL
1231 .into_iter()
1232 .filter_map(|event| self.get(event).map(|name| (event, name)))
1233 }
1234
1235 pub fn is_empty(&self) -> bool {
1237 self.iter().next().is_none() && self.auth_iter().next().is_none()
1238 }
1239}
1240
1241#[derive(Debug, Clone, Deserialize)]
1243#[serde(default)]
1244pub struct AuthSpec {
1245 pub identity_field: String,
1247 pub password_field: String,
1249 pub oauth_providers: Vec<String>,
1251}
1252
1253impl Default for AuthSpec {
1254 fn default() -> Self {
1255 AuthSpec {
1256 identity_field: "email".to_string(),
1257 password_field: "password_hash".to_string(),
1258 oauth_providers: Vec::new(),
1259 }
1260 }
1261}
1262
1263#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1271pub enum AuthEvent {
1272 BeforeRegister,
1273 AfterRegister,
1274 BeforeLogin,
1275 AfterLogin,
1276 BeforeApiKey,
1277 AfterApiKey,
1278}
1279
1280impl AuthEvent {
1281 pub const ALL: [AuthEvent; 6] = [
1283 AuthEvent::BeforeRegister,
1284 AuthEvent::AfterRegister,
1285 AuthEvent::BeforeLogin,
1286 AuthEvent::AfterLogin,
1287 AuthEvent::BeforeApiKey,
1288 AuthEvent::AfterApiKey,
1289 ];
1290
1291 pub fn as_str(self) -> &'static str {
1293 match self {
1294 AuthEvent::BeforeRegister => "before_register",
1295 AuthEvent::AfterRegister => "after_register",
1296 AuthEvent::BeforeLogin => "before_login",
1297 AuthEvent::AfterLogin => "after_login",
1298 AuthEvent::BeforeApiKey => "before_api_key",
1299 AuthEvent::AfterApiKey => "after_api_key",
1300 }
1301 }
1302
1303 pub fn action(self) -> &'static str {
1305 match self {
1306 AuthEvent::BeforeRegister | AuthEvent::AfterRegister => "register",
1307 AuthEvent::BeforeLogin | AuthEvent::AfterLogin => "login",
1308 AuthEvent::BeforeApiKey | AuthEvent::AfterApiKey => "api_key",
1309 }
1310 }
1311
1312 pub fn phase(self) -> &'static str {
1314 if self.is_before() {
1315 "before"
1316 } else {
1317 "after"
1318 }
1319 }
1320
1321 pub fn is_before(self) -> bool {
1323 matches!(
1324 self,
1325 AuthEvent::BeforeRegister | AuthEvent::BeforeLogin | AuthEvent::BeforeApiKey
1326 )
1327 }
1328}
1329
1330impl Hooks {
1331 pub fn get_auth(&self, event: AuthEvent) -> Option<&str> {
1333 let slot = match event {
1334 AuthEvent::BeforeRegister => &self.before_register,
1335 AuthEvent::AfterRegister => &self.after_register,
1336 AuthEvent::BeforeLogin => &self.before_login,
1337 AuthEvent::AfterLogin => &self.after_login,
1338 AuthEvent::BeforeApiKey => &self.before_api_key,
1339 AuthEvent::AfterApiKey => &self.after_api_key,
1340 };
1341 slot.as_deref()
1342 }
1343
1344 pub fn auth_iter(&self) -> impl Iterator<Item = (AuthEvent, &str)> {
1346 AuthEvent::ALL
1347 .into_iter()
1348 .filter_map(|event| self.get_auth(event).map(|name| (event, name)))
1349 }
1350}
1351
1352#[cfg(test)]
1353mod tests {
1354 use super::*;
1355
1356 fn parse_resource(src: &str) -> Resource {
1357 let resource: Resource = toml::from_str(src).unwrap();
1358 resource.validate().unwrap();
1359 resource
1360 }
1361
1362 #[test]
1363 fn a_rate_limit_is_a_count_over_a_window_however_the_window_is_spelled() {
1364 let limit = |requests, window_secs| {
1365 Some(RateLimitRule::Limit {
1366 requests,
1367 window_secs,
1368 })
1369 };
1370 assert_eq!(RateLimitRule::parse("100/60"), limit(100, 60));
1372 assert_eq!(RateLimitRule::parse("100/60s"), limit(100, 60));
1373 assert_eq!(RateLimitRule::parse("100/1m"), limit(100, 60));
1374 assert_eq!(RateLimitRule::parse("100/minute"), limit(100, 60));
1375 assert_eq!(RateLimitRule::parse("5/2h"), limit(5, 7200));
1376 assert_eq!(RateLimitRule::parse("5/1d"), limit(5, 86_400));
1377 assert_eq!(RateLimitRule::parse(" 30 / 30S "), limit(30, 30));
1379
1380 assert_eq!(RateLimitRule::parse("off"), Some(RateLimitRule::Off));
1381 assert_eq!(RateLimitRule::parse("none"), Some(RateLimitRule::Off));
1382 assert_eq!(RateLimitRule::parse(""), Some(RateLimitRule::Inherit));
1383 assert_eq!(
1384 RateLimitRule::parse("inherit"),
1385 Some(RateLimitRule::Inherit)
1386 );
1387 }
1388
1389 #[test]
1390 fn a_rate_limit_that_says_nothing_enforceable_is_refused_rather_than_guessed() {
1391 for spelling in ["100", "/60", "abc/60", "100/", "100/0", "0/60", "100/2x"] {
1394 assert_eq!(
1395 RateLimitRule::parse(spelling),
1396 None,
1397 "`{spelling}` should not parse as a rate limit"
1398 );
1399 }
1400 let error = toml::from_str::<RateLimits>("all = \"100\"").unwrap_err();
1402 assert!(
1403 error.to_string().contains("not a rate limit"),
1404 "unhelpful error: {error}"
1405 );
1406 }
1407
1408 #[test]
1409 fn a_rate_limit_reads_back_in_the_units_it_was_written_in() {
1410 for spelling in ["100/1m", "5/30s", "10/2h", "1/1d"] {
1411 assert_eq!(
1412 RateLimitRule::parse(spelling).unwrap().as_string(),
1413 spelling
1414 );
1415 }
1416 }
1417
1418 #[test]
1419 fn a_per_action_rate_limit_beats_the_resource_wide_one() {
1420 let resource = parse_resource(
1421 r#"
1422[resource]
1423name = "post"
1424
1425[fields.title]
1426type = "string"
1427
1428[rate_limit]
1429all = "100/1m"
1430create = "5/1m"
1431delete = "off"
1432"#,
1433 );
1434 let limits = &resource.rate_limit;
1435 assert_eq!(
1438 limits.for_action(CrudAction::Create),
1439 RateLimitRule::parse("5/1m").unwrap()
1440 );
1441 assert_eq!(
1442 limits.for_action(CrudAction::List),
1443 RateLimitRule::parse("100/1m").unwrap()
1444 );
1445 assert_eq!(limits.for_action(CrudAction::Delete), RateLimitRule::Off);
1446 assert!(!limits.is_empty());
1447
1448 let quiet = parse_resource(
1450 r#"
1451[resource]
1452name = "page"
1453
1454[fields.title]
1455type = "string"
1456"#,
1457 );
1458 assert!(quiet.rate_limit.is_empty());
1459 assert_eq!(
1460 quiet.rate_limit.for_action(CrudAction::List),
1461 RateLimitRule::Inherit
1462 );
1463 }
1464
1465 #[test]
1466 fn access_parser_and_permissions_defaults_match_org_membership_model() {
1467 assert_eq!(Access::parse("public"), Access::Public);
1468 assert_eq!(Access::parse("authenticated"), Access::Authenticated);
1469 assert_eq!(Access::parse("member"), Access::Member);
1470 assert_eq!(Access::parse("owner"), Access::Owner);
1471 assert_eq!(Access::parse("role:admin"), Access::Role("admin".into()));
1472 assert_eq!(Access::parse("wat"), Access::Private);
1473
1474 let defaults = Permissions::default();
1475 assert_eq!(defaults.list, Access::Member);
1476 assert_eq!(defaults.read, Access::Member);
1477 assert_eq!(defaults.create, Access::Member);
1478 assert_eq!(defaults.update, Access::Member);
1479 assert_eq!(defaults.delete, Access::Member);
1480 }
1481
1482 #[test]
1483 fn validate_rejects_reserved_id_and_dangling_reference_definition() {
1484 let reserved_id: Resource = toml::from_str(
1485 r#"
1486[resource]
1487name = "bad"
1488
1489[fields.id]
1490type = "string"
1491"#,
1492 )
1493 .unwrap();
1494 assert!(reserved_id.validate().is_err());
1495
1496 let missing_target: Resource = toml::from_str(
1497 r#"
1498[resource]
1499name = "bad_ref"
1500
1501[fields.owner_id]
1502type = "reference"
1503"#,
1504 )
1505 .unwrap();
1506 assert!(missing_target.validate().is_err());
1507 }
1508
1509 #[test]
1510 fn content_format_is_only_allowed_on_text_fields() {
1511 let good: Resource = toml::from_str(
1512 r#"
1513[resource]
1514name = "article"
1515
1516[fields.body]
1517type = "text"
1518
1519[fields.body.admin]
1520format = "markdown"
1521"#,
1522 )
1523 .unwrap();
1524 good.validate().unwrap();
1525 assert_eq!(good.fields["body"].admin.format, ContentFormat::Markdown);
1526
1527 let bad: Resource = toml::from_str(
1528 r#"
1529[resource]
1530name = "article"
1531
1532[fields.published]
1533type = "boolean"
1534
1535[fields.published.admin]
1536format = "html"
1537"#,
1538 )
1539 .unwrap();
1540 assert!(bad.validate().is_err());
1541 }
1542
1543 #[test]
1544 fn references_derive_relation_names_and_default_on_delete() {
1545 let resource = parse_resource(
1546 r#"
1547[resource]
1548name = "comment"
1549
1550[fields.post_id]
1551type = "reference"
1552references = "post"
1553required = true
1554
1555[fields.author_id]
1556type = "reference"
1557references = "user"
1558on_delete = "cascade"
1559"#,
1560 );
1561
1562 assert_eq!(relation_name("owner_id"), "owner");
1563 assert_eq!(relation_name("slug"), "slug");
1564
1565 let refs = resource.references();
1566 assert_eq!(refs.len(), 2);
1567 let post = refs.iter().find(|rf| rf.field == "post_id").unwrap();
1568 assert_eq!(post.target, "post");
1569 assert_eq!(post.relation, "post");
1570 assert_eq!(post.on_delete, OnDelete::Restrict);
1571 assert!(post.required);
1572
1573 let author = resource.reference_by_relation("author").unwrap();
1574 assert_eq!(author.field, "author_id");
1575 assert_eq!(author.on_delete, OnDelete::Cascade);
1576 }
1577
1578 #[test]
1579 fn hooks_parse_per_event_and_iterate_in_lifecycle_order() {
1580 let resource = parse_resource(
1581 r#"
1582[resource]
1583name = "post"
1584
1585[fields.title]
1586type = "string"
1587
1588[hooks]
1589before_create = "validate_post"
1590after_create = "notify"
1591after_list = "redact"
1592"#,
1593 );
1594
1595 assert_eq!(
1596 resource.hook(HookEvent::BeforeCreate),
1597 Some("validate_post")
1598 );
1599 assert_eq!(resource.hook(HookEvent::AfterCreate), Some("notify"));
1600 assert_eq!(resource.hook(HookEvent::AfterList), Some("redact"));
1601 assert_eq!(resource.hook(HookEvent::BeforeUpdate), None);
1602 assert!(!resource.hooks.is_empty());
1603
1604 let declared: Vec<_> = resource.hooks.iter().collect();
1605 assert_eq!(
1606 declared,
1607 vec![
1608 (HookEvent::AfterList, "redact"),
1609 (HookEvent::BeforeCreate, "validate_post"),
1610 (HookEvent::AfterCreate, "notify"),
1611 ]
1612 );
1613 }
1614
1615 #[test]
1616 fn hook_events_expose_wire_names_actions_and_phases() {
1617 assert_eq!(HookEvent::BeforeCreate.as_str(), "before_create");
1618 assert_eq!(HookEvent::BeforeCreate.action(), "create");
1619 assert_eq!(HookEvent::BeforeCreate.phase(), "before");
1620 assert!(HookEvent::BeforeCreate.is_before());
1621
1622 assert_eq!(HookEvent::AfterList.as_str(), "after_list");
1623 assert_eq!(HookEvent::AfterList.action(), "list");
1624 assert_eq!(HookEvent::AfterList.phase(), "after");
1625 assert!(!HookEvent::AfterList.is_before());
1626
1627 for event in HookEvent::ALL {
1629 let resource = parse_resource(&format!(
1630 "[resource]\nname = \"post\"\n\n[hooks]\n{} = \"h\"\n",
1631 event.as_str()
1632 ));
1633 assert_eq!(resource.hook(event), Some("h"), "{}", event.as_str());
1634 assert_eq!(resource.hooks.iter().count(), 1);
1635 }
1636 }
1637
1638 #[test]
1639 fn hooks_reject_typos_and_empty_function_names() {
1640 let typo = toml::from_str::<Resource>(
1641 r#"
1642[resource]
1643name = "post"
1644
1645[hooks]
1646befor_create = "oops"
1647"#,
1648 );
1649 assert!(typo.is_err(), "unknown hook keys must not be ignored");
1650
1651 let empty: Resource = toml::from_str(
1652 r#"
1653[resource]
1654name = "post"
1655
1656[hooks]
1657after_delete = " "
1658"#,
1659 )
1660 .unwrap();
1661 assert!(empty.validate().is_err());
1662 }
1663
1664 #[test]
1665 fn auth_events_expose_wire_names_actions_and_phases() {
1666 assert_eq!(AuthEvent::BeforeLogin.as_str(), "before_login");
1667 assert_eq!(AuthEvent::BeforeLogin.action(), "login");
1668 assert_eq!(AuthEvent::BeforeLogin.phase(), "before");
1669 assert!(AuthEvent::BeforeLogin.is_before());
1670
1671 assert_eq!(AuthEvent::AfterApiKey.action(), "api_key");
1672 assert_eq!(AuthEvent::AfterApiKey.phase(), "after");
1673 assert!(!AuthEvent::AfterApiKey.is_before());
1674
1675 for event in AuthEvent::ALL {
1678 let resource = parse_resource(&format!(
1679 "[resource]\nname = \"user\"\n\n[hooks]\nafter_create = \"c\"\n{} = \"h\"\n",
1680 event.as_str()
1681 ));
1682 assert_eq!(resource.auth_hook(event), Some("h"), "{}", event.as_str());
1683 assert_eq!(resource.hook(HookEvent::AfterCreate), Some("c"));
1684 assert_eq!(resource.hooks.auth_iter().count(), 1);
1685 assert_eq!(resource.hooks.iter().count(), 1);
1688 }
1689 }
1690
1691 #[test]
1692 fn auth_hooks_are_absent_by_default_and_reject_typos_and_empty_names() {
1693 let plain =
1694 parse_resource("[resource]\nname = \"user\"\n\n[hooks]\nafter_create = \"c\"\n");
1695 assert_eq!(plain.auth_hook(AuthEvent::BeforeLogin), None);
1696 assert!(parse_resource("[resource]\nname = \"user\"\n")
1697 .hooks
1698 .is_empty());
1699
1700 let typo = toml::from_str::<Resource>(
1701 "[resource]\nname = \"user\"\n\n[hooks]\nbefore_signin = \"oops\"\n",
1702 );
1703 assert!(typo.is_err(), "unknown auth hook keys must not be ignored");
1704
1705 let empty: Resource =
1706 toml::from_str("[resource]\nname = \"user\"\n\n[hooks]\nafter_login = \" \"\n")
1707 .unwrap();
1708 assert!(empty.validate().is_err());
1709 }
1710
1711 #[test]
1712 fn auth_hooks_are_rejected_on_any_resource_but_user() {
1713 let stray: Resource =
1716 toml::from_str("[resource]\nname = \"post\"\n\n[hooks]\nbefore_login = \"h\"\n")
1717 .unwrap();
1718 let message = stray.validate().unwrap_err().to_string();
1719 assert!(message.contains("before_login"), "{message}");
1720 assert!(message.contains("user"), "{message}");
1721 }
1722
1723 #[test]
1724 fn resources_have_no_hooks_by_default() {
1725 let resource = parse_resource("[resource]\nname = \"post\"\n");
1726 assert!(resource.hooks.is_empty());
1727 assert!(HookEvent::ALL
1728 .into_iter()
1729 .all(|event| resource.hook(event).is_none()));
1730 }
1731
1732 #[test]
1733 fn admin_visibility_defaults_hide_auth_resources_but_nothing_else() {
1734 let post = parse_resource("[resource]\nname = \"post\"\n");
1735 assert!(post.admin.is_visible("post"));
1736
1737 let user = parse_resource(crate::defaults::USER_TOML);
1738 assert!(!user.admin.is_visible("user"));
1739 assert!(!parse_resource(crate::defaults::MEMBERSHIP_TOML)
1740 .admin
1741 .is_visible("membership"));
1742
1743 let replaced = parse_resource(
1745 "[resource]\nname = \"user\"\nscope = \"global\"\n\n[fields.email]\ntype = \"string\"\n",
1746 );
1747 assert!(!replaced.admin.is_visible("user"));
1748
1749 let opted_in = parse_resource(
1751 "[resource]\nname = \"user\"\nscope = \"global\"\n\n[admin]\nvisible = true\n",
1752 );
1753 assert!(opted_in.admin.is_visible("user"));
1754 }
1755
1756 #[test]
1757 fn admin_labels_are_inferred_and_overridable() {
1758 let inferred = parse_resource("[resource]\nname = \"purchase_order\"\n");
1759 assert_eq!(inferred.admin_label(), "Purchase order");
1760 assert_eq!(inferred.admin_plural(), "Purchase orders");
1761
1762 let overridden = parse_resource(
1763 "[resource]\nname = \"person\"\n\n[admin]\nlabel = \"Person\"\nplural = \"People\"\n",
1764 );
1765 assert_eq!(overridden.admin_plural(), "People");
1766
1767 assert_eq!(titleize("owner_id"), "Owner");
1768 assert_eq!(titleize("total_cents"), "Total cents");
1769 assert_eq!(pluralize("Category"), "Categories");
1770 assert_eq!(pluralize("Address"), "Addresses");
1771 assert_eq!(pluralize("Day"), "Days");
1772 assert_eq!(pluralize("Product"), "Products");
1773 }
1774
1775 #[test]
1776 fn display_field_prefers_conventional_names_then_any_string() {
1777 let conventional = parse_resource(
1778 r#"
1779[resource]
1780name = "product"
1781
1782[fields.sku]
1783type = "string"
1784
1785[fields.name]
1786type = "string"
1787"#,
1788 );
1789 assert_eq!(conventional.admin_display_field().as_deref(), Some("name"));
1790
1791 let only_odd_names =
1792 parse_resource("[resource]\nname = \"blob\"\n\n[fields.zzz]\ntype = \"string\"\n");
1793 assert_eq!(only_odd_names.admin_display_field().as_deref(), Some("zzz"));
1794
1795 let nothing_stringy =
1796 parse_resource("[resource]\nname = \"tick\"\n\n[fields.count]\ntype = \"integer\"\n");
1797 assert_eq!(nothing_stringy.admin_display_field(), None);
1798
1799 let declared = parse_resource(
1801 r#"
1802[resource]
1803name = "product"
1804
1805[admin]
1806display_field = "sku"
1807
1808[fields.sku]
1809type = "string"
1810
1811[fields.name]
1812type = "string"
1813"#,
1814 );
1815 assert_eq!(declared.admin_display_field().as_deref(), Some("sku"));
1816 assert_eq!(declared.admin_search_field().as_deref(), Some("sku"));
1818 }
1819
1820 #[test]
1821 fn inferred_columns_skip_blobs_and_dashboard_hidden_fields() {
1822 let resource = parse_resource(
1823 r#"
1824[resource]
1825name = "product"
1826
1827[fields.name]
1828type = "string"
1829
1830[fields.status]
1831type = "string"
1832
1833[fields.description]
1834type = "text"
1835
1836[fields.attributes]
1837type = "json"
1838
1839[fields.secret_ratio]
1840type = "float"
1841
1842[fields.secret_ratio.admin]
1843visible = false
1844"#,
1845 );
1846 assert_eq!(resource.admin_columns(), vec!["name", "status"]);
1847
1848 let declared = parse_resource(
1849 r#"
1850[resource]
1851name = "product"
1852
1853[admin]
1854columns = ["status", "name"]
1855
1856[fields.name]
1857type = "string"
1858
1859[fields.status]
1860type = "string"
1861"#,
1862 );
1863 assert_eq!(declared.admin_columns(), vec!["status", "name"]);
1864 }
1865
1866 #[test]
1867 fn admin_section_rejects_columns_naming_fields_that_do_not_exist() {
1868 let bad_column: Resource = toml::from_str(
1869 "[resource]\nname = \"post\"\n\n[admin]\ncolumns = [\"nope\"]\n\n[fields.title]\ntype = \"string\"\n",
1870 )
1871 .unwrap();
1872 assert!(bad_column.validate().is_err());
1873
1874 let bad_display: Resource = toml::from_str(
1875 "[resource]\nname = \"post\"\n\n[admin]\ndisplay_field = \"nope\"\n\n[fields.title]\ntype = \"string\"\n",
1876 )
1877 .unwrap();
1878 assert!(bad_display.validate().is_err());
1879
1880 let unsearchable: Resource = toml::from_str(
1883 "[resource]\nname = \"tick\"\n\n[admin]\nsearch_field = \"count\"\n\n[fields.count]\ntype = \"integer\"\n",
1884 )
1885 .unwrap();
1886 assert!(unsearchable.validate().is_err());
1887
1888 let named_by_a_number = parse_resource(
1891 "[resource]\nname = \"tick\"\n\n[admin]\ndisplay_field = \"count\"\n\n[fields.count]\ntype = \"integer\"\n",
1892 );
1893 assert_eq!(
1894 named_by_a_number.admin_display_field().as_deref(),
1895 Some("count")
1896 );
1897 assert_eq!(named_by_a_number.admin_search_field(), None);
1898
1899 let multi = parse_resource(
1902 "[resource]\nname = \"post\"\n\n[admin]\nsearch_fields = [\"title\", \"body\"]\n\n[fields.title]\ntype = \"string\"\n\n[fields.body]\ntype = \"text\"\n",
1903 );
1904 assert_eq!(multi.admin_search_fields(), vec!["title", "body"]);
1905
1906 let single =
1909 parse_resource("[resource]\nname = \"post\"\n\n[fields.title]\ntype = \"string\"\n");
1910 assert_eq!(single.admin_search_fields(), vec!["title"]);
1911
1912 for bad in [
1913 "[resource]\nname = \"post\"\n\n[admin]\nsearch_fields = [\"nope\"]\n\n[fields.title]\ntype = \"string\"\n",
1914 "[resource]\nname = \"post\"\n\n[admin]\nsearch_fields = [\"views\"]\n\n[fields.views]\ntype = \"integer\"\n",
1915 "[resource]\nname = \"post\"\n\n[admin]\nsearch_fields = [\"secret\"]\n\n[fields.secret]\ntype = \"string\"\nhidden = true\n",
1916 ] {
1917 let resource: Resource = toml::from_str(bad).unwrap();
1918 assert!(resource.validate().is_err(), "{bad} should not load");
1919 }
1920
1921 assert!(toml::from_str::<Resource>(
1923 "[resource]\nname = \"post\"\n\n[admin]\nvisibel = true\n"
1924 )
1925 .is_err());
1926 }
1927
1928 #[test]
1929 fn field_admin_carries_widget_options_and_visibility() {
1930 let resource = parse_resource(
1931 r#"
1932[resource]
1933name = "product"
1934
1935[fields.status]
1936type = "string"
1937
1938[fields.status.admin]
1939label = "Lifecycle"
1940widget = "select"
1941options = ["draft", "active|Live"]
1942readonly = true
1943"#,
1944 );
1945 let status = &resource.fields["status"];
1946 assert_eq!(status.admin.label.as_deref(), Some("Lifecycle"));
1947 assert_eq!(status.admin.widget, Widget::Select);
1948 assert_eq!(status.admin.widget.as_str(), "select");
1949 assert_eq!(status.admin.options, vec!["draft", "active|Live"]);
1950 assert!(status.admin.readonly);
1951 assert!(status.admin.visible);
1953 assert_eq!(resource.fields["status"].admin.help, None);
1954 }
1955
1956 #[test]
1957 fn org_column_reflects_resource_scope() {
1958 let org_scoped = parse_resource(
1959 r#"
1960[resource]
1961name = "post"
1962
1963[fields.title]
1964type = "string"
1965"#,
1966 );
1967 let global = parse_resource(
1968 r#"
1969[resource]
1970name = "plan"
1971scope = "global"
1972
1973[fields.name]
1974type = "string"
1975"#,
1976 );
1977 let organization = parse_resource(crate::defaults::ORGANIZATION_TOML);
1978
1979 assert_eq!(org_scoped.org_column(), Some("organization_id"));
1980 assert_eq!(global.org_column(), None);
1981 assert_eq!(organization.org_column(), Some("id"));
1982 }
1983}