acube 0.1.0

Security-first server framework optimized for AI code generation
Documentation
//! Schema validation traits and helpers for the acube framework.

use std::sync::LazyLock;

use regex::Regex;
use serde::Serialize;

/// Trait for types that can be validated by acube.
///
/// Generated by `#[derive(AcubeSchema)]`.
pub trait AcubeValidate: Sized {
    /// Known field names for strict mode (unknown field rejection).
    fn known_fields() -> &'static [&'static str];

    /// Validate and sanitize the input, returning errors if invalid.
    fn validate(&mut self) -> Result<(), Vec<ValidationError>>;
}

/// Trait providing schema metadata (for OpenAPI generation).
pub trait AcubeSchemaInfo {
    /// Return schema metadata for this type.
    fn schema_info() -> SchemaInfo;

    /// Return an OpenAPI 3.0 JSON Schema object for this type.
    fn openapi_schema() -> serde_json::Value {
        serde_json::json!({ "type": "object" })
    }
}

/// A single field validation error.
#[derive(Debug, Clone, Serialize)]
pub struct ValidationError {
    /// Field path (e.g., "username" or "address.city").
    pub field: String,
    /// Error message.
    pub message: String,
    /// Error code (e.g., "min_length").
    pub code: String,
}

/// Schema metadata for a type.
#[derive(Debug, Clone)]
pub struct SchemaInfo {
    pub name: String,
    pub fields: Vec<FieldInfo>,
}

/// Constraints on a single field (for OpenAPI generation).
#[derive(Debug, Clone, Default)]
pub struct FieldConstraints {
    pub min_length: Option<usize>,
    pub max_length: Option<usize>,
    pub pattern: Option<String>,
    pub format: Option<String>,
    pub min: Option<f64>,
    pub max: Option<f64>,
}

/// Metadata for a single field.
#[derive(Debug, Clone)]
pub struct FieldInfo {
    pub name: String,
    pub type_name: String,
    pub required: bool,
    pub pii: bool,
    pub constraints: FieldConstraints,
}

// ─── Sanitization helpers ───────────────────────────────────────────────────

/// Trim leading and trailing whitespace.
pub fn sanitize_trim(s: &mut String) {
    let trimmed = s.trim().to_string();
    *s = trimmed;
}

/// Convert to lowercase.
pub fn sanitize_lowercase(s: &mut String) {
    *s = s.to_lowercase();
}

/// Strip HTML tags from a string.
pub fn sanitize_strip_html(s: &mut String) {
    static HTML_TAG_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]*>").unwrap());
    *s = HTML_TAG_RE.replace_all(s, "").to_string();
}

// ─── Format validation helpers ──────────────────────────────────────────────

/// Validate email format (RFC 5322 simplified).
pub fn validate_email(s: &str) -> bool {
    static EMAIL_RE: LazyLock<Regex> = LazyLock::new(|| {
        Regex::new(r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$").unwrap()
    });
    EMAIL_RE.is_match(s)
}

/// Validate URL format (http or https, practical check — not full RFC 3986).
pub fn validate_url(s: &str) -> bool {
    static URL_RE: LazyLock<Regex> = LazyLock::new(|| {
        Regex::new(r"^https?://[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*(:\d+)?(/[^\s]*)?$")
            .unwrap()
    });
    URL_RE.is_match(s)
}

/// Validate UUID format (v4 or any version, with hyphens).
pub fn validate_uuid(s: &str) -> bool {
    static UUID_RE: LazyLock<Regex> = LazyLock::new(|| {
        Regex::new(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
            .unwrap()
    });
    UUID_RE.is_match(s)
}

/// Validate a string against a regex pattern.
pub fn validate_pattern(s: &str, pattern: &str) -> bool {
    match Regex::new(pattern) {
        Ok(re) => re.is_match(s),
        Err(_) => false,
    }
}

// ─── Strict mode helper ─────────────────────────────────────────────────────

/// Check for unknown fields in a JSON object, returning errors for any fields
/// not in the known list.
pub fn check_unknown_fields(value: &serde_json::Value, known: &[&str]) -> Vec<ValidationError> {
    let mut errors = Vec::new();
    if let serde_json::Value::Object(map) = value {
        for key in map.keys() {
            if !known.contains(&key.as_str()) {
                errors.push(ValidationError {
                    field: key.clone(),
                    message: format!("Unknown field '{}'", key),
                    code: "unknown_field".to_string(),
                });
            }
        }
    }
    errors
}