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