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