use serde_json::Value; use crate::{Schema, SchemaError, SchemaKind}; impl Schema { pub fn validate_shape(&self, value: &Value) -> Result<(), SchemaError> { validate_value(self, value, "$") } } fn validate_value(schema: &Schema, value: &Value, path: &str) -> Result<(), SchemaError> { if value.is_null() { if schema.nullable || schema.kind == SchemaKind::Null { return Ok(()); } return Err(SchemaError::invalid_type( path, schema.expected_type_name(), "null", )); } match schema.kind { SchemaKind::Object => validate_object(schema, value, path), SchemaKind::Array => validate_array(schema, value, path), SchemaKind::String => validate_scalar(schema, value, path, Value::is_string), SchemaKind::Integer => validate_scalar(schema, value, path, Value::is_i64_or_u64), SchemaKind::Number => validate_scalar(schema, value, path, Value::is_number), SchemaKind::Boolean => validate_scalar(schema, value, path, Value::is_boolean), SchemaKind::Enum => validate_enum(schema, value, path), SchemaKind::Null => Err(SchemaError::invalid_type( path, "null", actual_type_name(value), )), SchemaKind::Oneof => validate_oneof(schema, value, path), } } fn validate_object(schema: &Schema, value: &Value, path: &str) -> Result<(), SchemaError> { let Some(object) = value.as_object() else { return Err(SchemaError::invalid_type( path, "object", actual_type_name(value), )); }; for (field_name, field_schema) in &schema.fields { let field_path = format!("{path}.{field_name}"); match object.get(field_name) { Some(field_value) => validate_value(field_schema, field_value, &field_path)?, None if field_schema.required => return Err(SchemaError::missing_field(field_path)), None => {} } } Ok(()) } fn validate_array(schema: &Schema, value: &Value, path: &str) -> Result<(), SchemaError> { let Some(items) = value.as_array() else { return Err(SchemaError::invalid_type( path, "array", actual_type_name(value), )); }; if let Some(item_schema) = schema.items.as_deref() { for (index, item) in items.iter().enumerate() { let item_path = format!("{path}[{index}]"); validate_value(item_schema, item, &item_path)?; } } Ok(()) } fn validate_scalar( schema: &Schema, value: &Value, path: &str, is_valid: impl Fn(&Value) -> bool, ) -> Result<(), SchemaError> { if is_valid(value) { return Ok(()); } Err(SchemaError::invalid_type( path, schema.expected_type_name(), actual_type_name(value), )) } fn validate_enum(schema: &Schema, value: &Value, path: &str) -> Result<(), SchemaError> { let Some(actual) = value.as_str() else { return Err(SchemaError::invalid_type( path, "enum", actual_type_name(value), )); }; if schema.enum_values.iter().any(|expected| expected == actual) { return Ok(()); } Err(SchemaError::invalid_enum_value( path, schema.enum_values.clone(), actual, )) } fn validate_oneof(schema: &Schema, value: &Value, path: &str) -> Result<(), SchemaError> { if schema .variants .iter() .any(|variant| validate_value(variant, value, path).is_ok()) { return Ok(()); } Err(SchemaError::oneof_mismatch(path)) } fn actual_type_name(value: &Value) -> &'static str { match value { Value::Null => "null", Value::Bool(_) => "boolean", Value::Number(number) if number.is_i64() || number.is_u64() => "integer", Value::Number(_) => "number", Value::String(_) => "string", Value::Array(_) => "array", Value::Object(_) => "object", } } trait JsonValueExt { fn is_i64_or_u64(&self) -> bool; } impl JsonValueExt for Value { fn is_i64_or_u64(&self) -> bool { self.as_i64().is_some() || self.as_u64().is_some() } } #[cfg(test)] mod tests { use std::collections::BTreeMap; use serde_json::json; use crate::{Schema, SchemaError, SchemaKind}; #[test] fn validates_nested_object_shape() { let schema = Schema { kind: SchemaKind::Object, description: None, required: true, nullable: false, default_value: None, fields: BTreeMap::from([( "lead".to_owned(), Schema { kind: SchemaKind::Object, description: None, required: true, nullable: false, default_value: None, fields: BTreeMap::from([( "email".to_owned(), Schema { kind: SchemaKind::String, description: None, required: true, nullable: false, default_value: None, fields: BTreeMap::new(), items: None, enum_values: Vec::new(), variants: Vec::new(), }, )]), items: None, enum_values: Vec::new(), variants: Vec::new(), }, )]), items: None, enum_values: Vec::new(), variants: Vec::new(), }; let result = schema.validate_shape(&json!({ "lead": { "email": "user@example.com" } })); assert!(result.is_ok()); } #[test] fn rejects_missing_required_field() { let schema = Schema { kind: SchemaKind::Object, description: None, required: true, nullable: false, default_value: None, fields: BTreeMap::from([( "email".to_owned(), Schema { kind: SchemaKind::String, description: None, required: true, nullable: false, default_value: None, fields: BTreeMap::new(), items: None, enum_values: Vec::new(), variants: Vec::new(), }, )]), items: None, enum_values: Vec::new(), variants: Vec::new(), }; let error = schema.validate_shape(&json!({})).unwrap_err(); assert_eq!(error, SchemaError::missing_field("$.email")); } #[test] fn rejects_invalid_scalar_type() { let schema = Schema { kind: SchemaKind::Boolean, description: None, required: true, nullable: false, default_value: None, fields: BTreeMap::new(), items: None, enum_values: Vec::new(), variants: Vec::new(), }; let error = schema.validate_shape(&json!("true")).unwrap_err(); assert_eq!(error, SchemaError::invalid_type("$", "boolean", "string")); } }