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