661 lines
25 KiB
Rust
661 lines
25 KiB
Rust
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
|
||
}
|