Skip to main content

apiplant_core/
schema.rs

1//! The declarative resource model.
2//!
3//! A *resource* is one `models/<name>.toml` file. It declares fields and a
4//! per-action permission policy; the framework turns it into a Postgres table
5//! and a set of RESTful CRUD endpoints. Users, roles and api-keys are ordinary
6//! resources that ship with built-in defaults (see [`crate::defaults`]).
7
8use serde::Deserialize;
9use std::collections::BTreeMap;
10use std::path::Path;
11
12/// A fully-parsed resource definition.
13#[derive(Debug, Clone, Deserialize)]
14pub struct Resource {
15    #[serde(rename = "resource")]
16    pub meta: ResourceMeta,
17    /// Ordered map so generated columns/endpoints are deterministic.
18    #[serde(default)]
19    pub fields: BTreeMap<String, Field>,
20    #[serde(default)]
21    pub permissions: Permissions,
22    /// Named functions to run around each CRUD operation.
23    #[serde(default)]
24    pub hooks: Hooks,
25    /// Optional auth configuration; only meaningful on the `user` resource.
26    #[serde(default)]
27    pub auth: Option<AuthSpec>,
28    /// How (and whether) this resource appears in the generated admin dashboard.
29    #[serde(default)]
30    pub admin: ResourceAdmin,
31}
32
33#[derive(Debug, Clone, Deserialize)]
34pub struct ResourceMeta {
35    pub name: String,
36    /// Physical table name; defaults to `apiplant_<name>` when omitted.
37    pub table: Option<String>,
38    /// Add `created_at` / `updated_at` columns (default true).
39    #[serde(default = "yes")]
40    pub timestamps: bool,
41    /// Column used for `owner` permission checks (default `owner_id`).
42    #[serde(default = "default_owner_field")]
43    pub owner_field: String,
44    /// Tenancy: `organization` (default — rows belong to an org and are isolated)
45    /// or `global` (shared across the whole deployment).
46    #[serde(default = "default_scope")]
47    pub scope: Scope,
48}
49
50fn yes() -> bool {
51    true
52}
53fn default_owner_field() -> String {
54    "owner_id".to_string()
55}
56fn default_scope() -> Scope {
57    Scope::Organization
58}
59
60impl Resource {
61    /// Physical table name.
62    pub fn table_name(&self) -> String {
63        self.meta
64            .table
65            .clone()
66            .unwrap_or_else(|| format!("apiplant_{}", self.meta.name))
67    }
68
69    /// The function bound to a lifecycle event, if any.
70    pub fn hook(&self, event: HookEvent) -> Option<&str> {
71        self.hooks.get(event)
72    }
73
74    /// The function bound to an auth event, if any. Only meaningful on the
75    /// `user` resource, which owns the built-in auth endpoints.
76    pub fn auth_hook(&self, event: AuthEvent) -> Option<&str> {
77        self.hooks.get_auth(event)
78    }
79
80    /// Validate internal consistency (called after loading).
81    pub fn validate(&self) -> crate::Result<()> {
82        for (event, function) in self.hooks.iter() {
83            if function.trim().is_empty() {
84                return Err(crate::Error::Schema {
85                    resource: self.meta.name.clone(),
86                    message: format!("hook `{}` names an empty function", event.as_str()),
87                });
88            }
89        }
90        for (event, function) in self.hooks.auth_iter() {
91            if function.trim().is_empty() {
92                return Err(crate::Error::Schema {
93                    resource: self.meta.name.clone(),
94                    message: format!("hook `{}` names an empty function", event.as_str()),
95                });
96            }
97            // Only `user` has auth endpoints to hook, so the same key on any
98            // other resource is a function that would never be called.
99            if self.meta.name != "user" {
100                return Err(crate::Error::Schema {
101                    resource: self.meta.name.clone(),
102                    message: format!(
103                        "hook `{}` only exists on the `user` resource, which owns the auth endpoints",
104                        event.as_str()
105                    ),
106                });
107            }
108        }
109        for (fname, field) in &self.fields {
110            if fname == "id" {
111                return Err(crate::Error::Schema {
112                    resource: self.meta.name.clone(),
113                    message: "`id` is reserved and added automatically".into(),
114                });
115            }
116            if field.ty == FieldType::Reference && field.references.is_none() {
117                return Err(crate::Error::Schema {
118                    resource: self.meta.name.clone(),
119                    message: format!("field `{fname}` is a reference without `references`"),
120                });
121            }
122            if field.admin.format != ContentFormat::Plain
123                && !matches!(field.ty, FieldType::Text | FieldType::String)
124            {
125                return Err(crate::Error::Schema {
126                    resource: self.meta.name.clone(),
127                    message: format!(
128                        "field `{fname}` sets [admin] format = \"{}\" but is not a text field",
129                        field.admin.format.as_str()
130                    ),
131                });
132            }
133        }
134        // `[admin]` is presentation, so a typo here is silent rather than
135        // dangerous — but it is still a typo, and naming a column that does not
136        // exist is never what anyone meant.
137        for column in &self.admin.columns {
138            if !self.fields.contains_key(column) && column != "id" {
139                return Err(crate::Error::Schema {
140                    resource: self.meta.name.clone(),
141                    message: format!("[admin] columns names unknown field `{column}`"),
142                });
143            }
144        }
145        for (key, declared) in [
146            ("display_field", &self.admin.display_field),
147            ("search_field", &self.admin.search_field),
148        ] {
149            if let Some(field) = declared {
150                let Some(declared_field) = self.fields.get(field) else {
151                    return Err(crate::Error::Schema {
152                        resource: self.meta.name.clone(),
153                        message: format!("[admin] {key} names unknown field `{field}`"),
154                    });
155                };
156                // The search box matches substrings, which only a text column
157                // can do — a search field of another type would be a box that
158                // answers 400 to every keystroke.
159                if key == "search_field"
160                    && !matches!(declared_field.ty, FieldType::String | FieldType::Text)
161                {
162                    return Err(crate::Error::Schema {
163                        resource: self.meta.name.clone(),
164                        message: format!(
165                            "[admin] search_field names `{field}`, which is not a text field and cannot be searched"
166                        ),
167                    });
168                }
169            }
170        }
171        Ok(())
172    }
173
174    /// Human label for a single record, e.g. `"Purchase order"`.
175    pub fn admin_label(&self) -> String {
176        self.admin
177            .label
178            .clone()
179            .unwrap_or_else(|| titleize(&self.meta.name))
180    }
181
182    /// Human label for the collection, e.g. `"Purchase orders"`.
183    pub fn admin_plural(&self) -> String {
184        self.admin
185            .plural
186            .clone()
187            .unwrap_or_else(|| pluralize(&self.admin_label()))
188    }
189
190    /// The field whose value names a record in tables, pickers and headings.
191    ///
192    /// An explicit `display_field` wins; otherwise the first conventionally
193    /// named field (`name`, `title`, …), then the first plain string field, and
194    /// finally `None` — at which point the dashboard falls back to the id.
195    pub fn admin_display_field(&self) -> Option<String> {
196        if let Some(declared) = &self.admin.display_field {
197            if self.fields.contains_key(declared) {
198                return Some(declared.clone());
199            }
200        }
201        const PREFERRED: [&str; 7] = ["name", "title", "label", "slug", "code", "number", "email"];
202        for candidate in PREFERRED {
203            if let Some(field) = self.fields.get(candidate) {
204                if !field.hidden && matches!(field.ty, FieldType::String | FieldType::Text) {
205                    return Some(candidate.to_string());
206                }
207            }
208        }
209        self.fields
210            .iter()
211            .find(|(_, field)| !field.hidden && field.ty == FieldType::String)
212            .map(|(name, _)| name.clone())
213    }
214
215    /// The field the dashboard's list search box filters on.
216    ///
217    /// Always a `string` or `text` column, because searching means matching
218    /// part of a value: a `display_field` of another type names records
219    /// perfectly well and simply leaves the resource without a search box.
220    pub fn admin_search_field(&self) -> Option<String> {
221        let candidate = match &self.admin.search_field {
222            Some(declared) if self.fields.contains_key(declared) => Some(declared.clone()),
223            Some(_) | None => self.admin_display_field(),
224        };
225        candidate.filter(|name| {
226            self.fields
227                .get(name)
228                .is_some_and(|field| matches!(field.ty, FieldType::String | FieldType::Text))
229        })
230    }
231
232    /// The columns the list table shows, in order.
233    ///
234    /// Declared `columns` win. Otherwise: the display field first, then up to
235    /// four more dashboard-visible fields, skipping `json`/`text` blobs (which
236    /// never read well in a cell) and the tenancy column.
237    pub fn admin_columns(&self) -> Vec<String> {
238        let declared: Vec<String> = self
239            .admin
240            .columns
241            .iter()
242            .filter(|name| self.fields.contains_key(*name))
243            .cloned()
244            .collect();
245        if !declared.is_empty() {
246            return declared;
247        }
248
249        let display = self.admin_display_field();
250        let mut columns: Vec<String> = display.iter().cloned().collect();
251        for (name, field) in &self.fields {
252            if columns.len() >= 5 {
253                break;
254            }
255            let skip = field.hidden
256                || !field.admin.visible
257                || Some(name) == display.as_ref()
258                || name == "organization_id"
259                || matches!(field.ty, FieldType::Json | FieldType::Text);
260            if !skip {
261                columns.push(name.clone());
262            }
263        }
264        columns
265    }
266
267    /// All `belongs_to` references declared by this resource's fields.
268    pub fn references(&self) -> Vec<Reference> {
269        self.fields
270            .iter()
271            .filter_map(|(name, field)| {
272                if field.ty != FieldType::Reference {
273                    return None;
274                }
275                let target = field.references.clone()?;
276                Some(Reference {
277                    field: name.clone(),
278                    target,
279                    relation: relation_name(name).to_string(),
280                    on_delete: field.on_delete.unwrap_or(OnDelete::Restrict),
281                    required: field.required,
282                })
283            })
284            .collect()
285    }
286
287    /// Find the reference exposed under a given relation name (`"owner"`).
288    pub fn reference_by_relation(&self, relation: &str) -> Option<Reference> {
289        self.references()
290            .into_iter()
291            .find(|r| r.relation == relation)
292    }
293
294    /// Whether this resource is isolated per organisation (the default).
295    pub fn is_org_scoped(&self) -> bool {
296        self.meta.scope == Scope::Organization
297    }
298
299    /// The column that carries this resource's organisation, if any:
300    /// `organization_id` for org-scoped resources, `id` for the `organization`
301    /// resource itself (its rows *are* organisations), else `None`.
302    pub fn org_column(&self) -> Option<&'static str> {
303        if self.is_org_scoped() {
304            Some("organization_id")
305        } else if self.meta.name == "organization" {
306            Some("id")
307        } else {
308            None
309        }
310    }
311
312    /// Load and validate a single resource file.
313    pub fn load(path: &Path) -> crate::Result<Self> {
314        let text = std::fs::read_to_string(path).map_err(|e| crate::Error::Io {
315            path: path.to_path_buf(),
316            source: e,
317        })?;
318        // Model files get the same `$VAR` expansion `main.toml` does — a
319        // resource can name an environment variable anywhere it takes a string.
320        let source = path.file_name().unwrap_or_default().to_string_lossy();
321        let resource: Resource =
322            crate::env::parse_toml(&text, &source).map_err(|e| crate::Error::Toml {
323                path: path.to_path_buf(),
324                source: e,
325            })?;
326        resource.validate()?;
327        Ok(resource)
328    }
329}
330
331/// The `[admin]` section of a resource: how it is presented — and to whom — in
332/// the generated dashboard.
333///
334/// This is **presentation only**. Hiding a resource from the dashboard does not
335/// make its API endpoints any less reachable; that is what
336/// [`Permissions`] is for. The two are deliberately separate: `[permissions]`
337/// decides what the API allows, `[admin]` decides what an operator is shown.
338#[derive(Debug, Clone, Default, Deserialize)]
339#[serde(default, deny_unknown_fields)]
340pub struct ResourceAdmin {
341    /// Show this resource in the dashboard. Unset means "decide from the
342    /// resource" — see [`ResourceAdmin::is_visible`].
343    pub visible: Option<bool>,
344    /// Organisation roles that may see it. Empty means "anyone who can list it"
345    /// — the API remains the authority either way.
346    pub roles: Vec<String>,
347    /// Human label for one record ("Product"). Defaults to the resource name.
348    pub label: Option<String>,
349    /// Human label for the collection ("Products"). Defaults to `label` + "s".
350    pub plural: Option<String>,
351    /// Sidebar group heading ("Catalogue"). Ungrouped resources sort last.
352    pub group: Option<String>,
353    /// Which field to render when a record is named — in tables, in reference
354    /// pickers, in breadcrumbs. Defaults to the first sensible string field.
355    pub display_field: Option<String>,
356    /// Columns to show in the list table, in order. Empty means "pick for me".
357    pub columns: Vec<String>,
358    /// Field the list search box filters on. Defaults to `display_field`.
359    pub search_field: Option<String>,
360    /// Sort key within the sidebar group; lower comes first.
361    pub order: i64,
362}
363
364impl ResourceAdmin {
365    /// Whether the named resource appears in the dashboard's resource
366    /// navigation.
367    ///
368    /// An explicit `visible` always wins. Otherwise the auth resources default
369    /// to hidden — they are managed through purpose-built screens (account,
370    /// team, organisation, API keys), and a raw table of `membership` rows is
371    /// exactly the developer-facing surface the dashboard is meant to avoid.
372    pub fn is_visible(&self, resource_name: &str) -> bool {
373        self.visible.unwrap_or(!is_auth_resource(resource_name))
374    }
375}
376
377/// Whether a resource is one of the built-in auth/tenancy resources, which the
378/// dashboard manages through dedicated screens instead of generic CRUD.
379pub fn is_auth_resource(name: &str) -> bool {
380    matches!(
381        name,
382        "user" | "organization" | "membership" | "membership_role" | "api_key" | "oauth_connection"
383    )
384}
385
386/// Per-field dashboard presentation, from `[fields.<name>.admin]`.
387#[derive(Debug, Clone, Deserialize)]
388#[serde(default, deny_unknown_fields)]
389pub struct FieldAdmin {
390    /// Show this field in the dashboard at all. Hidden fields are still part of
391    /// the API (unlike [`Field::hidden`]).
392    pub visible: bool,
393    /// Show the field but refuse edits.
394    pub readonly: bool,
395    /// Human label. Defaults to a title-cased field name.
396    pub label: Option<String>,
397    /// One line of guidance shown under the input.
398    pub help: Option<String>,
399    /// Input to render. `auto` picks from the field's type.
400    pub widget: Widget,
401    /// Allowed values, turning the input into a dropdown. Each entry may be
402    /// `"value"` or `"value|Label"`.
403    pub options: Vec<String>,
404    /// Placeholder text for free-text inputs.
405    pub placeholder: Option<String>,
406    /// What the stored text *is*, for `text`/`string` fields. Purely a
407    /// presentation hint: the dashboard highlights and previews the markup,
408    /// and the API stores and returns the same characters either way.
409    pub format: ContentFormat,
410}
411
412impl Default for FieldAdmin {
413    fn default() -> Self {
414        FieldAdmin {
415            visible: true,
416            readonly: false,
417            label: None,
418            help: None,
419            widget: Widget::Auto,
420            options: Vec::new(),
421            placeholder: None,
422            format: ContentFormat::Plain,
423        }
424    }
425}
426
427/// What kind of content a free-text field holds.
428///
429/// Nothing about storage changes — this only tells the dashboard whether to
430/// give the editor markup highlighting and a live preview.
431#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
432#[serde(rename_all = "snake_case")]
433pub enum ContentFormat {
434    /// Ordinary text, edited in a plain textarea (the default).
435    #[default]
436    Plain,
437    Markdown,
438    Html,
439}
440
441impl ContentFormat {
442    pub fn as_str(self) -> &'static str {
443        match self {
444            ContentFormat::Plain => "plain",
445            ContentFormat::Markdown => "markdown",
446            ContentFormat::Html => "html",
447        }
448    }
449}
450
451/// The input a field is edited with in the dashboard.
452#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
453#[serde(rename_all = "snake_case")]
454pub enum Widget {
455    /// Derive the input from the field's type (the default).
456    Auto,
457    Text,
458    Textarea,
459    Select,
460    Email,
461    Url,
462    Password,
463    Color,
464    Date,
465    DateTime,
466    Json,
467    Switch,
468}
469
470impl Widget {
471    pub fn as_str(self) -> &'static str {
472        match self {
473            Widget::Auto => "auto",
474            Widget::Text => "text",
475            Widget::Textarea => "textarea",
476            Widget::Select => "select",
477            Widget::Email => "email",
478            Widget::Url => "url",
479            Widget::Password => "password",
480            Widget::Color => "color",
481            Widget::Date => "date",
482            Widget::DateTime => "date_time",
483            Widget::Json => "json",
484            Widget::Switch => "switch",
485        }
486    }
487}
488
489/// One column in a resource.
490#[derive(Debug, Clone, Deserialize)]
491pub struct Field {
492    #[serde(rename = "type")]
493    pub ty: FieldType,
494    /// Target resource name when `ty == Reference`.
495    #[serde(default)]
496    pub references: Option<String>,
497    #[serde(default)]
498    pub required: bool,
499    #[serde(default)]
500    pub unique: bool,
501    /// Exclude from API responses (e.g. password hashes).
502    #[serde(default)]
503    pub hidden: bool,
504    /// Optional default rendered as a SQL literal.
505    #[serde(default)]
506    pub default: Option<serde_json::Value>,
507    pub max_length: Option<u32>,
508    /// For `reference` fields: what happens to this row when the referenced row
509    /// is deleted. Defaults to [`OnDelete::Restrict`] (safe: blocks orphaning).
510    #[serde(default)]
511    pub on_delete: Option<OnDelete>,
512    /// Dashboard presentation for this field, from `[fields.<name>.admin]`.
513    #[serde(default)]
514    pub admin: FieldAdmin,
515}
516
517/// Supported column types.
518#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
519#[serde(rename_all = "snake_case")]
520pub enum FieldType {
521    String,
522    Text,
523    Integer,
524    BigInt,
525    Float,
526    Boolean,
527    Uuid,
528    Timestamp,
529    Json,
530    /// Foreign key; see [`Field::references`].
531    Reference,
532}
533
534/// Referential action applied by a foreign key when the parent row is deleted.
535#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
536#[serde(rename_all = "snake_case")]
537pub enum OnDelete {
538    /// Block the parent delete while children exist (default).
539    Restrict,
540    /// Null out this reference (requires a nullable column).
541    SetNull,
542    /// Delete this row too.
543    Cascade,
544    /// No referential action.
545    NoAction,
546}
547
548impl OnDelete {
549    pub fn to_sql(self) -> &'static str {
550        match self {
551            OnDelete::Restrict => "RESTRICT",
552            OnDelete::SetNull => "SET NULL",
553            OnDelete::Cascade => "CASCADE",
554            OnDelete::NoAction => "NO ACTION",
555        }
556    }
557}
558
559/// A resolved `belongs_to` edge: the referencing field, its target resource, and
560/// the JSON key it expands under (`owner_id` → `owner`).
561#[derive(Debug, Clone)]
562pub struct Reference {
563    pub field: String,
564    pub target: String,
565    pub relation: String,
566    pub on_delete: OnDelete,
567    pub required: bool,
568}
569
570/// The relation name a reference field expands under: `owner_id` → `owner`,
571/// otherwise the field name unchanged.
572pub fn relation_name(field: &str) -> &str {
573    field.strip_suffix("_id").unwrap_or(field)
574}
575
576/// `"purchase_order"` → `"Purchase order"`. Sentence case, not title case: a
577/// dashboard full of Capitalised Nouns reads like a form, not an application.
578pub fn titleize(name: &str) -> String {
579    let spaced = name.trim_end_matches("_id").replace('_', " ");
580    let mut chars = spaced.chars();
581    match chars.next() {
582        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
583        None => spaced,
584    }
585}
586
587/// A deliberately small English pluraliser — enough for the labels apiplant
588/// generates, and overridable per resource with `[admin] plural`.
589pub fn pluralize(label: &str) -> String {
590    let lower = label.to_lowercase();
591    if lower.ends_with('s')
592        || lower.ends_with("x")
593        || lower.ends_with("ch")
594        || lower.ends_with("sh")
595    {
596        format!("{label}es")
597    } else if lower.ends_with('y')
598        && !lower.ends_with("ay")
599        && !lower.ends_with("ey")
600        && !lower.ends_with("oy")
601        && !lower.ends_with("uy")
602    {
603        format!("{}ies", &label[..label.len() - 1])
604    } else {
605        format!("{label}s")
606    }
607}
608
609/// Whether a resource is isolated per organisation (the default) or shared
610/// across the whole deployment.
611#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
612#[serde(rename_all = "snake_case")]
613pub enum Scope {
614    /// Rows belong to an organisation; every request is scoped to the caller's
615    /// active organisation and `organization_id` is enforced automatically.
616    Organization,
617    /// Not tenant-scoped — shared by everyone, governed only by `[permissions]`.
618    Global,
619}
620
621/// Access policy for a single action on a resource.
622///
623/// On an organisation-scoped resource, org membership is always required and
624/// queries are already filtered to the caller's active organisation; these
625/// levels then decide *who among the members* may act. `Role` is an
626/// **organisation** role (from the caller's membership), not a global one.
627#[derive(Debug, Clone, PartialEq, Eq)]
628pub enum Access {
629    /// No auth required. Only meaningful on `global` resources; on an
630    /// org-scoped resource it is treated like `member`.
631    Public,
632    /// Any authenticated principal (⇒ any member, on an org-scoped resource).
633    Authenticated,
634    /// Any member of the (active) organisation.
635    Member,
636    /// A member holding the named role **within the organisation**.
637    Role(String),
638    /// The principal must own the row (owner_field == principal id).
639    Owner,
640    /// Never exposed.
641    Private,
642}
643
644impl Access {
645    /// Parse the string form used in TOML (`"public"`, `"member"`, `"role:admin"`, …).
646    pub fn parse(s: &str) -> Access {
647        match s {
648            "public" => Access::Public,
649            "authenticated" => Access::Authenticated,
650            "member" => Access::Member,
651            "owner" => Access::Owner,
652            "private" => Access::Private,
653            other => other
654                .strip_prefix("role:")
655                .map(|role| Access::Role(role.to_string()))
656                .unwrap_or(Access::Private),
657        }
658    }
659}
660
661/// Per-action permissions. Strings in TOML are parsed via [`Access::parse`].
662#[derive(Debug, Clone, Deserialize)]
663#[serde(from = "PermissionsRaw")]
664pub struct Permissions {
665    pub list: Access,
666    pub read: Access,
667    pub create: Access,
668    pub update: Access,
669    pub delete: Access,
670}
671
672impl Default for Permissions {
673    fn default() -> Self {
674        // Multitenant-by-default: every action is limited to members of the
675        // caller's organisation. On a `global` resource, `member` behaves like
676        // `authenticated` (there is no org to belong to).
677        Permissions {
678            list: Access::Member,
679            read: Access::Member,
680            create: Access::Member,
681            update: Access::Member,
682            delete: Access::Member,
683        }
684    }
685}
686
687#[derive(Deserialize)]
688struct PermissionsRaw {
689    list: Option<String>,
690    read: Option<String>,
691    create: Option<String>,
692    update: Option<String>,
693    delete: Option<String>,
694}
695
696impl From<PermissionsRaw> for Permissions {
697    fn from(r: PermissionsRaw) -> Self {
698        let d = Permissions::default();
699        Permissions {
700            list: r.list.map(|s| Access::parse(&s)).unwrap_or(d.list),
701            read: r.read.map(|s| Access::parse(&s)).unwrap_or(d.read),
702            create: r.create.map(|s| Access::parse(&s)).unwrap_or(d.create),
703            update: r.update.map(|s| Access::parse(&s)).unwrap_or(d.update),
704            delete: r.delete.map(|s| Access::parse(&s)).unwrap_or(d.delete),
705        }
706    }
707}
708
709/// One point in a resource's request lifecycle at which a function may run.
710///
711/// `before_*` hooks run after the permission check but before the database is
712/// touched, so they can validate, rewrite the submitted payload, or abort the
713/// request. `after_*` hooks run once the operation succeeded and can rewrite the
714/// response body.
715#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
716pub enum HookEvent {
717    BeforeList,
718    AfterList,
719    BeforeRead,
720    AfterRead,
721    BeforeCreate,
722    AfterCreate,
723    BeforeUpdate,
724    AfterUpdate,
725    BeforeDelete,
726    AfterDelete,
727}
728
729impl HookEvent {
730    /// Every event, in lifecycle order.
731    pub const ALL: [HookEvent; 10] = [
732        HookEvent::BeforeList,
733        HookEvent::AfterList,
734        HookEvent::BeforeRead,
735        HookEvent::AfterRead,
736        HookEvent::BeforeCreate,
737        HookEvent::AfterCreate,
738        HookEvent::BeforeUpdate,
739        HookEvent::AfterUpdate,
740        HookEvent::BeforeDelete,
741        HookEvent::AfterDelete,
742    ];
743
744    /// The TOML key / wire name, e.g. `"before_create"`.
745    pub fn as_str(self) -> &'static str {
746        match self {
747            HookEvent::BeforeList => "before_list",
748            HookEvent::AfterList => "after_list",
749            HookEvent::BeforeRead => "before_read",
750            HookEvent::AfterRead => "after_read",
751            HookEvent::BeforeCreate => "before_create",
752            HookEvent::AfterCreate => "after_create",
753            HookEvent::BeforeUpdate => "before_update",
754            HookEvent::AfterUpdate => "after_update",
755            HookEvent::BeforeDelete => "before_delete",
756            HookEvent::AfterDelete => "after_delete",
757        }
758    }
759
760    /// The operation this event belongs to (`"create"`, `"list"`, …).
761    pub fn action(self) -> &'static str {
762        match self {
763            HookEvent::BeforeList | HookEvent::AfterList => "list",
764            HookEvent::BeforeRead | HookEvent::AfterRead => "read",
765            HookEvent::BeforeCreate | HookEvent::AfterCreate => "create",
766            HookEvent::BeforeUpdate | HookEvent::AfterUpdate => "update",
767            HookEvent::BeforeDelete | HookEvent::AfterDelete => "delete",
768        }
769    }
770
771    /// `"before"` or `"after"`.
772    pub fn phase(self) -> &'static str {
773        if self.is_before() {
774            "before"
775        } else {
776            "after"
777        }
778    }
779
780    /// Whether this event fires ahead of the database operation.
781    pub fn is_before(self) -> bool {
782        matches!(
783            self,
784            HookEvent::BeforeList
785                | HookEvent::BeforeRead
786                | HookEvent::BeforeCreate
787                | HookEvent::BeforeUpdate
788                | HookEvent::BeforeDelete
789        )
790    }
791}
792
793/// The `[hooks]` section of a resource: a function name per lifecycle event.
794///
795/// Unknown keys are rejected so a typo (`befor_create`) fails at load time
796/// instead of silently never firing.
797///
798/// The [`AuthEvent`] keys live here too, and are only meaningful on the `user`
799/// resource — declaring one anywhere else fails
800/// [validation](Resource::validate), since nothing would ever fire it.
801#[derive(Debug, Clone, Default, Deserialize)]
802#[serde(default, deny_unknown_fields)]
803pub struct Hooks {
804    pub before_list: Option<String>,
805    pub after_list: Option<String>,
806    pub before_read: Option<String>,
807    pub after_read: Option<String>,
808    pub before_create: Option<String>,
809    pub after_create: Option<String>,
810    pub before_update: Option<String>,
811    pub after_update: Option<String>,
812    pub before_delete: Option<String>,
813    pub after_delete: Option<String>,
814    pub before_register: Option<String>,
815    pub after_register: Option<String>,
816    pub before_login: Option<String>,
817    pub after_login: Option<String>,
818    pub before_api_key: Option<String>,
819    pub after_api_key: Option<String>,
820}
821
822impl Hooks {
823    /// The function bound to an event, if any.
824    pub fn get(&self, event: HookEvent) -> Option<&str> {
825        let slot = match event {
826            HookEvent::BeforeList => &self.before_list,
827            HookEvent::AfterList => &self.after_list,
828            HookEvent::BeforeRead => &self.before_read,
829            HookEvent::AfterRead => &self.after_read,
830            HookEvent::BeforeCreate => &self.before_create,
831            HookEvent::AfterCreate => &self.after_create,
832            HookEvent::BeforeUpdate => &self.before_update,
833            HookEvent::AfterUpdate => &self.after_update,
834            HookEvent::BeforeDelete => &self.before_delete,
835            HookEvent::AfterDelete => &self.after_delete,
836        };
837        slot.as_deref()
838    }
839
840    /// Every declared `(event, function)` pair, in lifecycle order.
841    pub fn iter(&self) -> impl Iterator<Item = (HookEvent, &str)> {
842        HookEvent::ALL
843            .into_iter()
844            .filter_map(|event| self.get(event).map(|name| (event, name)))
845    }
846
847    /// Whether any hook at all is declared, auth events included.
848    pub fn is_empty(&self) -> bool {
849        self.iter().next().is_none() && self.auth_iter().next().is_none()
850    }
851}
852
853/// Auth configuration carried in a `[auth]` section on the `user` resource.
854#[derive(Debug, Clone, Deserialize)]
855#[serde(default)]
856pub struct AuthSpec {
857    /// Field used as the login identifier.
858    pub identity_field: String,
859    /// Field holding the password hash.
860    pub password_field: String,
861    /// Enabled OAuth providers, e.g. `["google", "facebook"]`.
862    pub oauth_providers: Vec<String>,
863}
864
865impl Default for AuthSpec {
866    fn default() -> Self {
867        AuthSpec {
868            identity_field: "email".to_string(),
869            password_field: "password_hash".to_string(),
870            oauth_providers: Vec::new(),
871        }
872    }
873}
874
875/// One point in an auth endpoint's lifecycle at which a function may run.
876///
877/// These are declared in the `user` model's ordinary `[hooks]` section, next to
878/// its CRUD hooks, and are only meaningful there — the built-in endpoints are
879/// the `user` resource's other door. They sit alongside the [`HookEvent`]s
880/// rather than replacing them: registration is still a `create` on `user`, so
881/// `before_create` / `after_create` fire there too.
882#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
883pub enum AuthEvent {
884    BeforeRegister,
885    AfterRegister,
886    BeforeLogin,
887    AfterLogin,
888    BeforeApiKey,
889    AfterApiKey,
890}
891
892impl AuthEvent {
893    /// Every event, in lifecycle order.
894    pub const ALL: [AuthEvent; 6] = [
895        AuthEvent::BeforeRegister,
896        AuthEvent::AfterRegister,
897        AuthEvent::BeforeLogin,
898        AuthEvent::AfterLogin,
899        AuthEvent::BeforeApiKey,
900        AuthEvent::AfterApiKey,
901    ];
902
903    /// The TOML key / wire name, e.g. `"before_login"`.
904    pub fn as_str(self) -> &'static str {
905        match self {
906            AuthEvent::BeforeRegister => "before_register",
907            AuthEvent::AfterRegister => "after_register",
908            AuthEvent::BeforeLogin => "before_login",
909            AuthEvent::AfterLogin => "after_login",
910            AuthEvent::BeforeApiKey => "before_api_key",
911            AuthEvent::AfterApiKey => "after_api_key",
912        }
913    }
914
915    /// The endpoint this event belongs to (`"register"`, `"login"`, `"api_key"`).
916    pub fn action(self) -> &'static str {
917        match self {
918            AuthEvent::BeforeRegister | AuthEvent::AfterRegister => "register",
919            AuthEvent::BeforeLogin | AuthEvent::AfterLogin => "login",
920            AuthEvent::BeforeApiKey | AuthEvent::AfterApiKey => "api_key",
921        }
922    }
923
924    /// `"before"` or `"after"`.
925    pub fn phase(self) -> &'static str {
926        if self.is_before() {
927            "before"
928        } else {
929            "after"
930        }
931    }
932
933    /// Whether this event fires ahead of the work the endpoint does.
934    pub fn is_before(self) -> bool {
935        matches!(
936            self,
937            AuthEvent::BeforeRegister | AuthEvent::BeforeLogin | AuthEvent::BeforeApiKey
938        )
939    }
940}
941
942impl Hooks {
943    /// The function bound to an auth event, if any.
944    pub fn get_auth(&self, event: AuthEvent) -> Option<&str> {
945        let slot = match event {
946            AuthEvent::BeforeRegister => &self.before_register,
947            AuthEvent::AfterRegister => &self.after_register,
948            AuthEvent::BeforeLogin => &self.before_login,
949            AuthEvent::AfterLogin => &self.after_login,
950            AuthEvent::BeforeApiKey => &self.before_api_key,
951            AuthEvent::AfterApiKey => &self.after_api_key,
952        };
953        slot.as_deref()
954    }
955
956    /// Every declared auth `(event, function)` pair, in lifecycle order.
957    pub fn auth_iter(&self) -> impl Iterator<Item = (AuthEvent, &str)> {
958        AuthEvent::ALL
959            .into_iter()
960            .filter_map(|event| self.get_auth(event).map(|name| (event, name)))
961    }
962}
963
964#[cfg(test)]
965mod tests {
966    use super::*;
967
968    fn parse_resource(src: &str) -> Resource {
969        let resource: Resource = toml::from_str(src).unwrap();
970        resource.validate().unwrap();
971        resource
972    }
973
974    #[test]
975    fn access_parser_and_permissions_defaults_match_org_membership_model() {
976        assert_eq!(Access::parse("public"), Access::Public);
977        assert_eq!(Access::parse("authenticated"), Access::Authenticated);
978        assert_eq!(Access::parse("member"), Access::Member);
979        assert_eq!(Access::parse("owner"), Access::Owner);
980        assert_eq!(Access::parse("role:admin"), Access::Role("admin".into()));
981        assert_eq!(Access::parse("wat"), Access::Private);
982
983        let defaults = Permissions::default();
984        assert_eq!(defaults.list, Access::Member);
985        assert_eq!(defaults.read, Access::Member);
986        assert_eq!(defaults.create, Access::Member);
987        assert_eq!(defaults.update, Access::Member);
988        assert_eq!(defaults.delete, Access::Member);
989    }
990
991    #[test]
992    fn validate_rejects_reserved_id_and_dangling_reference_definition() {
993        let reserved_id: Resource = toml::from_str(
994            r#"
995[resource]
996name = "bad"
997
998[fields.id]
999type = "string"
1000"#,
1001        )
1002        .unwrap();
1003        assert!(reserved_id.validate().is_err());
1004
1005        let missing_target: Resource = toml::from_str(
1006            r#"
1007[resource]
1008name = "bad_ref"
1009
1010[fields.owner_id]
1011type = "reference"
1012"#,
1013        )
1014        .unwrap();
1015        assert!(missing_target.validate().is_err());
1016    }
1017
1018    #[test]
1019    fn content_format_is_only_allowed_on_text_fields() {
1020        let good: Resource = toml::from_str(
1021            r#"
1022[resource]
1023name = "article"
1024
1025[fields.body]
1026type = "text"
1027
1028[fields.body.admin]
1029format = "markdown"
1030"#,
1031        )
1032        .unwrap();
1033        good.validate().unwrap();
1034        assert_eq!(good.fields["body"].admin.format, ContentFormat::Markdown);
1035
1036        let bad: Resource = toml::from_str(
1037            r#"
1038[resource]
1039name = "article"
1040
1041[fields.published]
1042type = "boolean"
1043
1044[fields.published.admin]
1045format = "html"
1046"#,
1047        )
1048        .unwrap();
1049        assert!(bad.validate().is_err());
1050    }
1051
1052    #[test]
1053    fn references_derive_relation_names_and_default_on_delete() {
1054        let resource = parse_resource(
1055            r#"
1056[resource]
1057name = "comment"
1058
1059[fields.post_id]
1060type = "reference"
1061references = "post"
1062required = true
1063
1064[fields.author_id]
1065type = "reference"
1066references = "user"
1067on_delete = "cascade"
1068"#,
1069        );
1070
1071        assert_eq!(relation_name("owner_id"), "owner");
1072        assert_eq!(relation_name("slug"), "slug");
1073
1074        let refs = resource.references();
1075        assert_eq!(refs.len(), 2);
1076        let post = refs.iter().find(|rf| rf.field == "post_id").unwrap();
1077        assert_eq!(post.target, "post");
1078        assert_eq!(post.relation, "post");
1079        assert_eq!(post.on_delete, OnDelete::Restrict);
1080        assert!(post.required);
1081
1082        let author = resource.reference_by_relation("author").unwrap();
1083        assert_eq!(author.field, "author_id");
1084        assert_eq!(author.on_delete, OnDelete::Cascade);
1085    }
1086
1087    #[test]
1088    fn hooks_parse_per_event_and_iterate_in_lifecycle_order() {
1089        let resource = parse_resource(
1090            r#"
1091[resource]
1092name = "post"
1093
1094[fields.title]
1095type = "string"
1096
1097[hooks]
1098before_create = "validate_post"
1099after_create = "notify"
1100after_list = "redact"
1101"#,
1102        );
1103
1104        assert_eq!(
1105            resource.hook(HookEvent::BeforeCreate),
1106            Some("validate_post")
1107        );
1108        assert_eq!(resource.hook(HookEvent::AfterCreate), Some("notify"));
1109        assert_eq!(resource.hook(HookEvent::AfterList), Some("redact"));
1110        assert_eq!(resource.hook(HookEvent::BeforeUpdate), None);
1111        assert!(!resource.hooks.is_empty());
1112
1113        let declared: Vec<_> = resource.hooks.iter().collect();
1114        assert_eq!(
1115            declared,
1116            vec![
1117                (HookEvent::AfterList, "redact"),
1118                (HookEvent::BeforeCreate, "validate_post"),
1119                (HookEvent::AfterCreate, "notify"),
1120            ]
1121        );
1122    }
1123
1124    #[test]
1125    fn hook_events_expose_wire_names_actions_and_phases() {
1126        assert_eq!(HookEvent::BeforeCreate.as_str(), "before_create");
1127        assert_eq!(HookEvent::BeforeCreate.action(), "create");
1128        assert_eq!(HookEvent::BeforeCreate.phase(), "before");
1129        assert!(HookEvent::BeforeCreate.is_before());
1130
1131        assert_eq!(HookEvent::AfterList.as_str(), "after_list");
1132        assert_eq!(HookEvent::AfterList.action(), "list");
1133        assert_eq!(HookEvent::AfterList.phase(), "after");
1134        assert!(!HookEvent::AfterList.is_before());
1135
1136        // Every event round-trips through the `[hooks]` section under its own key.
1137        for event in HookEvent::ALL {
1138            let resource = parse_resource(&format!(
1139                "[resource]\nname = \"post\"\n\n[hooks]\n{} = \"h\"\n",
1140                event.as_str()
1141            ));
1142            assert_eq!(resource.hook(event), Some("h"), "{}", event.as_str());
1143            assert_eq!(resource.hooks.iter().count(), 1);
1144        }
1145    }
1146
1147    #[test]
1148    fn hooks_reject_typos_and_empty_function_names() {
1149        let typo = toml::from_str::<Resource>(
1150            r#"
1151[resource]
1152name = "post"
1153
1154[hooks]
1155befor_create = "oops"
1156"#,
1157        );
1158        assert!(typo.is_err(), "unknown hook keys must not be ignored");
1159
1160        let empty: Resource = toml::from_str(
1161            r#"
1162[resource]
1163name = "post"
1164
1165[hooks]
1166after_delete = "  "
1167"#,
1168        )
1169        .unwrap();
1170        assert!(empty.validate().is_err());
1171    }
1172
1173    #[test]
1174    fn auth_events_expose_wire_names_actions_and_phases() {
1175        assert_eq!(AuthEvent::BeforeLogin.as_str(), "before_login");
1176        assert_eq!(AuthEvent::BeforeLogin.action(), "login");
1177        assert_eq!(AuthEvent::BeforeLogin.phase(), "before");
1178        assert!(AuthEvent::BeforeLogin.is_before());
1179
1180        assert_eq!(AuthEvent::AfterApiKey.action(), "api_key");
1181        assert_eq!(AuthEvent::AfterApiKey.phase(), "after");
1182        assert!(!AuthEvent::AfterApiKey.is_before());
1183
1184        // Every event round-trips through `[hooks]` under its own key, next to
1185        // the CRUD ones.
1186        for event in AuthEvent::ALL {
1187            let resource = parse_resource(&format!(
1188                "[resource]\nname = \"user\"\n\n[hooks]\nafter_create = \"c\"\n{} = \"h\"\n",
1189                event.as_str()
1190            ));
1191            assert_eq!(resource.auth_hook(event), Some("h"), "{}", event.as_str());
1192            assert_eq!(resource.hook(HookEvent::AfterCreate), Some("c"));
1193            assert_eq!(resource.hooks.auth_iter().count(), 1);
1194            // The CRUD iterator stays CRUD-only, so nothing that walks a
1195            // resource's lifecycle events picks up an auth one by accident.
1196            assert_eq!(resource.hooks.iter().count(), 1);
1197        }
1198    }
1199
1200    #[test]
1201    fn auth_hooks_are_absent_by_default_and_reject_typos_and_empty_names() {
1202        let plain =
1203            parse_resource("[resource]\nname = \"user\"\n\n[hooks]\nafter_create = \"c\"\n");
1204        assert_eq!(plain.auth_hook(AuthEvent::BeforeLogin), None);
1205        assert!(parse_resource("[resource]\nname = \"user\"\n")
1206            .hooks
1207            .is_empty());
1208
1209        let typo = toml::from_str::<Resource>(
1210            "[resource]\nname = \"user\"\n\n[hooks]\nbefore_signin = \"oops\"\n",
1211        );
1212        assert!(typo.is_err(), "unknown auth hook keys must not be ignored");
1213
1214        let empty: Resource =
1215            toml::from_str("[resource]\nname = \"user\"\n\n[hooks]\nafter_login = \"  \"\n")
1216                .unwrap();
1217        assert!(empty.validate().is_err());
1218    }
1219
1220    #[test]
1221    fn auth_hooks_are_rejected_on_any_resource_but_user() {
1222        // The key parses anywhere — `[hooks]` is one section — so validation is
1223        // what catches a hook nothing would ever fire.
1224        let stray: Resource =
1225            toml::from_str("[resource]\nname = \"post\"\n\n[hooks]\nbefore_login = \"h\"\n")
1226                .unwrap();
1227        let message = stray.validate().unwrap_err().to_string();
1228        assert!(message.contains("before_login"), "{message}");
1229        assert!(message.contains("user"), "{message}");
1230    }
1231
1232    #[test]
1233    fn resources_have_no_hooks_by_default() {
1234        let resource = parse_resource("[resource]\nname = \"post\"\n");
1235        assert!(resource.hooks.is_empty());
1236        assert!(HookEvent::ALL
1237            .into_iter()
1238            .all(|event| resource.hook(event).is_none()));
1239    }
1240
1241    #[test]
1242    fn admin_visibility_defaults_hide_auth_resources_but_nothing_else() {
1243        let post = parse_resource("[resource]\nname = \"post\"\n");
1244        assert!(post.admin.is_visible("post"));
1245
1246        let user = parse_resource(crate::defaults::USER_TOML);
1247        assert!(!user.admin.is_visible("user"));
1248        assert!(!parse_resource(crate::defaults::MEMBERSHIP_TOML)
1249            .admin
1250            .is_visible("membership"));
1251
1252        // An app that replaces a built-in still gets the dedicated screen…
1253        let replaced = parse_resource(
1254            "[resource]\nname = \"user\"\nscope = \"global\"\n\n[fields.email]\ntype = \"string\"\n",
1255        );
1256        assert!(!replaced.admin.is_visible("user"));
1257
1258        // …unless it asks for the table back.
1259        let opted_in = parse_resource(
1260            "[resource]\nname = \"user\"\nscope = \"global\"\n\n[admin]\nvisible = true\n",
1261        );
1262        assert!(opted_in.admin.is_visible("user"));
1263    }
1264
1265    #[test]
1266    fn admin_labels_are_inferred_and_overridable() {
1267        let inferred = parse_resource("[resource]\nname = \"purchase_order\"\n");
1268        assert_eq!(inferred.admin_label(), "Purchase order");
1269        assert_eq!(inferred.admin_plural(), "Purchase orders");
1270
1271        let overridden = parse_resource(
1272            "[resource]\nname = \"person\"\n\n[admin]\nlabel = \"Person\"\nplural = \"People\"\n",
1273        );
1274        assert_eq!(overridden.admin_plural(), "People");
1275
1276        assert_eq!(titleize("owner_id"), "Owner");
1277        assert_eq!(titleize("total_cents"), "Total cents");
1278        assert_eq!(pluralize("Category"), "Categories");
1279        assert_eq!(pluralize("Address"), "Addresses");
1280        assert_eq!(pluralize("Day"), "Days");
1281        assert_eq!(pluralize("Product"), "Products");
1282    }
1283
1284    #[test]
1285    fn display_field_prefers_conventional_names_then_any_string() {
1286        let conventional = parse_resource(
1287            r#"
1288[resource]
1289name = "product"
1290
1291[fields.sku]
1292type = "string"
1293
1294[fields.name]
1295type = "string"
1296"#,
1297        );
1298        assert_eq!(conventional.admin_display_field().as_deref(), Some("name"));
1299
1300        let only_odd_names =
1301            parse_resource("[resource]\nname = \"blob\"\n\n[fields.zzz]\ntype = \"string\"\n");
1302        assert_eq!(only_odd_names.admin_display_field().as_deref(), Some("zzz"));
1303
1304        let nothing_stringy =
1305            parse_resource("[resource]\nname = \"tick\"\n\n[fields.count]\ntype = \"integer\"\n");
1306        assert_eq!(nothing_stringy.admin_display_field(), None);
1307
1308        // An explicit choice always wins, conventional or not.
1309        let declared = parse_resource(
1310            r#"
1311[resource]
1312name = "product"
1313
1314[admin]
1315display_field = "sku"
1316
1317[fields.sku]
1318type = "string"
1319
1320[fields.name]
1321type = "string"
1322"#,
1323        );
1324        assert_eq!(declared.admin_display_field().as_deref(), Some("sku"));
1325        // `search_field` falls back to whatever names the record.
1326        assert_eq!(declared.admin_search_field().as_deref(), Some("sku"));
1327    }
1328
1329    #[test]
1330    fn inferred_columns_skip_blobs_and_dashboard_hidden_fields() {
1331        let resource = parse_resource(
1332            r#"
1333[resource]
1334name = "product"
1335
1336[fields.name]
1337type = "string"
1338
1339[fields.status]
1340type = "string"
1341
1342[fields.description]
1343type = "text"
1344
1345[fields.attributes]
1346type = "json"
1347
1348[fields.secret_ratio]
1349type = "float"
1350
1351[fields.secret_ratio.admin]
1352visible = false
1353"#,
1354        );
1355        assert_eq!(resource.admin_columns(), vec!["name", "status"]);
1356
1357        let declared = parse_resource(
1358            r#"
1359[resource]
1360name = "product"
1361
1362[admin]
1363columns = ["status", "name"]
1364
1365[fields.name]
1366type = "string"
1367
1368[fields.status]
1369type = "string"
1370"#,
1371        );
1372        assert_eq!(declared.admin_columns(), vec!["status", "name"]);
1373    }
1374
1375    #[test]
1376    fn admin_section_rejects_columns_naming_fields_that_do_not_exist() {
1377        let bad_column: Resource = toml::from_str(
1378            "[resource]\nname = \"post\"\n\n[admin]\ncolumns = [\"nope\"]\n\n[fields.title]\ntype = \"string\"\n",
1379        )
1380        .unwrap();
1381        assert!(bad_column.validate().is_err());
1382
1383        let bad_display: Resource = toml::from_str(
1384            "[resource]\nname = \"post\"\n\n[admin]\ndisplay_field = \"nope\"\n\n[fields.title]\ntype = \"string\"\n",
1385        )
1386        .unwrap();
1387        assert!(bad_display.validate().is_err());
1388
1389        // The search box matches substrings, so a search field that is not text
1390        // would be a box that answers 400 to every keystroke.
1391        let unsearchable: Resource = toml::from_str(
1392            "[resource]\nname = \"tick\"\n\n[admin]\nsearch_field = \"count\"\n\n[fields.count]\ntype = \"integer\"\n",
1393        )
1394        .unwrap();
1395        assert!(unsearchable.validate().is_err());
1396
1397        // And one inherited from a non-text `display_field` simply leaves the
1398        // resource without a search box, rather than failing the app.
1399        let named_by_a_number = parse_resource(
1400            "[resource]\nname = \"tick\"\n\n[admin]\ndisplay_field = \"count\"\n\n[fields.count]\ntype = \"integer\"\n",
1401        );
1402        assert_eq!(
1403            named_by_a_number.admin_display_field().as_deref(),
1404            Some("count")
1405        );
1406        assert_eq!(named_by_a_number.admin_search_field(), None);
1407
1408        // A typo in a key is caught too, rather than silently ignored.
1409        assert!(toml::from_str::<Resource>(
1410            "[resource]\nname = \"post\"\n\n[admin]\nvisibel = true\n"
1411        )
1412        .is_err());
1413    }
1414
1415    #[test]
1416    fn field_admin_carries_widget_options_and_visibility() {
1417        let resource = parse_resource(
1418            r#"
1419[resource]
1420name = "product"
1421
1422[fields.status]
1423type = "string"
1424
1425[fields.status.admin]
1426label = "Lifecycle"
1427widget = "select"
1428options = ["draft", "active|Live"]
1429readonly = true
1430"#,
1431        );
1432        let status = &resource.fields["status"];
1433        assert_eq!(status.admin.label.as_deref(), Some("Lifecycle"));
1434        assert_eq!(status.admin.widget, Widget::Select);
1435        assert_eq!(status.admin.widget.as_str(), "select");
1436        assert_eq!(status.admin.options, vec!["draft", "active|Live"]);
1437        assert!(status.admin.readonly);
1438        // Defaults stay out of the way when `[fields.x.admin]` says nothing.
1439        assert!(status.admin.visible);
1440        assert_eq!(resource.fields["status"].admin.help, None);
1441    }
1442
1443    #[test]
1444    fn org_column_reflects_resource_scope() {
1445        let org_scoped = parse_resource(
1446            r#"
1447[resource]
1448name = "post"
1449
1450[fields.title]
1451type = "string"
1452"#,
1453        );
1454        let global = parse_resource(
1455            r#"
1456[resource]
1457name = "plan"
1458scope = "global"
1459
1460[fields.name]
1461type = "string"
1462"#,
1463        );
1464        let organization = parse_resource(crate::defaults::ORGANIZATION_TOML);
1465
1466        assert_eq!(org_scoped.org_column(), Some("organization_id"));
1467        assert_eq!(global.org_column(), None);
1468        assert_eq!(organization.org_column(), Some("id"));
1469    }
1470}