261 lines
7.4 KiB
Rust
261 lines
7.4 KiB
Rust
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"));
|
|
}
|
|
}
|