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"
383            | "organization"
384            | "membership"
385            | "membership_role"
386            | "api_key"
387            | "oauth_connection"
388            | "invitation"
389            | "auth_token"
390    )
391}
392
393/// Per-field dashboard presentation, from `[fields.<name>.admin]`.
394#[derive(Debug, Clone, Deserialize)]
395#[serde(default, deny_unknown_fields)]
396pub struct FieldAdmin {
397    /// Show this field in the dashboard at all. Hidden fields are still part of
398    /// the API (unlike [`Field::hidden`]).
399    pub visible: bool,
400    /// Show the field but refuse edits.
401    pub readonly: bool,
402    /// Human label. Defaults to a title-cased field name.
403    pub label: Option<String>,
404    /// One line of guidance shown under the input.
405    pub help: Option<String>,
406    /// Input to render. `auto` picks from the field's type.
407    pub widget: Widget,
408    /// Allowed values, turning the input into a dropdown. Each entry may be
409    /// `"value"` or `"value|Label"`.
410    pub options: Vec<String>,
411    /// Placeholder text for free-text inputs.
412    pub placeholder: Option<String>,
413    /// Collect this field on the registration form.
414    ///
415    /// Only meaningful on the `user` model. Unset means "decide from the field"
416    /// — see [`FieldAdmin::in_signup`] — which is what every app that never
417    /// thinks about this gets. Setting it is how a model says *this* is one of
418    /// the things we ask a new person for: adding `name` and `surname` to
419    /// `user` and marking them `signup = true` puts two boxes on the form
420    /// without making either of them mandatory.
421    pub signup: Option<bool>,
422    /// What the stored text *is*, for `text`/`string` fields. Purely a
423    /// presentation hint: the dashboard highlights and previews the markup,
424    /// and the API stores and returns the same characters either way.
425    pub format: ContentFormat,
426}
427
428impl Default for FieldAdmin {
429    fn default() -> Self {
430        FieldAdmin {
431            visible: true,
432            readonly: false,
433            label: None,
434            help: None,
435            widget: Widget::Auto,
436            options: Vec::new(),
437            placeholder: None,
438            format: ContentFormat::Plain,
439            signup: None,
440        }
441    }
442}
443
444impl FieldAdmin {
445    /// Whether the registration form collects this field.
446    ///
447    /// An explicit `signup` always wins. Otherwise a field is asked for exactly
448    /// when leaving it out would break the signup — i.e. when the model
449    /// *requires* it — which is the behaviour every app had before this
450    /// attribute existed.
451    pub fn in_signup(&self, field: &Field) -> bool {
452        self.signup.unwrap_or(field.required)
453    }
454}
455
456/// What kind of content a free-text field holds.
457///
458/// Nothing about storage changes — this only tells the dashboard whether to
459/// give the editor markup highlighting and a live preview.
460#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
461#[serde(rename_all = "snake_case")]
462pub enum ContentFormat {
463    /// Ordinary text, edited in a plain textarea (the default).
464    #[default]
465    Plain,
466    Markdown,
467    Html,
468}
469
470impl ContentFormat {
471    pub fn as_str(self) -> &'static str {
472        match self {
473            ContentFormat::Plain => "plain",
474            ContentFormat::Markdown => "markdown",
475            ContentFormat::Html => "html",
476        }
477    }
478}
479
480/// The input a field is edited with in the dashboard.
481#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
482#[serde(rename_all = "snake_case")]
483pub enum Widget {
484    /// Derive the input from the field's type (the default).
485    Auto,
486    Text,
487    Textarea,
488    Select,
489    Email,
490    Url,
491    Password,
492    Color,
493    Date,
494    DateTime,
495    Json,
496    Switch,
497}
498
499impl Widget {
500    pub fn as_str(self) -> &'static str {
501        match self {
502            Widget::Auto => "auto",
503            Widget::Text => "text",
504            Widget::Textarea => "textarea",
505            Widget::Select => "select",
506            Widget::Email => "email",
507            Widget::Url => "url",
508            Widget::Password => "password",
509            Widget::Color => "color",
510            Widget::Date => "date",
511            Widget::DateTime => "date_time",
512            Widget::Json => "json",
513            Widget::Switch => "switch",
514        }
515    }
516}
517
518/// One column in a resource.
519#[derive(Debug, Clone, Deserialize)]
520pub struct Field {
521    #[serde(rename = "type")]
522    pub ty: FieldType,
523    /// Target resource name when `ty == Reference`.
524    #[serde(default)]
525    pub references: Option<String>,
526    #[serde(default)]
527    pub required: bool,
528    #[serde(default)]
529    pub unique: bool,
530    /// Exclude from API responses (e.g. password hashes).
531    #[serde(default)]
532    pub hidden: bool,
533    /// Optional default rendered as a SQL literal.
534    #[serde(default)]
535    pub default: Option<serde_json::Value>,
536    pub max_length: Option<u32>,
537    /// For `reference` fields: what happens to this row when the referenced row
538    /// is deleted. Defaults to [`OnDelete::Restrict`] (safe: blocks orphaning).
539    #[serde(default)]
540    pub on_delete: Option<OnDelete>,
541    /// Dashboard presentation for this field, from `[fields.<name>.admin]`.
542    #[serde(default)]
543    pub admin: FieldAdmin,
544}
545
546/// Supported column types.
547#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
548#[serde(rename_all = "snake_case")]
549pub enum FieldType {
550    String,
551    Text,
552    Integer,
553    BigInt,
554    Float,
555    Boolean,
556    Uuid,
557    Timestamp,
558    Json,
559    /// Foreign key; see [`Field::references`].
560    Reference,
561}
562
563/// Referential action applied by a foreign key when the parent row is deleted.
564#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
565#[serde(rename_all = "snake_case")]
566pub enum OnDelete {
567    /// Block the parent delete while children exist (default).
568    Restrict,
569    /// Null out this reference (requires a nullable column).
570    SetNull,
571    /// Delete this row too.
572    Cascade,
573    /// No referential action.
574    NoAction,
575}
576
577impl OnDelete {
578    pub fn to_sql(self) -> &'static str {
579        match self {
580            OnDelete::Restrict => "RESTRICT",
581            OnDelete::SetNull => "SET NULL",
582            OnDelete::Cascade => "CASCADE",
583            OnDelete::NoAction => "NO ACTION",
584        }
585    }
586}
587
588/// A resolved `belongs_to` edge: the referencing field, its target resource, and
589/// the JSON key it expands under (`owner_id` → `owner`).
590#[derive(Debug, Clone)]
591pub struct Reference {
592    pub field: String,
593    pub target: String,
594    pub relation: String,
595    pub on_delete: OnDelete,
596    pub required: bool,
597}
598
599/// The relation name a reference field expands under: `owner_id` → `owner`,
600/// otherwise the field name unchanged.
601pub fn relation_name(field: &str) -> &str {
602    field.strip_suffix("_id").unwrap_or(field)
603}
604
605/// `"purchase_order"` → `"Purchase order"`. Sentence case, not title case: a
606/// dashboard full of Capitalised Nouns reads like a form, not an application.
607pub fn titleize(name: &str) -> String {
608    let spaced = name.trim_end_matches("_id").replace('_', " ");
609    let mut chars = spaced.chars();
610    match chars.next() {
611        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
612        None => spaced,
613    }
614}
615
616/// A deliberately small English pluraliser — enough for the labels apiplant
617/// generates, and overridable per resource with `[admin] plural`.
618pub fn pluralize(label: &str) -> String {
619    let lower = label.to_lowercase();
620    if lower.ends_with('s')
621        || lower.ends_with("x")
622        || lower.ends_with("ch")
623        || lower.ends_with("sh")
624    {
625        format!("{label}es")
626    } else if lower.ends_with('y')
627        && !lower.ends_with("ay")
628        && !lower.ends_with("ey")
629        && !lower.ends_with("oy")
630        && !lower.ends_with("uy")
631    {
632        format!("{}ies", &label[..label.len() - 1])
633    } else {
634        format!("{label}s")
635    }
636}
637
638/// Whether a resource is isolated per organisation (the default) or shared
639/// across the whole deployment.
640#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
641#[serde(rename_all = "snake_case")]
642pub enum Scope {
643    /// Rows belong to an organisation; every request is scoped to the caller's
644    /// active organisation and `organization_id` is enforced automatically.
645    Organization,
646    /// Not tenant-scoped — shared by everyone, governed only by `[permissions]`.
647    Global,
648}
649
650/// Access policy for a single action on a resource.
651///
652/// On an organisation-scoped resource, org membership is always required and
653/// queries are already filtered to the caller's active organisation; these
654/// levels then decide *who among the members* may act. `Role` is an
655/// **organisation** role (from the caller's membership), not a global one.
656#[derive(Debug, Clone, PartialEq, Eq)]
657pub enum Access {
658    /// No auth required. Only meaningful on `global` resources; on an
659    /// org-scoped resource it is treated like `member`.
660    Public,
661    /// Any authenticated principal (⇒ any member, on an org-scoped resource).
662    Authenticated,
663    /// Any member of the (active) organisation.
664    Member,
665    /// A member holding the named role **within the organisation**.
666    Role(String),
667    /// The principal must own the row (owner_field == principal id).
668    Owner,
669    /// Never exposed.
670    Private,
671}
672
673impl Access {
674    /// Parse the string form used in TOML (`"public"`, `"member"`, `"role:admin"`, …).
675    pub fn parse(s: &str) -> Access {
676        match s {
677            "public" => Access::Public,
678            "authenticated" => Access::Authenticated,
679            "member" => Access::Member,
680            "owner" => Access::Owner,
681            "private" => Access::Private,
682            other => other
683                .strip_prefix("role:")
684                .map(|role| Access::Role(role.to_string()))
685                .unwrap_or(Access::Private),
686        }
687    }
688
689    /// The canonical string form.
690    pub fn as_string(&self) -> String {
691        match self {
692            Access::Public => "public".to_string(),
693            Access::Authenticated => "authenticated".to_string(),
694            Access::Member => "member".to_string(),
695            Access::Role(role) => format!("role:{role}"),
696            Access::Owner => "owner".to_string(),
697            Access::Private => "private".to_string(),
698        }
699    }
700}
701
702/// Per-action permissions. Strings in TOML are parsed via [`Access::parse`].
703#[derive(Debug, Clone, Deserialize)]
704#[serde(from = "PermissionsRaw")]
705pub struct Permissions {
706    pub list: Access,
707    pub read: Access,
708    pub create: Access,
709    pub update: Access,
710    pub delete: Access,
711}
712
713impl Default for Permissions {
714    fn default() -> Self {
715        // Multitenant-by-default: every action is limited to members of the
716        // caller's organisation. On a `global` resource, `member` behaves like
717        // `authenticated` (there is no org to belong to).
718        Permissions {
719            list: Access::Member,
720            read: Access::Member,
721            create: Access::Member,
722            update: Access::Member,
723            delete: Access::Member,
724        }
725    }
726}
727
728#[derive(Deserialize)]
729struct PermissionsRaw {
730    list: Option<String>,
731    read: Option<String>,
732    create: Option<String>,
733    update: Option<String>,
734    delete: Option<String>,
735}
736
737impl From<PermissionsRaw> for Permissions {
738    fn from(r: PermissionsRaw) -> Self {
739        let d = Permissions::default();
740        Permissions {
741            list: r.list.map(|s| Access::parse(&s)).unwrap_or(d.list),
742            read: r.read.map(|s| Access::parse(&s)).unwrap_or(d.read),
743            create: r.create.map(|s| Access::parse(&s)).unwrap_or(d.create),
744            update: r.update.map(|s| Access::parse(&s)).unwrap_or(d.update),
745            delete: r.delete.map(|s| Access::parse(&s)).unwrap_or(d.delete),
746        }
747    }
748}
749
750/// One point in a resource's request lifecycle at which a function may run.
751///
752/// `before_*` hooks run after the permission check but before the database is
753/// touched, so they can validate, rewrite the submitted payload, or abort the
754/// request. `after_*` hooks run once the operation succeeded and can rewrite the
755/// response body.
756#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
757pub enum HookEvent {
758    BeforeList,
759    AfterList,
760    BeforeRead,
761    AfterRead,
762    BeforeCreate,
763    AfterCreate,
764    BeforeUpdate,
765    AfterUpdate,
766    BeforeDelete,
767    AfterDelete,
768}
769
770impl HookEvent {
771    /// Every event, in lifecycle order.
772    pub const ALL: [HookEvent; 10] = [
773        HookEvent::BeforeList,
774        HookEvent::AfterList,
775        HookEvent::BeforeRead,
776        HookEvent::AfterRead,
777        HookEvent::BeforeCreate,
778        HookEvent::AfterCreate,
779        HookEvent::BeforeUpdate,
780        HookEvent::AfterUpdate,
781        HookEvent::BeforeDelete,
782        HookEvent::AfterDelete,
783    ];
784
785    /// The TOML key / wire name, e.g. `"before_create"`.
786    pub fn as_str(self) -> &'static str {
787        match self {
788            HookEvent::BeforeList => "before_list",
789            HookEvent::AfterList => "after_list",
790            HookEvent::BeforeRead => "before_read",
791            HookEvent::AfterRead => "after_read",
792            HookEvent::BeforeCreate => "before_create",
793            HookEvent::AfterCreate => "after_create",
794            HookEvent::BeforeUpdate => "before_update",
795            HookEvent::AfterUpdate => "after_update",
796            HookEvent::BeforeDelete => "before_delete",
797            HookEvent::AfterDelete => "after_delete",
798        }
799    }
800
801    /// The operation this event belongs to (`"create"`, `"list"`, …).
802    pub fn action(self) -> &'static str {
803        match self {
804            HookEvent::BeforeList | HookEvent::AfterList => "list",
805            HookEvent::BeforeRead | HookEvent::AfterRead => "read",
806            HookEvent::BeforeCreate | HookEvent::AfterCreate => "create",
807            HookEvent::BeforeUpdate | HookEvent::AfterUpdate => "update",
808            HookEvent::BeforeDelete | HookEvent::AfterDelete => "delete",
809        }
810    }
811
812    /// `"before"` or `"after"`.
813    pub fn phase(self) -> &'static str {
814        if self.is_before() {
815            "before"
816        } else {
817            "after"
818        }
819    }
820
821    /// Whether this event fires ahead of the database operation.
822    pub fn is_before(self) -> bool {
823        matches!(
824            self,
825            HookEvent::BeforeList
826                | HookEvent::BeforeRead
827                | HookEvent::BeforeCreate
828                | HookEvent::BeforeUpdate
829                | HookEvent::BeforeDelete
830        )
831    }
832}
833
834/// The `[hooks]` section of a resource: a function name per lifecycle event.
835///
836/// Unknown keys are rejected so a typo (`befor_create`) fails at load time
837/// instead of silently never firing.
838///
839/// The [`AuthEvent`] keys live here too, and are only meaningful on the `user`
840/// resource — declaring one anywhere else fails
841/// [validation](Resource::validate), since nothing would ever fire it.
842#[derive(Debug, Clone, Default, Deserialize)]
843#[serde(default, deny_unknown_fields)]
844pub struct Hooks {
845    pub before_list: Option<String>,
846    pub after_list: Option<String>,
847    pub before_read: Option<String>,
848    pub after_read: Option<String>,
849    pub before_create: Option<String>,
850    pub after_create: Option<String>,
851    pub before_update: Option<String>,
852    pub after_update: Option<String>,
853    pub before_delete: Option<String>,
854    pub after_delete: Option<String>,
855    pub before_register: Option<String>,
856    pub after_register: Option<String>,
857    pub before_login: Option<String>,
858    pub after_login: Option<String>,
859    pub before_api_key: Option<String>,
860    pub after_api_key: Option<String>,
861}
862
863impl Hooks {
864    /// The function bound to an event, if any.
865    pub fn get(&self, event: HookEvent) -> Option<&str> {
866        let slot = match event {
867            HookEvent::BeforeList => &self.before_list,
868            HookEvent::AfterList => &self.after_list,
869            HookEvent::BeforeRead => &self.before_read,
870            HookEvent::AfterRead => &self.after_read,
871            HookEvent::BeforeCreate => &self.before_create,
872            HookEvent::AfterCreate => &self.after_create,
873            HookEvent::BeforeUpdate => &self.before_update,
874            HookEvent::AfterUpdate => &self.after_update,
875            HookEvent::BeforeDelete => &self.before_delete,
876            HookEvent::AfterDelete => &self.after_delete,
877        };
878        slot.as_deref()
879    }
880
881    /// Every declared `(event, function)` pair, in lifecycle order.
882    pub fn iter(&self) -> impl Iterator<Item = (HookEvent, &str)> {
883        HookEvent::ALL
884            .into_iter()
885            .filter_map(|event| self.get(event).map(|name| (event, name)))
886    }
887
888    /// Whether any hook at all is declared, auth events included.
889    pub fn is_empty(&self) -> bool {
890        self.iter().next().is_none() && self.auth_iter().next().is_none()
891    }
892}
893
894/// Auth configuration carried in a `[auth]` section on the `user` resource.
895#[derive(Debug, Clone, Deserialize)]
896#[serde(default)]
897pub struct AuthSpec {
898    /// Field used as the login identifier.
899    pub identity_field: String,
900    /// Field holding the password hash.
901    pub password_field: String,
902    /// Enabled OAuth providers, e.g. `["google", "facebook"]`.
903    pub oauth_providers: Vec<String>,
904}
905
906impl Default for AuthSpec {
907    fn default() -> Self {
908        AuthSpec {
909            identity_field: "email".to_string(),
910            password_field: "password_hash".to_string(),
911            oauth_providers: Vec::new(),
912        }
913    }
914}
915
916/// One point in an auth endpoint's lifecycle at which a function may run.
917///
918/// These are declared in the `user` model's ordinary `[hooks]` section, next to
919/// its CRUD hooks, and are only meaningful there — the built-in endpoints are
920/// the `user` resource's other door. They sit alongside the [`HookEvent`]s
921/// rather than replacing them: registration is still a `create` on `user`, so
922/// `before_create` / `after_create` fire there too.
923#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
924pub enum AuthEvent {
925    BeforeRegister,
926    AfterRegister,
927    BeforeLogin,
928    AfterLogin,
929    BeforeApiKey,
930    AfterApiKey,
931}
932
933impl AuthEvent {
934    /// Every event, in lifecycle order.
935    pub const ALL: [AuthEvent; 6] = [
936        AuthEvent::BeforeRegister,
937        AuthEvent::AfterRegister,
938        AuthEvent::BeforeLogin,
939        AuthEvent::AfterLogin,
940        AuthEvent::BeforeApiKey,
941        AuthEvent::AfterApiKey,
942    ];
943
944    /// The TOML key / wire name, e.g. `"before_login"`.
945    pub fn as_str(self) -> &'static str {
946        match self {
947            AuthEvent::BeforeRegister => "before_register",
948            AuthEvent::AfterRegister => "after_register",
949            AuthEvent::BeforeLogin => "before_login",
950            AuthEvent::AfterLogin => "after_login",
951            AuthEvent::BeforeApiKey => "before_api_key",
952            AuthEvent::AfterApiKey => "after_api_key",
953        }
954    }
955
956    /// The endpoint this event belongs to (`"register"`, `"login"`, `"api_key"`).
957    pub fn action(self) -> &'static str {
958        match self {
959            AuthEvent::BeforeRegister | AuthEvent::AfterRegister => "register",
960            AuthEvent::BeforeLogin | AuthEvent::AfterLogin => "login",
961            AuthEvent::BeforeApiKey | AuthEvent::AfterApiKey => "api_key",
962        }
963    }
964
965    /// `"before"` or `"after"`.
966    pub fn phase(self) -> &'static str {
967        if self.is_before() {
968            "before"
969        } else {
970            "after"
971        }
972    }
973
974    /// Whether this event fires ahead of the work the endpoint does.
975    pub fn is_before(self) -> bool {
976        matches!(
977            self,
978            AuthEvent::BeforeRegister | AuthEvent::BeforeLogin | AuthEvent::BeforeApiKey
979        )
980    }
981}
982
983impl Hooks {
984    /// The function bound to an auth event, if any.
985    pub fn get_auth(&self, event: AuthEvent) -> Option<&str> {
986        let slot = match event {
987            AuthEvent::BeforeRegister => &self.before_register,
988            AuthEvent::AfterRegister => &self.after_register,
989            AuthEvent::BeforeLogin => &self.before_login,
990            AuthEvent::AfterLogin => &self.after_login,
991            AuthEvent::BeforeApiKey => &self.before_api_key,
992            AuthEvent::AfterApiKey => &self.after_api_key,
993        };
994        slot.as_deref()
995    }
996
997    /// Every declared auth `(event, function)` pair, in lifecycle order.
998    pub fn auth_iter(&self) -> impl Iterator<Item = (AuthEvent, &str)> {
999        AuthEvent::ALL
1000            .into_iter()
1001            .filter_map(|event| self.get_auth(event).map(|name| (event, name)))
1002    }
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007    use super::*;
1008
1009    fn parse_resource(src: &str) -> Resource {
1010        let resource: Resource = toml::from_str(src).unwrap();
1011        resource.validate().unwrap();
1012        resource
1013    }
1014
1015    #[test]
1016    fn access_parser_and_permissions_defaults_match_org_membership_model() {
1017        assert_eq!(Access::parse("public"), Access::Public);
1018        assert_eq!(Access::parse("authenticated"), Access::Authenticated);
1019        assert_eq!(Access::parse("member"), Access::Member);
1020        assert_eq!(Access::parse("owner"), Access::Owner);
1021        assert_eq!(Access::parse("role:admin"), Access::Role("admin".into()));
1022        assert_eq!(Access::parse("wat"), Access::Private);
1023
1024        let defaults = Permissions::default();
1025        assert_eq!(defaults.list, Access::Member);
1026        assert_eq!(defaults.read, Access::Member);
1027        assert_eq!(defaults.create, Access::Member);
1028        assert_eq!(defaults.update, Access::Member);
1029        assert_eq!(defaults.delete, Access::Member);
1030    }
1031
1032    #[test]
1033    fn validate_rejects_reserved_id_and_dangling_reference_definition() {
1034        let reserved_id: Resource = toml::from_str(
1035            r#"
1036[resource]
1037name = "bad"
1038
1039[fields.id]
1040type = "string"
1041"#,
1042        )
1043        .unwrap();
1044        assert!(reserved_id.validate().is_err());
1045
1046        let missing_target: Resource = toml::from_str(
1047            r#"
1048[resource]
1049name = "bad_ref"
1050
1051[fields.owner_id]
1052type = "reference"
1053"#,
1054        )
1055        .unwrap();
1056        assert!(missing_target.validate().is_err());
1057    }
1058
1059    #[test]
1060    fn content_format_is_only_allowed_on_text_fields() {
1061        let good: Resource = toml::from_str(
1062            r#"
1063[resource]
1064name = "article"
1065
1066[fields.body]
1067type = "text"
1068
1069[fields.body.admin]
1070format = "markdown"
1071"#,
1072        )
1073        .unwrap();
1074        good.validate().unwrap();
1075        assert_eq!(good.fields["body"].admin.format, ContentFormat::Markdown);
1076
1077        let bad: Resource = toml::from_str(
1078            r#"
1079[resource]
1080name = "article"
1081
1082[fields.published]
1083type = "boolean"
1084
1085[fields.published.admin]
1086format = "html"
1087"#,
1088        )
1089        .unwrap();
1090        assert!(bad.validate().is_err());
1091    }
1092
1093    #[test]
1094    fn references_derive_relation_names_and_default_on_delete() {
1095        let resource = parse_resource(
1096            r#"
1097[resource]
1098name = "comment"
1099
1100[fields.post_id]
1101type = "reference"
1102references = "post"
1103required = true
1104
1105[fields.author_id]
1106type = "reference"
1107references = "user"
1108on_delete = "cascade"
1109"#,
1110        );
1111
1112        assert_eq!(relation_name("owner_id"), "owner");
1113        assert_eq!(relation_name("slug"), "slug");
1114
1115        let refs = resource.references();
1116        assert_eq!(refs.len(), 2);
1117        let post = refs.iter().find(|rf| rf.field == "post_id").unwrap();
1118        assert_eq!(post.target, "post");
1119        assert_eq!(post.relation, "post");
1120        assert_eq!(post.on_delete, OnDelete::Restrict);
1121        assert!(post.required);
1122
1123        let author = resource.reference_by_relation("author").unwrap();
1124        assert_eq!(author.field, "author_id");
1125        assert_eq!(author.on_delete, OnDelete::Cascade);
1126    }
1127
1128    #[test]
1129    fn hooks_parse_per_event_and_iterate_in_lifecycle_order() {
1130        let resource = parse_resource(
1131            r#"
1132[resource]
1133name = "post"
1134
1135[fields.title]
1136type = "string"
1137
1138[hooks]
1139before_create = "validate_post"
1140after_create = "notify"
1141after_list = "redact"
1142"#,
1143        );
1144
1145        assert_eq!(
1146            resource.hook(HookEvent::BeforeCreate),
1147            Some("validate_post")
1148        );
1149        assert_eq!(resource.hook(HookEvent::AfterCreate), Some("notify"));
1150        assert_eq!(resource.hook(HookEvent::AfterList), Some("redact"));
1151        assert_eq!(resource.hook(HookEvent::BeforeUpdate), None);
1152        assert!(!resource.hooks.is_empty());
1153
1154        let declared: Vec<_> = resource.hooks.iter().collect();
1155        assert_eq!(
1156            declared,
1157            vec![
1158                (HookEvent::AfterList, "redact"),
1159                (HookEvent::BeforeCreate, "validate_post"),
1160                (HookEvent::AfterCreate, "notify"),
1161            ]
1162        );
1163    }
1164
1165    #[test]
1166    fn hook_events_expose_wire_names_actions_and_phases() {
1167        assert_eq!(HookEvent::BeforeCreate.as_str(), "before_create");
1168        assert_eq!(HookEvent::BeforeCreate.action(), "create");
1169        assert_eq!(HookEvent::BeforeCreate.phase(), "before");
1170        assert!(HookEvent::BeforeCreate.is_before());
1171
1172        assert_eq!(HookEvent::AfterList.as_str(), "after_list");
1173        assert_eq!(HookEvent::AfterList.action(), "list");
1174        assert_eq!(HookEvent::AfterList.phase(), "after");
1175        assert!(!HookEvent::AfterList.is_before());
1176
1177        // Every event round-trips through the `[hooks]` section under its own key.
1178        for event in HookEvent::ALL {
1179            let resource = parse_resource(&format!(
1180                "[resource]\nname = \"post\"\n\n[hooks]\n{} = \"h\"\n",
1181                event.as_str()
1182            ));
1183            assert_eq!(resource.hook(event), Some("h"), "{}", event.as_str());
1184            assert_eq!(resource.hooks.iter().count(), 1);
1185        }
1186    }
1187
1188    #[test]
1189    fn hooks_reject_typos_and_empty_function_names() {
1190        let typo = toml::from_str::<Resource>(
1191            r#"
1192[resource]
1193name = "post"
1194
1195[hooks]
1196befor_create = "oops"
1197"#,
1198        );
1199        assert!(typo.is_err(), "unknown hook keys must not be ignored");
1200
1201        let empty: Resource = toml::from_str(
1202            r#"
1203[resource]
1204name = "post"
1205
1206[hooks]
1207after_delete = "  "
1208"#,
1209        )
1210        .unwrap();
1211        assert!(empty.validate().is_err());
1212    }
1213
1214    #[test]
1215    fn auth_events_expose_wire_names_actions_and_phases() {
1216        assert_eq!(AuthEvent::BeforeLogin.as_str(), "before_login");
1217        assert_eq!(AuthEvent::BeforeLogin.action(), "login");
1218        assert_eq!(AuthEvent::BeforeLogin.phase(), "before");
1219        assert!(AuthEvent::BeforeLogin.is_before());
1220
1221        assert_eq!(AuthEvent::AfterApiKey.action(), "api_key");
1222        assert_eq!(AuthEvent::AfterApiKey.phase(), "after");
1223        assert!(!AuthEvent::AfterApiKey.is_before());
1224
1225        // Every event round-trips through `[hooks]` under its own key, next to
1226        // the CRUD ones.
1227        for event in AuthEvent::ALL {
1228            let resource = parse_resource(&format!(
1229                "[resource]\nname = \"user\"\n\n[hooks]\nafter_create = \"c\"\n{} = \"h\"\n",
1230                event.as_str()
1231            ));
1232            assert_eq!(resource.auth_hook(event), Some("h"), "{}", event.as_str());
1233            assert_eq!(resource.hook(HookEvent::AfterCreate), Some("c"));
1234            assert_eq!(resource.hooks.auth_iter().count(), 1);
1235            // The CRUD iterator stays CRUD-only, so nothing that walks a
1236            // resource's lifecycle events picks up an auth one by accident.
1237            assert_eq!(resource.hooks.iter().count(), 1);
1238        }
1239    }
1240
1241    #[test]
1242    fn auth_hooks_are_absent_by_default_and_reject_typos_and_empty_names() {
1243        let plain =
1244            parse_resource("[resource]\nname = \"user\"\n\n[hooks]\nafter_create = \"c\"\n");
1245        assert_eq!(plain.auth_hook(AuthEvent::BeforeLogin), None);
1246        assert!(parse_resource("[resource]\nname = \"user\"\n")
1247            .hooks
1248            .is_empty());
1249
1250        let typo = toml::from_str::<Resource>(
1251            "[resource]\nname = \"user\"\n\n[hooks]\nbefore_signin = \"oops\"\n",
1252        );
1253        assert!(typo.is_err(), "unknown auth hook keys must not be ignored");
1254
1255        let empty: Resource =
1256            toml::from_str("[resource]\nname = \"user\"\n\n[hooks]\nafter_login = \"  \"\n")
1257                .unwrap();
1258        assert!(empty.validate().is_err());
1259    }
1260
1261    #[test]
1262    fn auth_hooks_are_rejected_on_any_resource_but_user() {
1263        // The key parses anywhere — `[hooks]` is one section — so validation is
1264        // what catches a hook nothing would ever fire.
1265        let stray: Resource =
1266            toml::from_str("[resource]\nname = \"post\"\n\n[hooks]\nbefore_login = \"h\"\n")
1267                .unwrap();
1268        let message = stray.validate().unwrap_err().to_string();
1269        assert!(message.contains("before_login"), "{message}");
1270        assert!(message.contains("user"), "{message}");
1271    }
1272
1273    #[test]
1274    fn resources_have_no_hooks_by_default() {
1275        let resource = parse_resource("[resource]\nname = \"post\"\n");
1276        assert!(resource.hooks.is_empty());
1277        assert!(HookEvent::ALL
1278            .into_iter()
1279            .all(|event| resource.hook(event).is_none()));
1280    }
1281
1282    #[test]
1283    fn admin_visibility_defaults_hide_auth_resources_but_nothing_else() {
1284        let post = parse_resource("[resource]\nname = \"post\"\n");
1285        assert!(post.admin.is_visible("post"));
1286
1287        let user = parse_resource(crate::defaults::USER_TOML);
1288        assert!(!user.admin.is_visible("user"));
1289        assert!(!parse_resource(crate::defaults::MEMBERSHIP_TOML)
1290            .admin
1291            .is_visible("membership"));
1292
1293        // An app that replaces a built-in still gets the dedicated screen…
1294        let replaced = parse_resource(
1295            "[resource]\nname = \"user\"\nscope = \"global\"\n\n[fields.email]\ntype = \"string\"\n",
1296        );
1297        assert!(!replaced.admin.is_visible("user"));
1298
1299        // …unless it asks for the table back.
1300        let opted_in = parse_resource(
1301            "[resource]\nname = \"user\"\nscope = \"global\"\n\n[admin]\nvisible = true\n",
1302        );
1303        assert!(opted_in.admin.is_visible("user"));
1304    }
1305
1306    #[test]
1307    fn admin_labels_are_inferred_and_overridable() {
1308        let inferred = parse_resource("[resource]\nname = \"purchase_order\"\n");
1309        assert_eq!(inferred.admin_label(), "Purchase order");
1310        assert_eq!(inferred.admin_plural(), "Purchase orders");
1311
1312        let overridden = parse_resource(
1313            "[resource]\nname = \"person\"\n\n[admin]\nlabel = \"Person\"\nplural = \"People\"\n",
1314        );
1315        assert_eq!(overridden.admin_plural(), "People");
1316
1317        assert_eq!(titleize("owner_id"), "Owner");
1318        assert_eq!(titleize("total_cents"), "Total cents");
1319        assert_eq!(pluralize("Category"), "Categories");
1320        assert_eq!(pluralize("Address"), "Addresses");
1321        assert_eq!(pluralize("Day"), "Days");
1322        assert_eq!(pluralize("Product"), "Products");
1323    }
1324
1325    #[test]
1326    fn display_field_prefers_conventional_names_then_any_string() {
1327        let conventional = parse_resource(
1328            r#"
1329[resource]
1330name = "product"
1331
1332[fields.sku]
1333type = "string"
1334
1335[fields.name]
1336type = "string"
1337"#,
1338        );
1339        assert_eq!(conventional.admin_display_field().as_deref(), Some("name"));
1340
1341        let only_odd_names =
1342            parse_resource("[resource]\nname = \"blob\"\n\n[fields.zzz]\ntype = \"string\"\n");
1343        assert_eq!(only_odd_names.admin_display_field().as_deref(), Some("zzz"));
1344
1345        let nothing_stringy =
1346            parse_resource("[resource]\nname = \"tick\"\n\n[fields.count]\ntype = \"integer\"\n");
1347        assert_eq!(nothing_stringy.admin_display_field(), None);
1348
1349        // An explicit choice always wins, conventional or not.
1350        let declared = parse_resource(
1351            r#"
1352[resource]
1353name = "product"
1354
1355[admin]
1356display_field = "sku"
1357
1358[fields.sku]
1359type = "string"
1360
1361[fields.name]
1362type = "string"
1363"#,
1364        );
1365        assert_eq!(declared.admin_display_field().as_deref(), Some("sku"));
1366        // `search_field` falls back to whatever names the record.
1367        assert_eq!(declared.admin_search_field().as_deref(), Some("sku"));
1368    }
1369
1370    #[test]
1371    fn inferred_columns_skip_blobs_and_dashboard_hidden_fields() {
1372        let resource = parse_resource(
1373            r#"
1374[resource]
1375name = "product"
1376
1377[fields.name]
1378type = "string"
1379
1380[fields.status]
1381type = "string"
1382
1383[fields.description]
1384type = "text"
1385
1386[fields.attributes]
1387type = "json"
1388
1389[fields.secret_ratio]
1390type = "float"
1391
1392[fields.secret_ratio.admin]
1393visible = false
1394"#,
1395        );
1396        assert_eq!(resource.admin_columns(), vec!["name", "status"]);
1397
1398        let declared = parse_resource(
1399            r#"
1400[resource]
1401name = "product"
1402
1403[admin]
1404columns = ["status", "name"]
1405
1406[fields.name]
1407type = "string"
1408
1409[fields.status]
1410type = "string"
1411"#,
1412        );
1413        assert_eq!(declared.admin_columns(), vec!["status", "name"]);
1414    }
1415
1416    #[test]
1417    fn admin_section_rejects_columns_naming_fields_that_do_not_exist() {
1418        let bad_column: Resource = toml::from_str(
1419            "[resource]\nname = \"post\"\n\n[admin]\ncolumns = [\"nope\"]\n\n[fields.title]\ntype = \"string\"\n",
1420        )
1421        .unwrap();
1422        assert!(bad_column.validate().is_err());
1423
1424        let bad_display: Resource = toml::from_str(
1425            "[resource]\nname = \"post\"\n\n[admin]\ndisplay_field = \"nope\"\n\n[fields.title]\ntype = \"string\"\n",
1426        )
1427        .unwrap();
1428        assert!(bad_display.validate().is_err());
1429
1430        // The search box matches substrings, so a search field that is not text
1431        // would be a box that answers 400 to every keystroke.
1432        let unsearchable: Resource = toml::from_str(
1433            "[resource]\nname = \"tick\"\n\n[admin]\nsearch_field = \"count\"\n\n[fields.count]\ntype = \"integer\"\n",
1434        )
1435        .unwrap();
1436        assert!(unsearchable.validate().is_err());
1437
1438        // And one inherited from a non-text `display_field` simply leaves the
1439        // resource without a search box, rather than failing the app.
1440        let named_by_a_number = parse_resource(
1441            "[resource]\nname = \"tick\"\n\n[admin]\ndisplay_field = \"count\"\n\n[fields.count]\ntype = \"integer\"\n",
1442        );
1443        assert_eq!(
1444            named_by_a_number.admin_display_field().as_deref(),
1445            Some("count")
1446        );
1447        assert_eq!(named_by_a_number.admin_search_field(), None);
1448
1449        // A typo in a key is caught too, rather than silently ignored.
1450        assert!(toml::from_str::<Resource>(
1451            "[resource]\nname = \"post\"\n\n[admin]\nvisibel = true\n"
1452        )
1453        .is_err());
1454    }
1455
1456    #[test]
1457    fn field_admin_carries_widget_options_and_visibility() {
1458        let resource = parse_resource(
1459            r#"
1460[resource]
1461name = "product"
1462
1463[fields.status]
1464type = "string"
1465
1466[fields.status.admin]
1467label = "Lifecycle"
1468widget = "select"
1469options = ["draft", "active|Live"]
1470readonly = true
1471"#,
1472        );
1473        let status = &resource.fields["status"];
1474        assert_eq!(status.admin.label.as_deref(), Some("Lifecycle"));
1475        assert_eq!(status.admin.widget, Widget::Select);
1476        assert_eq!(status.admin.widget.as_str(), "select");
1477        assert_eq!(status.admin.options, vec!["draft", "active|Live"]);
1478        assert!(status.admin.readonly);
1479        // Defaults stay out of the way when `[fields.x.admin]` says nothing.
1480        assert!(status.admin.visible);
1481        assert_eq!(resource.fields["status"].admin.help, None);
1482    }
1483
1484    #[test]
1485    fn org_column_reflects_resource_scope() {
1486        let org_scoped = parse_resource(
1487            r#"
1488[resource]
1489name = "post"
1490
1491[fields.title]
1492type = "string"
1493"#,
1494        );
1495        let global = parse_resource(
1496            r#"
1497[resource]
1498name = "plan"
1499scope = "global"
1500
1501[fields.name]
1502type = "string"
1503"#,
1504        );
1505        let organization = parse_resource(crate::defaults::ORGANIZATION_TOML);
1506
1507        assert_eq!(org_scoped.org_column(), Some("organization_id"));
1508        assert_eq!(global.org_column(), None);
1509        assert_eq!(organization.org_column(), Some("id"));
1510    }
1511}