use std::collections::{HashMap, HashSet};
use serde_json::Value;
use crate::errors::create_error;
use crate::format::validate_format;
use crate::suggestions::{
suggest_array_fix, suggest_missing_required, suggest_number_fix, suggest_remove_additional,
suggest_string_fix, suggest_type_fix,
};
use crate::types::{
Suggestion, ValidationError, ValidationOptions, ValidationResult, ValidationStats,
};
struct WalkerContext<'a> {
errors: Vec<ValidationError>,
root_schema: &'a Value,
fields_checked: u64,
fields_valid: u64,
fields_invalid: u64,
options: ValidationOptions,
}
fn get_json_type(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
fn matches_type(type_name: &str, data: &Value) -> bool {
match type_name {
"string" => data.is_string(),
"number" => data.is_number(),
"integer" => data.is_number() && data.as_f64().map(|n| n.fract() == 0.0).unwrap_or(false),
"boolean" => data.is_boolean(),
"null" => data.is_null(),
"object" => data.is_object(),
"array" => data.is_array(),
_ => false,
}
}
fn is_in_partial_paths(current_path: &str, paths: &[String]) -> bool {
paths.iter().any(|p| {
current_path == p
|| current_path.starts_with(&format!("{}/", p))
|| p.starts_with(&format!("{}/", current_path))
})
}
fn add_error(ctx: &mut WalkerContext, mut error: ValidationError, suggestion: Option<Suggestion>) {
if let Some(s) = suggestion {
error.suggestion = Some(s);
}
ctx.errors.push(error);
ctx.fields_invalid += 1;
}
fn resolve_ref<'a>(ref_str: &str, root_schema: &'a Value) -> Option<&'a Value> {
let prefix = "#/$defs/";
if !ref_str.starts_with(prefix) {
return None;
}
let def_name = &ref_str[prefix.len()..];
root_schema.get("$defs").and_then(|defs| defs.get(def_name))
}
fn walk_schema(schema: &Value, data: &Value, path: &str, ctx: &mut WalkerContext) {
if ctx.options.mode == "partial" && !ctx.options.paths.is_empty() && !is_in_partial_paths(path, &ctx.options.paths) {
return;
}
if let Some(ref_str) = schema.get("$ref").and_then(|v| v.as_str()) {
match resolve_ref(ref_str, ctx.root_schema) {
Some(resolved) => {
walk_schema(resolved, data, path, ctx);
return;
}
None => {
let mut context = HashMap::new();
context.insert("ref", ref_str.to_string());
add_error(ctx, create_error("E011", path, context), None);
return;
}
}
}
if let Some(dep) = schema.get("x-deprecated") {
if !dep.is_null() && dep.as_bool() != Some(false) {
let reason = if let Some(s) = dep.as_str() {
s.to_string()
} else {
"deprecated".to_string()
};
let mut context = HashMap::new();
context.insert("field", path.to_string());
context.insert("reason", reason);
let warning = create_error("W001", path, context);
ctx.errors.push(warning);
}
}
if let Some(all_of) = schema.get("allOf").and_then(|v| v.as_array()) {
validate_all_of(all_of, data, path, ctx);
}
if let Some(any_of) = schema.get("anyOf").and_then(|v| v.as_array()) {
validate_any_of(any_of, data, path, ctx);
}
if let Some(one_of) = schema.get("oneOf").and_then(|v| v.as_array()) {
validate_one_of(one_of, data, path, ctx);
}
if let Some(enum_values) = schema.get("enum").and_then(|v| v.as_array()) {
ctx.fields_checked += 1;
if !enum_values.iter().any(|e| e == data) {
let data_str = serde_json::to_string(data).unwrap_or_default();
let enum_str = enum_values
.iter()
.map(|e| serde_json::to_string(e).unwrap_or_default())
.collect::<Vec<_>>()
.join(", ");
let mut context = HashMap::new();
context.insert("value", data_str);
context.insert("constraint", format!("enum [{}]", enum_str));
add_error(ctx, create_error("E009", path, context), None);
} else {
ctx.fields_valid += 1;
}
return;
}
if schema.get("const").is_some() {
let const_val = &schema["const"];
ctx.fields_checked += 1;
if const_val != data {
let data_str = serde_json::to_string(data).unwrap_or_default();
let const_str = serde_json::to_string(const_val).unwrap_or_default();
let mut context = HashMap::new();
context.insert("value", data_str);
context.insert("constraint", format!("const {}", const_str));
add_error(ctx, create_error("E009", path, context), None);
} else {
ctx.fields_valid += 1;
}
return;
}
if let Some(type_val) = schema.get("type") {
let types: Vec<String> = if let Some(arr) = type_val.as_array() {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
} else if let Some(s) = type_val.as_str() {
vec![s.to_string()]
} else {
vec![]
};
let type_matches = types.iter().any(|t| matches_type(t, data));
if !type_matches {
ctx.fields_checked += 1;
let actual_type = get_json_type(data);
let suggestion = suggest_type_fix(data, &types[0]);
let mut context = HashMap::new();
context.insert("expected", types.join(" | "));
context.insert("actual", actual_type.to_string());
add_error(ctx, create_error("E001", path, context), suggestion);
return; }
}
let actual_type = get_json_type(data);
match actual_type {
"string" => {
if let Some(s) = data.as_str() {
validate_string(schema, s, path, ctx);
}
}
"number" => {
if let Some(n) = data.as_f64() {
validate_number(schema, n, path, ctx);
}
}
"array" => {
if let Some(arr) = data.as_array() {
validate_array(schema, arr, path, ctx);
}
}
"object" => {
if let Some(obj) = data.as_object() {
validate_object(schema, obj, path, ctx);
}
}
_ => {
ctx.fields_checked += 1;
ctx.fields_valid += 1;
}
}
}
fn validate_string(schema: &Value, data: &str, path: &str, ctx: &mut WalkerContext) {
ctx.fields_checked += 1;
let mut valid = true;
if let Some(min_len) = schema.get("minLength").and_then(|v| v.as_u64()) {
if (data.len() as u64) < min_len {
let mut context = HashMap::new();
context.insert(
"constraint",
format!("minLength {}, got length {}", min_len, data.len()),
);
add_error(ctx, create_error("E004", path, context), None);
valid = false;
}
}
if let Some(max_len) = schema.get("maxLength").and_then(|v| v.as_u64()) {
if (data.len() as u64) > max_len {
let suggestion = suggest_string_fix(data, schema);
let mut context = HashMap::new();
context.insert(
"constraint",
format!("maxLength {}, got length {}", max_len, data.len()),
);
add_error(ctx, create_error("E004", path, context), suggestion);
valid = false;
}
}
if let Some(pattern) = schema.get("pattern").and_then(|v| v.as_str()) {
if let Ok(re) = regex::Regex::new(pattern) {
if !re.is_match(data) {
let mut context = HashMap::new();
context.insert(
"constraint",
format!("pattern \"{}\" does not match", pattern),
);
add_error(ctx, create_error("E004", path, context), None);
valid = false;
}
}
}
if let Some(format) = schema.get("format").and_then(|v| v.as_str()) {
if !validate_format(data, format) {
let mut context = HashMap::new();
context.insert("format", format.to_string());
context.insert("value", data.to_string());
add_error(ctx, create_error("E008", path, context), None);
valid = false;
}
}
if valid {
ctx.fields_valid += 1;
}
}
fn validate_number(schema: &Value, data: f64, path: &str, ctx: &mut WalkerContext) {
ctx.fields_checked += 1;
let mut valid = true;
if let Some(min) = schema.get("minimum").and_then(|v| v.as_f64()) {
if data < min {
let suggestion = suggest_number_fix(data, schema);
let mut context = HashMap::new();
context.insert(
"constraint",
format!("minimum {}, got {}", format_num(min), format_num(data)),
);
add_error(ctx, create_error("E005", path, context), suggestion);
valid = false;
}
}
if let Some(max) = schema.get("maximum").and_then(|v| v.as_f64()) {
if data > max {
let suggestion = suggest_number_fix(data, schema);
let mut context = HashMap::new();
context.insert(
"constraint",
format!("maximum {}, got {}", format_num(max), format_num(data)),
);
add_error(ctx, create_error("E005", path, context), suggestion);
valid = false;
}
}
if let Some(exc_min) = schema.get("exclusiveMinimum").and_then(|v| v.as_f64()) {
if data <= exc_min {
let suggestion = suggest_number_fix(data, schema);
let mut context = HashMap::new();
context.insert(
"constraint",
format!(
"exclusiveMinimum {}, got {}",
format_num(exc_min),
format_num(data)
),
);
add_error(ctx, create_error("E005", path, context), suggestion);
valid = false;
}
}
if let Some(exc_max) = schema.get("exclusiveMaximum").and_then(|v| v.as_f64()) {
if data >= exc_max {
let suggestion = suggest_number_fix(data, schema);
let mut context = HashMap::new();
context.insert(
"constraint",
format!(
"exclusiveMaximum {}, got {}",
format_num(exc_max),
format_num(data)
),
);
add_error(ctx, create_error("E005", path, context), suggestion);
valid = false;
}
}
if let Some(multiple_of) = schema.get("multipleOf").and_then(|v| v.as_f64()) {
let remainder = (data % multiple_of).abs();
let tolerance = 1e-10;
if remainder > tolerance && (remainder - multiple_of).abs() > tolerance {
let mut context = HashMap::new();
context.insert(
"constraint",
format!(
"multipleOf {}, got {}",
format_num(multiple_of),
format_num(data)
),
);
add_error(ctx, create_error("E005", path, context), None);
valid = false;
}
}
if valid {
ctx.fields_valid += 1;
}
}
fn format_num(n: f64) -> String {
if n.fract() == 0.0 && n.abs() < 1e15 {
format!("{}", n as i64)
} else {
format!("{}", n)
}
}
fn validate_array(schema: &Value, data: &[Value], path: &str, ctx: &mut WalkerContext) {
ctx.fields_checked += 1;
let mut valid = true;
if let Some(min_items) = schema.get("minItems").and_then(|v| v.as_u64()) {
if (data.len() as u64) < min_items {
let mut context = HashMap::new();
context.insert(
"constraint",
format!("minItems {}, got {}", min_items, data.len()),
);
add_error(ctx, create_error("E006", path, context), None);
valid = false;
}
}
if let Some(max_items) = schema.get("maxItems").and_then(|v| v.as_u64()) {
if (data.len() as u64) > max_items {
let suggestion = suggest_array_fix(schema);
let mut context = HashMap::new();
context.insert(
"constraint",
format!("maxItems {}, got {}", max_items, data.len()),
);
add_error(ctx, create_error("E006", path, context), suggestion);
valid = false;
}
}
if schema
.get("uniqueItems")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
let mut seen: Vec<&Value> = Vec::new();
for item in data {
if seen.contains(&item) {
let mut context = HashMap::new();
context.insert(
"constraint",
"uniqueItems: array contains duplicates".to_string(),
);
add_error(ctx, create_error("E006", path, context), None);
valid = false;
break;
}
seen.push(item);
}
}
if valid {
ctx.fields_valid += 1;
}
if let Some(items_schema) = schema.get("items") {
for (i, item) in data.iter().enumerate() {
let item_path = format!("{}/{}", path, i);
walk_schema(items_schema, item, &item_path, ctx);
}
}
}
fn validate_object(
schema: &Value,
data: &serde_json::Map<String, Value>,
path: &str,
ctx: &mut WalkerContext,
) {
ctx.fields_checked += 1;
let mut valid = true;
let keys: Vec<&String> = data.keys().collect();
if let Some(required) = schema.get("required").and_then(|v| v.as_array()) {
for prop in required {
if let Some(prop_name) = prop.as_str() {
if !data.contains_key(prop_name) {
let suggestion = suggest_missing_required(prop_name);
let mut context = HashMap::new();
context.insert("property", prop_name.to_string());
add_error(ctx, create_error("E002", path, context), Some(suggestion));
valid = false;
}
}
}
}
if let Some(min_props) = schema.get("minProperties").and_then(|v| v.as_u64()) {
if (keys.len() as u64) < min_props {
let mut context = HashMap::new();
context.insert(
"constraint",
format!("minProperties {}, got {}", min_props, keys.len()),
);
add_error(ctx, create_error("E007", path, context), None);
valid = false;
}
}
if let Some(max_props) = schema.get("maxProperties").and_then(|v| v.as_u64()) {
if (keys.len() as u64) > max_props {
let mut context = HashMap::new();
context.insert(
"constraint",
format!("maxProperties {}, got {}", max_props, keys.len()),
);
add_error(ctx, create_error("E007", path, context), None);
valid = false;
}
}
if let Some(additional) = schema.get("additionalProperties") {
if additional != &Value::Bool(true) {
let defined: HashSet<&str> = schema
.get("properties")
.and_then(|v| v.as_object())
.map(|obj| obj.keys().map(|k| k.as_str()).collect())
.unwrap_or_default();
for key in &keys {
if !defined.contains(key.as_str()) {
if additional == &Value::Bool(false) {
let suggestion = suggest_remove_additional(key);
let mut context = HashMap::new();
context.insert("property", key.to_string());
add_error(ctx, create_error("E003", path, context), Some(suggestion));
valid = false;
} else if additional.is_object() {
let prop_path = format!("{}/{}", path, key);
walk_schema(additional, &data[key.as_str()], &prop_path, ctx);
}
}
}
}
}
if valid {
ctx.fields_valid += 1;
}
if let Some(properties) = schema.get("properties").and_then(|v| v.as_object()) {
for (prop_name, prop_schema) in properties {
if let Some(prop_data) = data.get(prop_name) {
let prop_path = format!("{}/{}", path, prop_name);
walk_schema(prop_schema, prop_data, &prop_path, ctx);
}
}
}
}
fn create_sub_context<'a>(parent_ctx: &WalkerContext<'a>) -> WalkerContext<'a> {
WalkerContext {
errors: Vec::new(),
root_schema: parent_ctx.root_schema,
fields_checked: 0,
fields_valid: 0,
fields_invalid: 0,
options: parent_ctx.options.clone(),
}
}
fn validate_all_of(schemas: &[Value], data: &Value, path: &str, ctx: &mut WalkerContext) {
for sub_schema in schemas {
let mut sub_ctx = create_sub_context(ctx);
walk_schema(sub_schema, data, path, &mut sub_ctx);
if !sub_ctx.errors.is_empty() {
let mut context = HashMap::new();
context.insert("keyword", "allOf".to_string());
add_error(ctx, create_error("E010", path, context), None);
return;
}
}
}
fn validate_any_of(schemas: &[Value], data: &Value, path: &str, ctx: &mut WalkerContext) {
for sub_schema in schemas {
let mut sub_ctx = create_sub_context(ctx);
walk_schema(sub_schema, data, path, &mut sub_ctx);
if sub_ctx.errors.is_empty() {
return; }
}
let mut context = HashMap::new();
context.insert("keyword", "anyOf".to_string());
add_error(ctx, create_error("E010", path, context), None);
}
fn validate_one_of(schemas: &[Value], data: &Value, path: &str, ctx: &mut WalkerContext) {
let mut match_count = 0;
for sub_schema in schemas {
let mut sub_ctx = create_sub_context(ctx);
walk_schema(sub_schema, data, path, &mut sub_ctx);
if sub_ctx.errors.is_empty() {
match_count += 1;
}
}
if match_count != 1 {
let mut context = HashMap::new();
context.insert("keyword", "oneOf".to_string());
add_error(ctx, create_error("E010", path, context), None);
}
}
pub fn validate(
data: &Value,
protocol: &Value,
options: Option<ValidationOptions>,
) -> ValidationResult {
let schema = protocol
.get("schema")
.cloned()
.unwrap_or(Value::Object(serde_json::Map::new()));
let opts = options.unwrap_or(ValidationOptions {
mode: "full".to_string(),
paths: vec![],
});
let mut ctx = WalkerContext {
errors: Vec::new(),
root_schema: &schema,
fields_checked: 0,
fields_valid: 0,
fields_invalid: 0,
options: opts.clone(),
};
walk_schema(&schema, data, "", &mut ctx);
ValidationResult {
valid: ctx.errors.iter().filter(|e| e.severity == "error").count() == 0,
mode: opts.mode,
errors: ctx.errors,
stats: ValidationStats {
fields_checked: ctx.fields_checked,
fields_valid: ctx.fields_valid,
fields_invalid: ctx.fields_invalid,
},
}
}
pub fn validate_schema(
data: &Value,
schema: &Value,
options: Option<ValidationOptions>,
) -> ValidationResult {
let protocol = serde_json::json!({
"$protocol": "https://dataprotocol.dev/v1",
"name": "__inline__",
"version": "0.0.0",
"schema": schema
});
validate(data, &protocol, options)
}