Files
regorus/src/schema/validate.rs
Anand Krishnamoorthi ed6ae465b0 refactor(value): migrate Value::Object to Object storage abstraction (#736)
Builds on #57. Swap Value::Object's payload from Rc<BTreeMap<Value, Value>>
to Rc<Object> and migrate all call sites to the Object API.

as_object / as_object_mut keep their names but return &Object / &mut Object.
The mutable accessor handles Rc::make_mut internally, so callers no longer
do it themselves. Object grows into_value() and From<Object> for Value.
Value's serializer now delegates to Object::serialize, dropping a duplicate
non-string-key stringification path.

RVM IterationState::Object is rewritten around ObjectCursor: O(log n)
steps over a shared Rc<Object>, no eager pair snapshot. Snapshot
independence is preserved by Rc copy-on-write; setup_next_iteration
advances the cursor inline and advance() becomes a no-op for this variant.
A new iteration_state_object_is_snapshot_independent_of_source test
covers CoW against a mutated alias.

Value::Set still wraps Rc<BTreeSet<Value>>; the matching Set abstraction
and its swap ship in follow-up PRs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-05 18:04:37 -05:00

735 lines
28 KiB
Rust

// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
#![allow(missing_debug_implementations)] // validator is zero-sized marker
#![allow(dead_code)]
#![allow(clippy::pattern_type_mismatch, clippy::needless_continue)]
use crate::{
schema::{error::ValidationError, Schema, Type},
value::Object,
*,
};
use alloc::collections::BTreeMap;
use regex::Regex;
type String = Rc<str>;
/// Validator for checking if a Value conforms to a Schema.
pub struct SchemaValidator;
impl SchemaValidator {
/// Validates a Value against a Schema.
///
/// # Arguments
/// * `value` - The Value to validate
/// * `schema` - The Schema to validate against
///
/// # Returns
/// * `Ok(())` if the value conforms to the schema
/// * `Err(ValidationError)` if validation fails
///
/// # Example
/// ```rust
/// use regorus::schema::{Schema, validate::SchemaValidator};
/// use regorus::Value;
/// use serde_json::json;
///
/// let schema_json = json!({
/// "type": "string",
/// "minLength": 1,
/// "maxLength": 10
/// });
/// let schema = Schema::from_serde_json_value(schema_json).unwrap();
/// let value = Value::from("hello");
///
/// let result = SchemaValidator::validate(&value, &schema);
/// assert!(result.is_ok());
/// ```
pub fn validate(value: &Value, schema: &Schema) -> Result<(), ValidationError> {
Self::validate_with_path(value, schema, "")
}
/// Internal validation function that tracks the current path for error reporting.
fn validate_with_path(
value: &Value,
schema: &Schema,
path: &str,
) -> Result<(), ValidationError> {
match schema.as_type() {
Type::Any { .. } => {
// Any type accepts all values
Ok(())
}
Type::Integer {
minimum, maximum, ..
} => Self::validate_integer(value, *minimum, *maximum, path),
Type::Number {
minimum, maximum, ..
} => Self::validate_number(value, *minimum, *maximum, path),
Type::Boolean { .. } => Self::validate_boolean(value, path),
Type::Null { .. } => Self::validate_null(value, path),
Type::String {
min_length,
max_length,
pattern,
..
} => Self::validate_string(value, *min_length, *max_length, pattern.as_ref(), path),
Type::Array {
items,
min_items,
max_items,
..
} => Self::validate_array(value, items, *min_items, *max_items, path),
Type::Object {
properties,
required,
additional_properties,
discriminated_subobject,
..
} => Self::validate_object(
value,
properties,
required.as_ref().map(|r| &**r),
additional_properties.as_ref(),
discriminated_subobject.as_ref().map(|d| &**d),
path,
),
Type::AnyOf(schemas) => Self::validate_any_of(value, schemas, path),
Type::Const {
value: const_value, ..
} => Self::validate_const(value, const_value, path),
Type::Enum { values, .. } => Self::validate_enum(value, values, path),
Type::Set { items, .. } => Self::validate_set(value, items, path),
}
}
fn validate_integer(
value: &Value,
minimum: Option<i64>,
maximum: Option<i64>,
path: &str,
) -> Result<(), ValidationError> {
match value {
Value::Number(num) => {
if let Some(int_val) = num.as_i64() {
if let Some(min) = minimum {
if int_val < min {
return Err(ValidationError::OutOfRange {
value: int_val.to_string().into(),
min: Some(min.to_string().into()),
max: maximum.map(|m| m.to_string().into()),
path: path.to_string().into(),
});
}
}
if let Some(max) = maximum {
if int_val > max {
return Err(ValidationError::OutOfRange {
value: int_val.to_string().into(),
min: minimum.map(|m| m.to_string().into()),
max: Some(max.to_string().into()),
path: path.into(),
});
}
}
Ok(())
} else {
Err(ValidationError::TypeMismatch {
expected: "integer".into(),
actual: "non-integer number".into(),
path: path.into(),
})
}
}
_ => Err(ValidationError::TypeMismatch {
expected: "integer".into(),
actual: Self::value_type_name(value),
path: path.into(),
}),
}
}
fn validate_number(
value: &Value,
minimum: Option<f64>,
maximum: Option<f64>,
path: &str,
) -> Result<(), ValidationError> {
match value {
Value::Number(num) => {
if let Some(float_val) = num.as_f64() {
if let Some(min) = minimum {
if float_val < min {
return Err(ValidationError::OutOfRange {
value: float_val.to_string().into(),
min: Some(min.to_string().into()),
max: maximum.map(|m| m.to_string().into()),
path: path.into(),
});
}
}
if let Some(max) = maximum {
if float_val > max {
return Err(ValidationError::OutOfRange {
value: float_val.to_string().into(),
min: minimum.map(|m| m.to_string().into()),
max: Some(max.to_string().into()),
path: path.to_string().into(),
});
}
}
Ok(())
} else {
Err(ValidationError::TypeMismatch {
expected: "number".into(),
actual: "non-numeric value".into(),
path: path.into(),
})
}
}
_ => Err(ValidationError::TypeMismatch {
expected: "number".into(),
actual: Self::value_type_name(value),
path: path.into(),
}),
}
}
fn validate_boolean(value: &Value, path: &str) -> Result<(), ValidationError> {
match value {
Value::Bool(_) => Ok(()),
_ => Err(ValidationError::TypeMismatch {
expected: "boolean".into(),
actual: Self::value_type_name(value),
path: path.into(),
}),
}
}
fn validate_null(value: &Value, path: &str) -> Result<(), ValidationError> {
match value {
Value::Null => Ok(()),
_ => Err(ValidationError::TypeMismatch {
expected: "null".into(),
actual: Self::value_type_name(value),
path: path.into(),
}),
}
}
fn validate_string(
value: &Value,
min_length: Option<usize>,
max_length: Option<usize>,
pattern: Option<&String>,
path: &str,
) -> Result<(), ValidationError> {
match value {
Value::String(string_value) => {
let str_len = string_value.len();
// Check length constraints
if let Some(min) = min_length {
if str_len < min {
return Err(ValidationError::LengthConstraint {
actual_length: str_len,
min_length: Some(min),
max_length,
path: path.into(),
});
}
}
if let Some(max) = max_length {
if str_len > max {
return Err(ValidationError::LengthConstraint {
actual_length: str_len,
min_length,
max_length: Some(max),
path: path.into(),
});
}
}
// Check pattern constraint
if let Some(pattern_str) = pattern {
let regex =
Regex::new(pattern_str).map_err(|e| ValidationError::InvalidPattern {
pattern: pattern_str.as_ref().into(),
error: e.to_string().into(),
})?;
if !regex.is_match(string_value) {
return Err(ValidationError::PatternMismatch {
value: string_value.to_string().into(),
pattern: pattern_str.clone(),
path: path.into(),
});
}
}
Ok(())
}
_ => Err(ValidationError::TypeMismatch {
expected: "string".into(),
actual: Self::value_type_name(value),
path: path.into(),
}),
}
}
fn validate_array(
value: &Value,
items_schema: &Schema,
min_items: Option<usize>,
max_items: Option<usize>,
path: &str,
) -> Result<(), ValidationError> {
match value {
Value::Array(array_value) => {
let arr_len = array_value.len();
// Check size constraints
if let Some(min) = min_items {
if arr_len < min {
return Err(ValidationError::ArraySizeConstraint {
actual_size: arr_len,
min_items: Some(min),
max_items,
path: path.into(),
});
}
}
if let Some(max) = max_items {
if arr_len > max {
return Err(ValidationError::ArraySizeConstraint {
actual_size: arr_len,
min_items,
max_items: Some(max),
path: path.into(),
});
}
}
// Validate each item
for (index, item) in array_value.iter().enumerate() {
Self::validate_with_path(
item,
items_schema,
&if path.is_empty() {
format!("[{index}]")
} else {
format!("{path}[{index}]")
},
)
.map_err(|e| {
ValidationError::ArrayItemValidationFailed {
index,
path: path.into(),
error: Box::new(e),
}
})?;
}
Ok(())
}
_ => Err(ValidationError::TypeMismatch {
expected: "array".into(),
actual: Self::value_type_name(value),
path: path.into(),
}),
}
}
fn validate_object(
value: &Value,
properties: &BTreeMap<String, Schema>,
required: Option<&Vec<String>>,
additional_properties: Option<&Schema>,
discriminated_subobject: Option<&crate::schema::DiscriminatedSubobject>,
path: &str,
) -> Result<(), ValidationError> {
match value {
Value::Object(object_value) => {
// Check required properties
if let Some(required_props) = required {
for required_prop in required_props.iter() {
if !object_value.contains_key(&Value::String(required_prop.clone())) {
return Err(ValidationError::MissingRequiredProperty {
property: required_prop.clone(),
path: path.into(),
});
}
}
}
// Handle discriminated subobjects (allOf with if/then)
// Validates against the appropriate variant schema based on discriminator field value
if let Some(discriminated_subobject) = discriminated_subobject {
Self::validate_discriminated_subobject_with_base(
object_value,
discriminated_subobject,
properties,
additional_properties,
path,
)?;
} else {
// Only validate regular object properties if no discriminated subobject exists
// Validate each property
for (prop_name, prop_value) in object_value.iter() {
// First, ensure the property key is a string
let prop_name_str = match prop_name {
Value::String(string_key) => string_key,
_ => {
return Err(ValidationError::NonStringKey {
key_type: Self::value_type_name(prop_name),
path: path.into(),
});
}
};
// Create property path lazily using a closure
let make_prop_path = || {
if path.is_empty() {
format!("[{prop_name_str}]")
} else {
format!("{path}.{prop_name_str}")
}
};
if let Some(prop_schema) = properties.get(prop_name_str) {
// Property is defined in schema, validate against it
Self::validate_with_path(prop_value, prop_schema, &make_prop_path())
.map_err(|e| ValidationError::PropertyValidationFailed {
property: prop_name_str.clone(),
path: path.into(),
error: Box::new(e),
})?;
} else if let Some(additional_schema) = additional_properties {
// Property is not defined but additional properties are allowed
Self::validate_with_path(
prop_value,
additional_schema,
&make_prop_path(),
)
.map_err(|e| {
ValidationError::PropertyValidationFailed {
property: prop_name_str.clone(),
path: path.into(),
error: Box::new(e),
}
})?;
} else {
// Property is not defined and additional properties are not allowed
return Err(ValidationError::AdditionalPropertiesNotAllowed {
property: prop_name_str.clone(),
path: path.into(),
});
}
}
}
Ok(())
}
_ => Err(ValidationError::TypeMismatch {
expected: "object".into(),
actual: Self::value_type_name(value),
path: path.into(),
}),
}
}
fn validate_any_of(
value: &Value,
schemas: &Vec<Schema>,
path: &str,
) -> Result<(), ValidationError> {
let mut errors = Vec::new();
for schema in schemas {
match Self::validate_with_path(value, schema, path) {
Ok(()) => return Ok(()), // If any schema matches, validation succeeds
Err(e) => errors.push(e),
}
}
// If no schema matched, return error with all validation attempts
Err(ValidationError::NoUnionMatch {
path: path.into(),
errors,
})
}
fn validate_const(
value: &Value,
const_value: &Value,
path: &str,
) -> Result<(), ValidationError> {
if value == const_value {
Ok(())
} else {
let expected_json =
serde_json::to_string(const_value).unwrap_or_else(|_| format!("{const_value:?}"));
let actual_json = serde_json::to_string(value).unwrap_or_else(|_| format!("{value:?}"));
Err(ValidationError::ConstMismatch {
expected: expected_json.into(),
actual: actual_json.into(),
path: path.into(),
})
}
}
fn validate_enum(
value: &Value,
allowed_values: &[Value],
path: &str,
) -> Result<(), ValidationError> {
if allowed_values.contains(value) {
Ok(())
} else {
// Convert Value to JSON string, fallback to debug format if JSON serialization fails
let value_json = serde_json::to_string(value).unwrap_or_else(|_| format!("{value:?}"));
let allowed_json: Vec<String> = allowed_values
.iter()
.map(|v| {
serde_json::to_string(v)
.unwrap_or_else(|_| format!("{v:?}"))
.into()
})
.collect();
Err(ValidationError::NotInEnum {
value: value_json.into(),
allowed_values: allowed_json,
path: path.into(),
})
}
}
fn validate_set(
value: &Value,
items_schema: &Schema,
path: &str,
) -> Result<(), ValidationError> {
match value {
Value::Set(set_value) => {
// Validate each item in the set
for (index, item) in set_value.iter().enumerate() {
Self::validate_with_path(
item,
items_schema,
&if path.is_empty() {
format!("{{{index}}}]")
} else {
format!("{path}{{{index}}}]")
},
)?;
}
Ok(())
}
_ => Err(ValidationError::TypeMismatch {
expected: "set".into(),
actual: Self::value_type_name(value),
path: path.into(),
}),
}
}
fn validate_discriminated_subobject_with_base(
object_value: &Object,
discriminated_subobject: &crate::schema::DiscriminatedSubobject,
base_properties: &BTreeMap<String, Schema>,
base_additional_properties: Option<&Schema>,
path: &str,
) -> Result<(), ValidationError> {
let discriminator_field = &discriminated_subobject.discriminator;
let discriminator_key = Value::String(discriminator_field.clone());
// Find the discriminator field value in the object
let discriminator_value = object_value.get(&discriminator_key).ok_or_else(|| {
ValidationError::MissingDiscriminator {
discriminator: discriminator_field.clone(),
path: path.into(),
}
})?;
// Extract the string value from the discriminator field
let discriminator_str = match discriminator_value {
Value::String(string_value) => string_value.as_ref(),
_ => {
return Err(ValidationError::TypeMismatch {
expected: "string".into(),
actual: Self::value_type_name(discriminator_value),
path: format!("{path}.{discriminator_field}").into(),
});
}
};
// Find the corresponding variant schema
let variant_schema = discriminated_subobject
.variants
.get(discriminator_str)
.ok_or_else(|| ValidationError::UnknownDiscriminatorValue {
discriminator: discriminator_field.clone(),
value: discriminator_str.into(),
allowed_values: discriminated_subobject.variants.keys().cloned().collect(),
path: path.into(),
})?;
// Validate all properties against the appropriate schemas
for (prop_name, prop_value) in object_value.iter() {
// First, ensure the property key is a string
let prop_name_str = match prop_name {
Value::String(string_key) => string_key,
_ => {
return Err(ValidationError::NonStringKey {
key_type: Self::value_type_name(prop_name),
path: path.into(),
});
}
};
// Create property path lazily using a closure
let make_prop_path = || {
if path.is_empty() {
format!("[{prop_name_str}]")
} else {
format!("{path}.{prop_name_str}")
}
};
// Check if this property is defined in the variant schema first
if variant_schema.properties.get(prop_name_str).is_some() {
// Validate later in subobject.
continue;
}
// Check if this property is defined in the base schema properties
if let Some(prop_schema) = base_properties.get(prop_name_str) {
// Property is defined in base schema, validate against it
Self::validate_with_path(prop_value, prop_schema, &make_prop_path()).map_err(
|e| ValidationError::PropertyValidationFailed {
property: prop_name_str.clone(),
path: path.into(),
error: Box::new(e),
},
)?;
continue;
}
// Check if additional properties are allowed in the variant
if variant_schema.additional_properties.is_some() {
// Property is not defined but additional properties are allowed in variant.
// Validate later.
continue;
} else if let Some(base_additional) = base_additional_properties {
// Check if additional properties are allowed in the base schema
Self::validate_with_path(prop_value, base_additional, &make_prop_path()).map_err(
|e| ValidationError::PropertyValidationFailed {
property: prop_name_str.clone(),
path: path.into(),
error: Box::new(e),
},
)?;
} else {
// Property is not defined and additional properties are not allowed
return Err(ValidationError::AdditionalPropertiesNotAllowed {
property: prop_name_str.clone(),
path: path.into(),
});
}
}
// Validate the object against the variant schema for required properties
Self::validate_subobject(object_value, variant_schema, path).map_err(|e| {
ValidationError::DiscriminatedSubobjectValidationFailed {
discriminator: discriminator_field.clone(),
value: discriminator_str.into(),
path: path.into(),
error: Box::new(e),
}
})
}
fn validate_subobject(
object_value: &Object,
subobject: &crate::schema::Subobject,
path: &str,
) -> Result<(), ValidationError> {
// Check required properties from the subobject
if let Some(required_props) = &subobject.required {
for required_prop in required_props.iter() {
if !object_value.contains_key(&Value::String(required_prop.clone())) {
return Err(ValidationError::MissingRequiredProperty {
property: required_prop.clone(),
path: path.into(),
});
}
}
}
// Validate each property in the subobject
for (prop_name, prop_schema) in subobject.properties.iter() {
let prop_key = Value::String(prop_name.clone());
if let Some(prop_value) = object_value.get(&prop_key) {
Self::validate_with_path(
prop_value,
prop_schema,
&if path.is_empty() {
format!("[{prop_name}]")
} else {
format!("{path}.{prop_name}")
},
)
.map_err(|e| ValidationError::PropertyValidationFailed {
property: prop_name.clone(),
path: path.into(),
error: Box::new(e),
})?;
}
}
// Handle additional properties if specified
if let Some(additional_schema) = &subobject.additional_properties {
for (prop_name, prop_value) in object_value.iter() {
if let Value::String(prop_name_str) = prop_name {
if !subobject.properties.contains_key(prop_name_str) {
Self::validate_with_path(
prop_value,
additional_schema,
&if path.is_empty() {
format!("[{prop_name_str}]")
} else {
format!("{path}.{prop_name_str}")
},
)
.map_err(|e| {
ValidationError::PropertyValidationFailed {
property: prop_name_str.clone(),
path: path.into(),
error: Box::new(e),
}
})?;
}
}
}
}
Ok(())
}
fn value_type_name(value: &Value) -> String {
match value {
Value::Null => "null".into(),
Value::Bool(_) => "boolean".into(),
Value::Number(_) => "number".into(),
Value::String(_) => "string".into(),
Value::Array(_) => "array".into(),
Value::Set(_) => "set".into(),
Value::Object(_) => "object".into(),
Value::Undefined => "undefined".into(),
}
}
}