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