zeyn 0.0.4

Rust web framework for data heavy applications, written by AI agents and reviewed by people
Documentation
use crate::i18n::Text;
use crate::value::{FieldKind, FieldType, Value};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Deserializer, Serialize};
use std::fmt;
use std::ops::Deref;

/// Validated data with typed fields. Generated by `#[model]` or `#[entity]`, never written by hand.
pub trait Model: Clone + Default + Serialize + DeserializeOwned + Send + Sync + 'static {
    const NAME: &'static str;

    /// Holds personal or otherwise sensitive data: every time a user is shown such a record, a
    /// read is audited. Set with `#[entity(..., sensitive)]`.
    const SENSITIVE: bool = false;

    fn fields() -> &'static [FieldMeta];

    /// Value of the field at `index` in `fields()`.
    fn value_at(&self, index: usize) -> Option<Value>;

    fn validate(&self) -> Result<(), ValidationErrors>;
}

/// A model stored in a database table. Generated by `#[entity]`.
pub trait Entity: Model {
    const TABLE: &'static str;
}

/// Adds an error for every `#[rule(embed)]` field whose address is not on a host of the app's
/// `frame-src`. Runs where the request context is known: forms and API inputs.
pub(crate) fn check_embeds<M: Model>(model: &M, csp: &crate::csp::Csp, errors: &mut ValidationErrors) {
    for (i, meta) in M::fields().iter().enumerate() {
        if !meta.rules.contains(&Rule::Embed) || errors.errors.iter().any(|e| e.field == meta.name) {
            continue;
        }
        if let Some(Value::Text(url)) = model.value_at(i) {
            if !url.trim().is_empty() && !csp.allows_frame(&url) {
                let host = url.trim_start_matches("https://").split(['/', '?', '#']).next().unwrap_or("").to_string();
                errors.errors.push(FieldError {
                    field: meta.name,
                    rule: Rule::Embed,
                    message: Text::key("zeyn.validation.embed_host").arg("label", meta.label_text()).arg("host", host),
                });
            }
        }
    }
}

/// Field validation rule.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(tag = "rule", content = "value", rename_all = "snake_case")]
pub enum Rule {
    Required,
    Unique,
    Email,
    MinLen(usize),
    MaxLen(usize),
    Min(i64),
    Max(i64),
    /// Most decimals of a `Decimal` field (`#[rule(scale = 4)]`); 2 without the rule.
    Scale(u8),
    /// Most digits of a `Decimal` field, decimals included (`#[rule(precision = 12)]`); 18
    /// without the rule. With the scale it gives the column type, `NUMERIC(12, 4)`.
    Precision(u8),
    /// The raw value fills a pattern (`#[rule(mask = "(999) 999 99 99")]`): `9` a digit, `a` a
    /// letter, `A` a letter written upper case, `*` a letter or digit, a backslash makes the
    /// next character literal. Literals are shown while typing but never stored. See
    /// [`crate::ui::mask_fits`].
    Mask(&'static str),
    /// An address to embed with [`crate::ui::Embed`] (`#[rule(embed)]` on a `String`): an
    /// `https` URL in the form [`crate::embed_url`] gives, on a host of the app's `frame-src`
    /// (`App::csp`). Forms take pasted embed code too and keep only its canonical address.
    Embed,
    /// The submitted value could not be read as the field's type.
    Format,
    /// Raised by a model level `validate = ...` function.
    Custom,
    /// A problem in one cell of a lines field: the line (from 0) and the column.
    Line {
        row: u32,
        field: &'static str,
    },
}

/// Field definition produced at compile time.
#[derive(Debug, Serialize)]
pub struct FieldMeta {
    /// Name of the model the field belongs to. Label catalog key is `Model.field`.
    pub model: &'static str,
    pub name: &'static str,
    pub label: &'static str,
    pub kind: FieldKind,
    pub rules: &'static [Rule],
    pub key: bool,
    pub readonly: bool,
    /// Row version for optimistic concurrency. Sent back by the client so a stale save is detected.
    pub version: bool,
    pub optional: bool,
    /// Set by `#[mask(...)]`: the value is shown masked unless the user may reveal it.
    pub mask: Option<crate::privacy::Masking>,
}

impl FieldMeta {
    /// Field label as text: catalog key `Model.field`, falling back to the `#[label]` or field name.
    pub fn label_text(&self) -> Text {
        Text::key_or(format!("{}.{}", self.model, self.name), self.label)
    }

    pub fn is_required(&self) -> bool {
        self.rules.contains(&Rule::Required)
    }

    pub fn max_len(&self) -> Option<usize> {
        self.rules.iter().find_map(|r| match r {
            Rule::MaxLen(n) => Some(*n),
            _ => None,
        })
    }

    pub fn min_len(&self) -> Option<usize> {
        self.rules.iter().find_map(|r| match r {
            Rule::MinLen(n) => Some(*n),
            _ => None,
        })
    }

    pub fn min(&self) -> Option<i64> {
        self.rules.iter().find_map(|r| match r {
            Rule::Min(n) => Some(*n),
            _ => None,
        })
    }

    /// Digits and decimals of a `Decimal` field: its `precision` and `scale` rules, or 18 and 2.
    pub fn decimal_limits(&self) -> (u8, u8) {
        let scale = self.rules.iter().find_map(|r| if let Rule::Scale(n) = r { Some(*n) } else { None });
        let precision = self.rules.iter().find_map(|r| if let Rule::Precision(n) = r { Some(*n) } else { None });
        let scale = scale.unwrap_or(crate::decimal::DEFAULT_SCALE).min(crate::decimal::MAX_SCALE);
        let precision =
            precision.unwrap_or(crate::decimal::DEFAULT_PRECISION).clamp(scale.max(1), crate::decimal::MAX_PRECISION);
        (precision, scale)
    }

    /// The pattern of the `mask` rule, if any.
    pub fn mask_pattern(&self) -> Option<&'static str> {
        self.rules.iter().find_map(|r| if let Rule::Mask(p) = r { Some(*p) } else { None })
    }

    pub fn max(&self) -> Option<i64> {
        self.rules.iter().find_map(|r| match r {
            Rule::Max(n) => Some(*n),
            _ => None,
        })
    }
}

/// Typed field reference such as `Product::code`. A wrong field name does not compile.
pub struct Field<M, T> {
    meta: &'static FieldMeta,
    get: fn(&M) -> &T,
}

impl<M, T> Field<M, T> {
    #[doc(hidden)]
    pub const fn new(meta: &'static FieldMeta, get: fn(&M) -> &T) -> Self {
        Self { meta, get }
    }

    pub fn meta(&self) -> &'static FieldMeta {
        self.meta
    }

    pub fn get<'a>(&self, model: &'a M) -> &'a T {
        (self.get)(model)
    }
}

impl<M, T> Clone for Field<M, T> {
    fn clone(&self) -> Self {
        *self
    }
}

impl<M, T> Copy for Field<M, T> {}

impl<M, T> fmt::Debug for Field<M, T> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Field({})", self.meta.name)
    }
}

/// One validation error. `field` is empty for errors that belong to the whole model.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct FieldError {
    pub field: &'static str,
    pub rule: Rule,
    pub message: Text,
}

/// All validation errors of a model.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct ValidationErrors {
    pub errors: Vec<FieldError>,
}

impl ValidationErrors {
    /// Adds an error to a field. Used in model level `validate = ...` functions.
    ///
    /// ```ignore
    /// fn check_dates(r: &LeaveRequest, errors: &mut ValidationErrors) {
    ///     if r.ends < r.starts {
    ///         errors.add(LeaveRequest::ends, "End date must be after the start date.");
    ///     }
    /// }
    /// ```
    pub fn add<M, T>(&mut self, field: Field<M, T>, message: impl Into<Text>) {
        self.errors.push(FieldError { field: field.meta().name, rule: Rule::Custom, message: message.into() });
    }

    /// Adds an error that belongs to the whole model.
    pub fn add_general(&mut self, message: impl Into<Text>) {
        self.errors.push(FieldError { field: "", rule: Rule::Custom, message: message.into() });
    }

    pub fn into_result(self) -> Result<(), ValidationErrors> {
        if self.errors.is_empty() { Ok(()) } else { Err(self) }
    }

    pub fn for_field(&self, field: &str) -> impl Iterator<Item = &FieldError> {
        self.errors.iter().filter(move |e| e.field == field)
    }
}

impl fmt::Display for ValidationErrors {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let messages: Vec<String> = self.errors.iter().map(|e| e.message.english()).collect();
        f.write_str(&messages.join(" "))
    }
}

impl std::error::Error for ValidationErrors {}

/// A value that passed validation. Only `Valid::new` and deserialization create one.
///
/// Save actions take `Valid<T>`, so unvalidated data cannot be saved.
#[derive(Clone, Debug, Serialize)]
#[serde(transparent)]
pub struct Valid<T>(T);

impl<T: Model> Valid<T> {
    pub fn new(value: T) -> Result<Self, ValidationErrors> {
        value.validate()?;
        Ok(Self(value))
    }

    pub fn into_inner(self) -> T {
        self.0
    }
}

impl<T> Deref for Valid<T> {
    type Target = T;
    fn deref(&self) -> &T {
        &self.0
    }
}

/// Data from the client is validated while it is deserialized. This is the trust boundary.
impl<'de, T: Model> Deserialize<'de> for Valid<T> {
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        let value = T::deserialize(deserializer)?;
        Valid::new(value).map_err(serde::de::Error::custom)
    }
}

/// Basic email shape check: one `@`, a non-empty local part and a dotted domain, no spaces.
fn looks_like_email(s: &str) -> bool {
    let Some((local, domain)) = s.split_once('@') else { return false };
    !local.is_empty()
        && !domain.contains('@')
        && !s.chars().any(char::is_whitespace)
        && domain.split('.').count() >= 2
        && domain.split('.').all(|part| !part.is_empty())
}

#[doc(hidden)]
pub mod __rules {
    use super::*;
    use crate::value::{SupportsMinMax, SupportsRequired, SupportsScale, SupportsText};
    use std::cmp::Ordering;

    pub fn required<T: SupportsRequired>() {}
    pub fn text<T: SupportsText>() {}
    pub fn min_max<T: SupportsMinMax>() {}
    pub fn scale<T: SupportsScale>() {}
    pub fn version<T: IsVersion>() {}

    /// Types allowed for `#[version]`.
    #[diagnostic::on_unimplemented(
        message = "`#[version]` cannot be used on `{Self}`",
        label = "a row version must be an i64",
        note = "declare the field as `pub version: i64`"
    )]
    pub trait IsVersion {}
    impl IsVersion for i64 {}

    /// A rule limit as shown in messages: dates in ISO form, numbers as they are.
    fn limit_text(meta: &FieldMeta, n: i64) -> String {
        match (meta.kind, crate::time::Date::from_compact(n)) {
            (crate::value::FieldKind::Date, Some(d)) => d.to_string(),
            _ => n.to_string(),
        }
    }

    /// Runs the field's rules and collects errors. `unique` is enforced by the database.
    pub fn validate<T: FieldType>(value: &T, meta: &FieldMeta, errors: &mut ValidationErrors) {
        let empty = value.__is_empty();
        let message = |key: &'static str| Text::key(key).arg("label", meta.label_text());
        for rule in meta.rules {
            let text = match *rule {
                Rule::Required if empty => Some(message("zeyn.validation.required")),
                Rule::MinLen(n) if !empty && value.__text_len().is_some_and(|len| len < n) => {
                    Some(message("zeyn.validation.min_len").arg("n", n.to_string()))
                }
                Rule::MaxLen(n) if value.__text_len().is_some_and(|len| len > n) => {
                    Some(message("zeyn.validation.max_len").arg("n", n.to_string()))
                }
                Rule::Email if !empty && value.__text().is_some_and(|s| !looks_like_email(s.trim())) => {
                    Some(message("zeyn.validation.email"))
                }
                Rule::Min(n) if value.__compare(n) == Some(Ordering::Less) => {
                    Some(message("zeyn.validation.min").arg("n", limit_text(meta, n)))
                }
                Rule::Max(n) if value.__compare(n) == Some(Ordering::Greater) => {
                    Some(message("zeyn.validation.max").arg("n", limit_text(meta, n)))
                }
                Rule::Mask(p) if !empty && value.__text().is_some_and(|s| !crate::ui::mask_fits(p, s)) => {
                    Some(message("zeyn.validation.mask").arg("pattern", crate::ui::mask_example(p)))
                }
                Rule::Embed
                    if !empty && value.__text().is_some_and(|s| crate::csp::embed_url(s).as_deref() != Some(s)) =>
                {
                    Some(message("zeyn.validation.embed"))
                }
                _ => None,
            };
            if let Some(message) = text {
                errors.errors.push(FieldError { field: meta.name, rule: *rule, message });
            }
        }
        if let Some(d) = value.__decimal() {
            let (precision, scale) = meta.decimal_limits();
            let problem = if d.fraction_digits() > scale {
                Some((Rule::Scale(scale), message("zeyn.validation.scale").arg("n", scale.to_string())))
            } else if d.integer_digits() > precision - scale {
                let whole = (precision - scale).to_string();
                Some((Rule::Precision(precision), message("zeyn.validation.precision").arg("n", whole)))
            } else {
                None
            };
            if let Some((rule, message)) = problem {
                errors.errors.push(FieldError { field: meta.name, rule, message });
            }
        }
        value.__validate_items(meta, errors);
    }
}