RustyCMS: File-based headless CMS with REST API, admin UI, multilingual support

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Peter Meier
2026-02-16 09:30:30 +01:00
commit aad93d145f
224 changed files with 19225 additions and 0 deletions

440
src/schema/validator.rs Normal file
View File

@@ -0,0 +1,440 @@
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 ────────────────────────────────────────────────────
if value.is_null() {
if !fd.nullable {
errors.push(ValidationError {
field: field_name.to_string(),
message: "Field does not allow null (set nullable: true to permit)".to_string(),
});
}
return;
}
// ── Type check ──────────────────────────────────────────────────────
let type_ok = match fd.field_type.as_str() {
"string" | "richtext" | "html" | "markdown" | "datetime" => 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" => value.is_array(),
"object" => value.is_object(),
"reference" => value.is_string(),
_ => 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 ─────────────────────────────────────────────────
if let Some(ref allowed) = fd.enum_values {
if !allowed.contains(value) {
errors.push(ValidationError {
field: field_name.to_string(),
message: format!("Value must be one of: {:?}", allowed),
});
}
}
// ── 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" {
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(),
});
}
}
}
}
}
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 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()),
);
}
}
}
}
// ---------------------------------------------------------------------------
// 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 {
if fd.field_type == "reference" {
let colls = fd.reference_collections();
if colls.is_empty() {
continue;
}
if let Some(Value::String(slug)) = obj.get(field_name) {
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
),
});
}
}
continue;
}
if fd.field_type == "array" {
if let Some(ref items) = fd.items {
if items.field_type != "reference" {
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() {
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
),
});
}
}
}
}
}
}
}
}
errors
}