noema-actix-webapi 0.1.0

Actix-web backend runtime on Noema (modules, sqlx, UoW, swagger, WebSocket dispatch)
use std::collections::BTreeMap;

/// Accumulated field errors. Maps to `400` / `validation.failed`.
#[derive(Debug, Clone, Default)]
pub struct ValidationError {
    fields: BTreeMap<String, String>,
}

impl ValidationError {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn add(&mut self, field: impl Into<String>, message: impl Into<String>) {
        self.fields.insert(field.into(), message.into());
    }

    pub fn is_empty(&self) -> bool {
        self.fields.is_empty()
    }

    pub fn fields(&self) -> &BTreeMap<String, String> {
        &self.fields
    }

    pub fn finish(self) -> Result<(), Self> {
        if self.is_empty() { Ok(()) } else { Err(self) }
    }
}

impl std::fmt::Display for ValidationError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "validation failed")
    }
}

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

pub fn require_non_empty(value: &str, field: &str) -> Result<(), String> {
    if value.trim().is_empty() {
        Err(format!("{field} must not be empty"))
    } else {
        Ok(())
    }
}

/// Local-part `@` domain with at least one dot; not a full RFC checker.
pub fn require_email(value: &str, field: &str) -> Result<(), String> {
    let Some((user, domain)) = value.split_once('@') else {
        return Err(format!("{field} must be a valid email"));
    };
    if user.is_empty()
        || domain.is_empty()
        || !domain.contains('.')
        || domain.starts_with('.')
        || domain.ends_with('.')
    {
        return Err(format!("{field} must be a valid email"));
    }
    Ok(())
}

pub fn require_in_range<T: PartialOrd + std::fmt::Display>(
    value: T,
    min: T,
    max: T,
    field: &str,
) -> Result<(), String> {
    if value < min || value > max {
        Err(format!("{field} must be between {min} and {max}"))
    } else {
        Ok(())
    }
}

pub fn push_result(err: &mut ValidationError, field: &str, result: Result<(), String>) {
    if let Err(message) = result {
        err.add(field, message);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_field_fails() {
        let msg = require_non_empty("", "name").unwrap_err();
        assert!(msg.contains("name"));
    }

    #[test]
    fn email_shape() {
        assert!(require_email("a@b.com", "email").is_ok());
        assert!(require_email("not-an-email", "email").is_err());
        assert!(require_email("@x.com", "email").is_err());
        assert!(require_email("a@com", "email").is_err());
    }

    #[test]
    fn range() {
        assert!(require_in_range(5u32, 1, 10, "n").is_ok());
        assert!(require_in_range(0u32, 1, 10, "n").is_err());
    }

    #[test]
    fn finish_ok_when_empty() {
        ValidationError::new().finish().unwrap();
    }
}