Files
rustycms/src/schema/validator.rs

661 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use serde_json::Value;
use super::types::{FieldDefinition, SchemaDefinition};
// ---------------------------------------------------------------------------
// ValidationError
// ---------------------------------------------------------------------------
#[derive(Debug)]
pub struct ValidationError {
pub field: String,
pub message: String,
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Field '{}': {}", self.field, self.message)
}
}
// ---------------------------------------------------------------------------
// Core validation (pure no store dependency)
// ---------------------------------------------------------------------------
/// Validate content against a schema definition.
/// Returns a list of validation errors (empty = valid).
pub fn validate_content(
schema: &SchemaDefinition,
content: &Value,
) -> Vec<ValidationError> {
let mut errors = Vec::new();
let obj = match content.as_object() {
Some(obj) => obj,
None => {
errors.push(ValidationError {
field: "_root".to_string(),
message: "Content must be a JSON object".to_string(),
});
return errors;
}
};
// ── Strict mode: reject unknown fields ───────────────────────────────
if schema.strict {
for key in obj.keys() {
if key.starts_with('_') {
continue;
}
if !schema.fields.contains_key(key) {
errors.push(ValidationError {
field: key.clone(),
message: "Unknown field (strict mode is enabled)".to_string(),
});
}
}
}
// ── Required fields ──────────────────────────────────────────────────
for (name, field_def) in &schema.fields {
if field_def.required && !field_def.auto {
if !obj.contains_key(name) || obj[name].is_null() {
// null is OK if the field is nullable
if obj.get(name).map_or(false, |v| v.is_null()) && field_def.nullable {
continue;
}
errors.push(ValidationError {
field: name.clone(),
message: "Field is required".to_string(),
});
}
}
}
// ── Per-field type & constraint validation ───────────────────────────
for (name, value) in obj {
if name.starts_with('_') {
continue;
}
if let Some(field_def) = schema.fields.get(name) {
validate_field(name, field_def, value, &mut errors);
}
}
errors
}
// ---------------------------------------------------------------------------
// Field-level validation
// ---------------------------------------------------------------------------
fn validate_field(
field_name: &str,
fd: &FieldDefinition,
value: &Value,
errors: &mut Vec<ValidationError>,
) {
// ── Null handling ────────────────────────────────────────────────────
// Optional fields (not required) may be null; required fields may not.
if value.is_null() {
if fd.required {
errors.push(ValidationError {
field: field_name.to_string(),
message: "Field is required".to_string(),
});
}
return;
}
// ── Type check ──────────────────────────────────────────────────────
let type_ok = match fd.field_type.as_str() {
"string" | "richtext" | "html" | "markdown" | "datetime" | "textOrRef" => value.is_string(),
"number" => value.is_number(),
"integer" => value.is_i64() || value.is_u64() || value.as_f64().map(|f| f.fract() == 0.0 && f.is_finite()).unwrap_or(false),
"boolean" => value.is_boolean(),
"array" | "multiSelect" => value.is_array(),
"object" => value.is_object(),
"reference" => value.is_string(),
"referenceOrInline" => value.is_string() || value.is_object(),
_ => true,
};
if !type_ok {
errors.push(ValidationError {
field: field_name.to_string(),
message: format!(
"Expected type '{}', got '{}'",
fd.field_type,
type_name(value)
),
});
return; // no point checking constraints if type is wrong
}
// ── Enum constraint (single value) ───────────────────────────────────
// Optional string+enum: empty string = no selection, allowed
if fd.field_type != "multiSelect" {
if let Some(ref allowed) = fd.enum_values {
if !allowed.contains(value) {
let empty_ok = !fd.required && value.as_str().map_or(false, |s| s.is_empty());
if !empty_ok {
errors.push(ValidationError {
field: field_name.to_string(),
message: format!("Value must be one of: {:?}", allowed),
});
}
}
}
}
// ── multiSelect: array of strings, each must be in enum ──────────────
if fd.field_type == "multiSelect" {
if let Some(arr) = value.as_array() {
let allowed: Vec<&Value> = fd.enum_values.as_ref().map(|e| e.iter().collect()).unwrap_or_default();
for (i, item) in arr.iter().enumerate() {
if !item.is_string() {
errors.push(ValidationError {
field: format!("{}[{}]", field_name, i),
message: "Each item must be a string".to_string(),
});
} else if !allowed.is_empty() && !allowed.iter().any(|v| v.as_str() == item.as_str()) {
errors.push(ValidationError {
field: format!("{}[{}]", field_name, i),
message: format!("Item must be one of: {:?}", fd.enum_values),
});
}
}
}
}
// ── String constraints ──────────────────────────────────────────────
if let Some(s) = value.as_str() {
if let Some(min) = fd.min_length {
if s.len() < min {
errors.push(ValidationError {
field: field_name.to_string(),
message: format!("String too short (min {} characters)", min),
});
}
}
if let Some(max) = fd.max_length {
if s.len() > max {
errors.push(ValidationError {
field: field_name.to_string(),
message: format!("String too long (max {} characters)", max),
});
}
}
if let Some(ref pattern) = fd.pattern {
// Pattern was validated at schema load time, so unwrap is safe
if let Ok(re) = regex::Regex::new(pattern) {
if !re.is_match(s) {
errors.push(ValidationError {
field: field_name.to_string(),
message: format!("Value does not match pattern '{}'", pattern),
});
}
}
}
}
// ── Number constraints ──────────────────────────────────────────────
if let Some(n) = value.as_f64() {
if let Some(min) = fd.min {
if n < min {
errors.push(ValidationError {
field: field_name.to_string(),
message: format!("Value {} is below minimum {}", n, min),
});
}
}
if let Some(max) = fd.max {
if n > max {
errors.push(ValidationError {
field: field_name.to_string(),
message: format!("Value {} exceeds maximum {}", n, max),
});
}
}
}
// ── Array constraints & item validation ─────────────────────────────
if let Some(arr) = value.as_array() {
if let Some(min) = fd.min_items {
if arr.len() < min {
errors.push(ValidationError {
field: field_name.to_string(),
message: format!("Array too short (min {} items)", min),
});
}
}
if let Some(max) = fd.max_items {
if arr.len() > max {
errors.push(ValidationError {
field: field_name.to_string(),
message: format!("Array too long (max {} items)", max),
});
}
}
if let Some(ref items_def) = fd.items {
for (i, item) in arr.iter().enumerate() {
validate_field(
&format!("{}[{}]", field_name, i),
items_def,
item,
errors,
);
}
}
}
// ── Nested object validation ────────────────────────────────────────
if fd.field_type == "object" || fd.field_type == "referenceOrInline" {
if value.is_object() {
if let (Some(ref nested_fields), Some(obj)) = (&fd.fields, value.as_object()) {
for (nested_name, nested_def) in nested_fields {
let full_name = format!("{}.{}", field_name, nested_name);
if let Some(nested_value) = obj.get(nested_name) {
validate_field(&full_name, nested_def, nested_value, errors);
} else if nested_def.required {
errors.push(ValidationError {
field: full_name,
message: "Field is required".to_string(),
});
}
}
} else if let (Some(ref ap), Some(obj)) = (&fd.additional_properties, value.as_object()) {
for (key, val) in obj {
let full_name = format!("{}.{}", field_name, key);
validate_field(&full_name, ap, val, errors);
}
}
}
}
}
fn type_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
// ---------------------------------------------------------------------------
// Defaults & auto-generation
// ---------------------------------------------------------------------------
/// Apply default values and auto-generated fields to content.
pub fn apply_defaults(schema: &SchemaDefinition, content: &mut Value) {
if let Some(obj) = content.as_object_mut() {
for (name, fd) in &schema.fields {
if obj.contains_key(name) {
continue;
}
if fd.field_type == "multiSelect" {
obj.insert(name.clone(), Value::Array(vec![]));
continue;
}
if let Some(ref default_value) = fd.default {
obj.insert(name.clone(), default_value.clone());
continue;
}
if fd.auto && fd.field_type == "datetime" {
obj.insert(
name.clone(),
Value::String(chrono::Utc::now().to_rfc3339()),
);
}
}
}
}
// ---------------------------------------------------------------------------
// Normalize multiSelect: string (e.g. "") → [] before validation
// ---------------------------------------------------------------------------
/// Coerce multiSelect fields that have a string value (e.g. from old data or form) to [].
pub fn normalize_multi_select(schema: &SchemaDefinition, content: &mut Value) {
let obj = match content.as_object_mut() {
Some(o) => o,
None => return,
};
for (field_name, fd) in &schema.fields {
if fd.field_type == "multiSelect" {
if let Some(v) = obj.get_mut(field_name) {
if v.is_string() {
*v = Value::Array(vec![]);
}
}
}
}
}
// ---------------------------------------------------------------------------
// Normalize reference arrays (used before validation on create/update)
// ---------------------------------------------------------------------------
/// Convert reference-array items from objects `{ _slug, ... }` to slug strings.
/// So clients can send either slugs or resolved refs; we always store slugs.
pub fn normalize_reference_arrays(schema: &SchemaDefinition, content: &mut Value) {
let obj = match content.as_object_mut() {
Some(o) => o,
None => return,
};
for (field_name, fd) in &schema.fields {
// Single reference / referenceOrInline: object with _slug → slug string
if fd.field_type == "reference" || fd.field_type == "referenceOrInline" {
let slug_opt = obj
.get(field_name)
.and_then(|v| v.as_object())
.and_then(|o| o.get("_slug"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let Some(s) = slug_opt {
obj.insert(field_name.clone(), Value::String(s));
}
continue;
}
if fd.field_type != "array" {
continue;
}
let items = match &fd.items {
Some(it) => it,
None => continue,
};
let is_ref_array =
items.field_type == "reference" || items.field_type == "referenceOrInline";
if !is_ref_array {
continue;
}
let arr = match obj.get_mut(field_name).and_then(|v| v.as_array_mut()) {
Some(a) => a,
None => continue,
};
for item in arr.iter_mut() {
let slug_opt = item
.as_object()
.and_then(|o| o.get("_slug"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let Some(s) = slug_opt {
*item = Value::String(s);
}
}
}
}
// ---------------------------------------------------------------------------
// Readonly check (used by update handler)
// ---------------------------------------------------------------------------
/// Check whether any readonly field has been changed.
/// `old` is the current stored content, `new` is the incoming update.
pub fn check_readonly_violations(
schema: &SchemaDefinition,
old: &Value,
new: &Value,
) -> Vec<ValidationError> {
let mut errors = Vec::new();
for (name, fd) in &schema.fields {
if !fd.readonly {
continue;
}
let old_val = old.get(name);
let new_val = new.get(name);
// If the incoming data includes the readonly field with a different value, reject
if let Some(nv) = new_val {
if let Some(ov) = old_val {
if nv != ov {
errors.push(ValidationError {
field: name.clone(),
message: "Field is readonly and cannot be changed after creation"
.to_string(),
});
}
}
}
}
errors
}
// ---------------------------------------------------------------------------
// Unique validation (needs list of existing entries from the handler)
// ---------------------------------------------------------------------------
/// Check that all unique fields have values that don't collide with existing entries.
/// `current_slug` should be `Some(slug)` on update (to exclude self) or `None` on create.
pub fn validate_unique(
schema: &SchemaDefinition,
content: &Value,
current_slug: Option<&str>,
existing_entries: &[(String, Value)],
) -> Vec<ValidationError> {
let mut errors = Vec::new();
let unique_fields: Vec<&String> = schema
.fields
.iter()
.filter(|(_, fd)| fd.unique)
.map(|(name, _)| name)
.collect();
if unique_fields.is_empty() {
return errors;
}
for field_name in &unique_fields {
let Some(value) = content.get(field_name.as_str()) else {
continue;
};
if value.is_null() {
continue;
}
for (slug, entry) in existing_entries {
// Skip self on update
if Some(slug.as_str()) == current_slug {
continue;
}
if let Some(existing_val) = entry.get(field_name.as_str()) {
if existing_val == value {
errors.push(ValidationError {
field: field_name.to_string(),
message: format!(
"Value must be unique '{}' is already used by entry '{}'",
value, slug
),
});
break;
}
}
}
}
errors
}
// ---------------------------------------------------------------------------
// Reference validation (handler provides a lookup function)
// ---------------------------------------------------------------------------
/// Check that all reference fields point to existing entries.
/// `entry_exists(collection, slug) -> bool` is provided by the handler.
/// Supports single collection and polymorphic `collections` (slug valid if found in any).
pub fn validate_references(
schema: &SchemaDefinition,
content: &Value,
entry_exists: &dyn Fn(&str, &str) -> bool,
) -> Vec<ValidationError> {
let mut errors = Vec::new();
if let Some(obj) = content.as_object() {
for (field_name, fd) in &schema.fields {
let is_ref = fd.field_type == "reference"
|| (fd.field_type == "referenceOrInline" && obj.get(field_name).map_or(false, |v| v.is_string()));
if is_ref {
let colls = fd.reference_collections();
if colls.is_empty() {
continue;
}
if let Some(Value::String(slug)) = obj.get(field_name) {
if slug.trim().is_empty() {
continue; // empty = no selection, valid for optional reference
}
let found = colls.iter().any(|c| entry_exists(c, slug));
if !found {
errors.push(ValidationError {
field: field_name.clone(),
message: format!(
"Referenced entry '{}' not found in any of {:?}",
slug, colls
),
});
} else if let Some(ref allowed) = fd.allowed_slugs {
if !allowed.is_empty() && !allowed.iter().any(|s| s.as_str() == slug) {
errors.push(ValidationError {
field: field_name.clone(),
message: format!(
"Slug '{}' is not in the allowed list for this reference",
slug
),
});
}
}
}
continue;
}
if fd.field_type == "array" {
if let Some(ref items) = fd.items {
let is_ref_item = items.field_type == "reference"
|| items.field_type == "referenceOrInline";
if !is_ref_item {
continue;
}
let colls = items.reference_collections();
if colls.is_empty() {
continue;
}
if let Some(Value::Array(arr)) = obj.get(field_name) {
for (i, item) in arr.iter().enumerate() {
if let Some(slug) = item.as_str() {
if slug.trim().is_empty() {
continue; // empty = no selection in list
}
let found = colls.iter().any(|c| entry_exists(c, slug));
if !found {
errors.push(ValidationError {
field: format!("{}[{}]", field_name, i),
message: format!(
"Referenced entry '{}' not found in any of {:?}",
slug, colls
),
});
} else if let Some(ref allowed) = items.allowed_slugs {
if !allowed.is_empty() && !allowed.iter().any(|s| s.as_str() == slug) {
errors.push(ValidationError {
field: format!("{}[{}]", field_name, i),
message: format!(
"Slug '{}' is not in the allowed list for this reference",
slug
),
});
}
}
}
}
}
}
}
}
}
errors
}
// ---------------------------------------------------------------------------
// Extract references (for reverse index / referrers)
// ---------------------------------------------------------------------------
/// One referenced entry: (collection, slug). Used to update the referrer index.
fn parse_ref_value(value: &str, collections: &[&str]) -> Option<(String, String)> {
let value = value.trim();
if value.is_empty() {
return None;
}
if let Some((coll, slug)) = value.split_once(':') {
let slug = slug.trim();
if !slug.is_empty() && collections.contains(&coll) {
return Some((coll.to_string(), slug.to_string()));
}
}
if let Some(&coll) = collections.first() {
return Some((coll.to_string(), value.to_string()));
}
None
}
/// One reference: (referenced collection, referenced slug, field name).
pub type ExtractedRef = (String, String, String);
/// Extract all (referenced collection, referenced slug, field name) from content for referrer index updates.
/// Only considers reference and referenceOrInline (string) fields; referenceOrInline as object is inline, not a ref.
pub fn extract_references(schema: &SchemaDefinition, content: &Value) -> Vec<ExtractedRef> {
let mut out = Vec::new();
let obj = match content.as_object() {
Some(o) => o,
None => return out,
};
for (field_name, fd) in &schema.fields {
let is_ref = fd.field_type == "reference"
|| (fd.field_type == "referenceOrInline" && obj.get(field_name).map_or(false, |v| v.is_string()));
if is_ref {
let colls = fd.reference_collections();
if colls.is_empty() {
continue;
}
if let Some(Value::String(s)) = obj.get(field_name) {
if let Some((c, slug)) = parse_ref_value(s, &colls) {
out.push((c, slug, field_name.clone()));
}
}
continue;
}
if fd.field_type == "array" {
if let Some(ref items) = fd.items {
let is_ref_item = items.field_type == "reference"
|| items.field_type == "referenceOrInline";
if !is_ref_item {
continue;
}
let colls = items.reference_collections();
if colls.is_empty() {
continue;
}
if let Some(Value::Array(arr)) = obj.get(field_name) {
for item in arr.iter() {
if let Some(s) = item.as_str() {
if let Some((c, slug)) = parse_ref_value(s, &colls) {
out.push((c, slug, field_name.clone()));
}
}
}
}
}
}
}
out
}