Skip to main content

laterite_core/
validation.rs

1//! One validation engine for admin forms and the API.
2//!
3//! Typed [`Rule`]s run over a field's submitted value into an [`ErrorBag`] of
4//! locale-free [`Text`] messages: the admin form reads it field by field and
5//! localizes each inline, the API localizes it into a `422` body. Cheap rules
6//! (required, length, format) run without touching the database; the one DB-backed
7//! rule, [`Rule::Unique`], runs a single `EXISTS`-style probe and only for a field
8//! that already passed its cheap rules, so a blank or malformed value is never also
9//! probed.
10//!
11//! The engine is bespoke because Laterite's rules are runtime and
12//! descriptor-driven (a `FormConfig` or YAML over a `HashMap`), which the
13//! derive-macro crates (`validator`, `garde`) do not fit, and because `Unique`
14//! is inherently database-specific. The one genuinely-standard primitive, email
15//! shape, is delegated to the RFC-compliant `email_address` crate.
16
17use std::collections::{BTreeMap, HashMap};
18
19use sea_query::{Alias, Expr, Query};
20use serde::{Deserialize, Serialize};
21
22use crate::i18n::Text;
23use crate::query::{bind_values, build, text_cast};
24use crate::{CoreResult, Db};
25
26/// Whether a submission creates a new record or updates an existing one. A
27/// [`Rule::Unique`] check ignores the edited row on update, and
28/// [`Rule::RequiredOn`] fires for one mode only.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum Mode {
32    Create,
33    Update,
34}
35
36/// A single rule applied to one field's submitted value. Serde-serialisable so
37/// a descriptor (later YAML) can author rules by name (`required`, `email`,
38/// `max_length`, ...).
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum Rule {
42    /// Non-empty after trimming, in every mode.
43    Required,
44    /// Non-empty after trimming, in the given mode only.
45    RequiredOn(Mode),
46    /// At least `n` characters. Skipped when empty, so pair it with `Required`
47    /// to forbid blanks.
48    MinLength(usize),
49    /// At most `n` characters.
50    MaxLength(usize),
51    /// A syntactically plausible email address. Skipped when empty.
52    Email,
53    /// Parses as a number (integer or decimal). Skipped when empty.
54    Numeric,
55    /// A syntactically valid absolute URL with a host. Skipped when empty.
56    Url,
57    /// The value must not already exist in this field's column of the target
58    /// table. On update, the edited row is ignored.
59    Unique,
60}
61
62/// The rules for one field, with the label used in its messages.
63#[derive(Debug, Clone)]
64pub struct FieldRules {
65    pub field: String,
66    pub label: String,
67    pub rules: Vec<Rule>,
68}
69
70impl FieldRules {
71    pub fn new(field: impl Into<String>, label: impl Into<String>, rules: Vec<Rule>) -> Self {
72        Self {
73            field: field.into(),
74            label: label.into(),
75            rules,
76        }
77    }
78}
79
80/// Per-field validation messages, each a locale-free [`Text`]. Empty means valid.
81/// Whoever renders the bag localizes each message at its sink: the admin form
82/// through the request translator, the API into a localized `422` body.
83#[derive(Debug, Default, Clone)]
84pub struct ErrorBag {
85    fields: BTreeMap<String, Vec<Text>>,
86}
87
88impl ErrorBag {
89    /// Whether every field validated (no messages).
90    pub fn is_empty(&self) -> bool {
91        self.fields.is_empty()
92    }
93
94    /// Records a message against a field.
95    pub fn add(&mut self, field: &str, message: Text) {
96        self.fields
97            .entry(field.to_string())
98            .or_default()
99            .push(message);
100    }
101
102    /// The messages recorded for `field` (empty if it validated).
103    pub fn messages(&self, field: &str) -> &[Text] {
104        self.fields.get(field).map(Vec::as_slice).unwrap_or(&[])
105    }
106}
107
108/// A validation message carrying the field `label` as a nested [`Text`] argument,
109/// so the label localizes too once descriptor labels become `Text`.
110fn field_msg(source: &'static str, label: &str) -> Text {
111    Text::new(source).arg("label", Text::dynamic(label))
112}
113
114/// A count-pluralized validation message carrying the field `label` and count `n`.
115fn field_plural_msg(one: &'static str, other: &'static str, n: usize, label: &str) -> Text {
116    let n = n as i64;
117    Text::new(one)
118        .with_plural(other, n)
119        .arg("n", n)
120        .arg("label", Text::dynamic(label))
121}
122
123/// Runs the cheap, non-database rules over `data` for `mode`, returning the error
124/// bag. [`Rule::Unique`] needs the database and is ignored here; use [`validate`]
125/// to include it. Exposed on its own for callers that want a DB-free check (for
126/// example an API validating a payload before any query).
127pub fn validate_fields(
128    fields: &[FieldRules],
129    data: &HashMap<String, String>,
130    mode: Mode,
131) -> ErrorBag {
132    let mut bag = ErrorBag::default();
133    for f in fields {
134        let value = data.get(&f.field).map(String::as_str).unwrap_or("");
135        let trimmed = value.trim();
136        let len = trimmed.chars().count();
137        for rule in &f.rules {
138            match rule {
139                Rule::Required if trimmed.is_empty() => {
140                    bag.add(&f.field, field_msg("{label} is required.", &f.label));
141                }
142                Rule::RequiredOn(m) if *m == mode && trimmed.is_empty() => {
143                    bag.add(&f.field, field_msg("{label} is required.", &f.label));
144                }
145                Rule::MinLength(n) if !trimmed.is_empty() && len < *n => {
146                    bag.add(
147                        &f.field,
148                        field_plural_msg(
149                            "{label} must be at least {n} character.",
150                            "{label} must be at least {n} characters.",
151                            *n,
152                            &f.label,
153                        ),
154                    );
155                }
156                Rule::MaxLength(n) if len > *n => {
157                    bag.add(
158                        &f.field,
159                        field_plural_msg(
160                            "{label} must be at most {n} character.",
161                            "{label} must be at most {n} characters.",
162                            *n,
163                            &f.label,
164                        ),
165                    );
166                }
167                Rule::Email if !trimmed.is_empty() && !is_email(trimmed) => {
168                    bag.add(
169                        &f.field,
170                        field_msg("{label} must be a valid email address.", &f.label),
171                    );
172                }
173                Rule::Numeric if !trimmed.is_empty() && trimmed.parse::<f64>().is_err() => {
174                    bag.add(&f.field, field_msg("{label} must be a number.", &f.label));
175                }
176                Rule::Url if !trimmed.is_empty() && !is_url(trimmed) => {
177                    bag.add(
178                        &f.field,
179                        field_msg("{label} must be a valid URL.", &f.label),
180                    );
181                }
182                _ => {}
183            }
184        }
185    }
186    bag
187}
188
189/// Runs [`validate_fields`], then the DB-backed [`Rule::Unique`] probe for each
190/// field that carries it, has a value, and passed its cheap rules. `edited_id` is
191/// the row being updated (ignored by the probe); pass `None` on create.
192pub async fn validate(
193    db: &Db,
194    table: &str,
195    id_field: &str,
196    fields: &[FieldRules],
197    data: &HashMap<String, String>,
198    mode: Mode,
199    edited_id: Option<&str>,
200) -> CoreResult<ErrorBag> {
201    let mut bag = validate_fields(fields, data, mode);
202    for f in fields {
203        let wants_unique = f.rules.iter().any(|r| matches!(r, Rule::Unique));
204        if !wants_unique || !bag.messages(&f.field).is_empty() {
205            continue;
206        }
207        let value = data.get(&f.field).map(|s| s.trim()).unwrap_or("");
208        if value.is_empty() {
209            continue;
210        }
211        if unique_conflict(db, table, &f.field, value, id_field, mode, edited_id).await? {
212            bag.add(&f.field, field_msg("{label} is already taken.", &f.label));
213        }
214    }
215    Ok(bag)
216}
217
218/// Whether `value` already exists in `table.column`, ignoring the edited row on
219/// update. Identifiers go through sea-query (quoted); the value and id are bound.
220async fn unique_conflict(
221    db: &Db,
222    table: &str,
223    column: &str,
224    value: &str,
225    id_field: &str,
226    mode: Mode,
227    edited_id: Option<&str>,
228) -> CoreResult<bool> {
229    let (sql, values) = {
230        let mut stmt = Query::select();
231        stmt.expr(Expr::val(1))
232            .from(Alias::new(table))
233            .and_where(Expr::col(Alias::new(column)).eq(value))
234            .limit(1);
235        if mode == Mode::Update {
236            if let Some(id) = edited_id {
237                stmt.and_where(
238                    Expr::col(Alias::new(id_field))
239                        .cast_as(Alias::new(text_cast(db.backend)))
240                        .ne(id),
241                );
242            }
243        }
244        build(db.backend, stmt.to_owned())
245    };
246    let row = bind_values(sqlx::query(&sql), values)
247        .fetch_optional(&db.pool)
248        .await?;
249    Ok(row.is_some())
250}
251
252/// Whether `s` is a syntactically valid email address, delegated to the
253/// RFC-compliant `email_address` crate. Deliverability is confirmed by a
254/// confirmation email, not by this shape check.
255fn is_email(s: &str) -> bool {
256    email_address::EmailAddress::is_valid(s)
257}
258
259/// Whether `s` is a syntactically valid absolute URL with a host, delegated to
260/// the `url` crate. Scheme-agnostic: an input type may narrow to http(s).
261fn is_url(s: &str) -> bool {
262    url::Url::parse(s).is_ok_and(|u| u.has_host())
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::i18n::Translator;
269
270    fn data(pairs: &[(&str, &str)]) -> HashMap<String, String> {
271        pairs
272            .iter()
273            .map(|(k, v)| (k.to_string(), v.to_string()))
274            .collect()
275    }
276
277    #[test]
278    fn cheap_rules_collect_per_field_messages() {
279        let fields = vec![
280            FieldRules::new("name", "Name", vec![Rule::Required, Rule::MaxLength(5)]),
281            FieldRules::new("email", "Email", vec![Rule::Email]),
282        ];
283        let bag = validate_fields(
284            &fields,
285            &data(&[("name", "toolong"), ("email", "nope")]),
286            Mode::Create,
287        );
288        let tr = Translator::new("en");
289        assert!(!bag.is_empty());
290        assert_eq!(bag.messages("name").len(), 1); // MaxLength only (Required passed)
291        assert!(tr.t(&bag.messages("name")[0]).contains("at most 5"));
292        assert_eq!(bag.messages("email").len(), 1);
293        assert!(tr.t(&bag.messages("email")[0]).contains("valid email"));
294    }
295
296    #[test]
297    fn numeric_rejects_non_numbers_and_skips_blanks() {
298        let fields = [FieldRules::new("qty", "Qty", vec![Rule::Numeric])];
299        assert!(validate_fields(&fields, &data(&[("qty", "12.5")]), Mode::Create).is_empty());
300        assert!(validate_fields(&fields, &data(&[("qty", "")]), Mode::Create).is_empty());
301        let bag = validate_fields(&fields, &data(&[("qty", "abc")]), Mode::Create);
302        assert_eq!(bag.messages("qty").len(), 1);
303    }
304
305    #[test]
306    fn url_rejects_non_urls_and_skips_blanks() {
307        let fields = [FieldRules::new("site", "Site", vec![Rule::Url])];
308        assert!(validate_fields(
309            &fields,
310            &data(&[("site", "https://example.com/x")]),
311            Mode::Create
312        )
313        .is_empty());
314        // Blank is skipped (pair with Required to forbid a missing value).
315        assert!(validate_fields(&fields, &data(&[("site", "")]), Mode::Create).is_empty());
316        // Schemeless / relative values fail.
317        let bag = validate_fields(&fields, &data(&[("site", "example.com")]), Mode::Create);
318        assert_eq!(bag.messages("site").len(), 1);
319    }
320
321    #[test]
322    fn required_on_fires_only_in_its_mode() {
323        let fields = vec![FieldRules::new(
324            "password",
325            "Password",
326            vec![Rule::RequiredOn(Mode::Create)],
327        )];
328        let blank = data(&[("password", "")]);
329        assert!(!validate_fields(&fields, &blank, Mode::Create).is_empty());
330        assert!(validate_fields(&fields, &blank, Mode::Update).is_empty());
331    }
332
333    #[test]
334    fn valid_input_produces_an_empty_bag() {
335        let fields = vec![FieldRules::new(
336            "email",
337            "Email",
338            vec![Rule::Required, Rule::Email],
339        )];
340        assert!(validate_fields(&fields, &data(&[("email", "a@b.com")]), Mode::Create).is_empty());
341    }
342
343    #[test]
344    fn error_bag_records_messages_localized_at_the_sink() {
345        let mut bag = ErrorBag::default();
346        bag.add("name", field_msg("{label} is required.", "Name"));
347        assert_eq!(bag.messages("name").len(), 1);
348        // The bag holds a locale-free Text; the sink localizes it.
349        let tr = Translator::new("en");
350        assert_eq!(tr.t(&bag.messages("name")[0]), "Name is required.");
351    }
352
353    #[test]
354    fn min_length_message_pluralizes_on_the_count() {
355        let tr = Translator::new("en");
356        assert_eq!(
357            tr.t(&field_plural_msg(
358                "{label} must be at least {n} character.",
359                "{label} must be at least {n} characters.",
360                1,
361                "Pin",
362            )),
363            "Pin must be at least 1 character."
364        );
365        assert_eq!(
366            tr.t(&field_plural_msg(
367                "{label} must be at least {n} character.",
368                "{label} must be at least {n} characters.",
369                8,
370                "Pin",
371            )),
372            "Pin must be at least 8 characters."
373        );
374    }
375
376    #[test]
377    fn email_shape_check_is_wired_to_the_crate() {
378        // Unambiguous cases the email_address crate agrees on either way.
379        assert!(is_email("a@b.com"));
380        assert!(is_email("user.name+tag@sub.example.co"));
381        assert!(!is_email("no-at"));
382        assert!(!is_email("@b.com"));
383        assert!(!is_email("a@@b.com"));
384    }
385}