Skip to main content

apiplant_core/
schema.rs

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