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