use crate::error::{Error, Result};
use crate::value::{Number, Value};
pub fn validate_against_schema(value: &Value, schema: &Value) -> Result<()> {
let schema_json = value_to_json(schema)
.map_err(|e| Error::Custom(format!("validate_against_schema: schema -> JSON: {e}")))?;
let instance_json = value_to_json(value)
.map_err(|e| Error::Parse(format!("validate_against_schema: value -> JSON: {e}")))?;
let validator = jsonschema::validator_for(&schema_json).map_err(|e| {
Error::Custom(format!(
"validate_against_schema: schema is not a valid JSON Schema: {e}"
))
})?;
if validator.is_valid(&instance_json) {
return Ok(());
}
let mut messages: Vec<String> = Vec::new();
for err in validator.iter_errors(&instance_json) {
messages.push(format!("{} (at `{}`)", err, err.instance_path));
}
let summary = if messages.len() == 1 {
format!("schema violation: {}", messages[0])
} else {
let joined = messages.join("\n - ");
format!(
"schema violations ({} total):\n - {}",
messages.len(),
joined
)
};
Err(Error::Custom(summary))
}
pub fn validate_against_schema_str(yaml: &str, schema_yaml: &str) -> Result<()> {
let value: Value = crate::from_str(yaml)?;
let schema: Value = crate::from_str(schema_yaml)?;
validate_against_schema(&value, &schema)
}
pub(crate) fn value_to_json(v: &Value) -> core::result::Result<serde_json::Value, String> {
serde_json::to_value(v).map_err(|e| e.to_string())
}
pub fn coerce_to_schema(value: &mut Value, schema: &Value) -> Result<usize> {
use jsonschema::JsonType;
use jsonschema::error::{TypeKind, ValidationErrorKind};
let schema_json = value_to_json(schema)
.map_err(|e| Error::Custom(format!("coerce_to_schema: schema -> JSON: {e}")))?;
let validator = jsonschema::validator_for(&schema_json).map_err(|e| {
Error::Custom(format!(
"coerce_to_schema: schema is not a valid JSON Schema: {e}"
))
})?;
let mut applied: usize = 0;
let max_iterations = 1024;
for _ in 0..max_iterations {
let instance_json = value_to_json(value)
.map_err(|e| Error::Parse(format!("coerce_to_schema: value -> JSON: {e}")))?;
let mut applied_this_pass = false;
let mut targets: Vec<(String, JsonType)> = Vec::new();
for err in validator.iter_errors(&instance_json) {
if let ValidationErrorKind::Type {
kind: TypeKind::Single(target),
} = &err.kind
{
targets.push((err.instance_path.to_string(), *target));
}
}
for (path, target) in targets {
let segments = parse_json_pointer(&path);
if let Some(node) = navigate_mut(value, &segments) {
if try_coerce(node, target) {
applied += 1;
applied_this_pass = true;
}
}
}
if !applied_this_pass {
break;
}
}
Ok(applied)
}
fn parse_json_pointer(s: &str) -> Vec<String> {
if s.is_empty() || s == "/" {
return Vec::new();
}
s.trim_start_matches('/')
.split('/')
.map(|seg| seg.replace("~1", "/").replace("~0", "~"))
.collect()
}
fn navigate_mut<'a>(value: &'a mut Value, path: &[String]) -> Option<&'a mut Value> {
let mut cursor = value;
for seg in path {
cursor = match cursor {
Value::Mapping(m) => m.get_mut(seg.as_str())?,
Value::Sequence(s) => {
let idx: usize = seg.parse().ok()?;
s.get_mut(idx)?
}
_ => return None,
};
}
Some(cursor)
}
fn try_coerce(node: &mut Value, target: jsonschema::JsonType) -> bool {
use jsonschema::JsonType;
let s = match node {
Value::String(s) => s.clone(),
_ => return false,
};
let coerced = match target {
JsonType::Integer => s
.parse::<i64>()
.ok()
.map(|n| Value::Number(Number::Integer(n))),
JsonType::Number => s
.parse::<f64>()
.ok()
.map(|f| Value::Number(Number::Float(f))),
JsonType::Boolean => match s.as_str() {
"true" => Some(Value::Bool(true)),
"false" => Some(Value::Bool(false)),
_ => None,
},
_ => None,
};
match coerced {
Some(new_v) => {
*node = new_v;
true
}
None => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(s: &str) -> Value {
crate::from_str(s).unwrap()
}
#[test]
fn valid_value_returns_ok() {
let schema =
parse("type: object\nrequired: [port]\nproperties:\n port:\n type: integer\n");
let value = parse("port: 8080\n");
assert!(validate_against_schema(&value, &schema).is_ok());
}
#[test]
fn type_mismatch_returns_err() {
let schema = parse("type: object\nproperties:\n port:\n type: integer\n");
let value = parse("port: hello\n");
let err = validate_against_schema(&value, &schema).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("schema violation"), "got: {msg}");
assert!(msg.contains("/port"), "path missing: {msg}");
}
#[test]
fn missing_required_field_returns_err() {
let schema = parse("type: object\nrequired: [port]\n");
let value = parse("host: localhost\n");
let err = validate_against_schema(&value, &schema).unwrap_err();
assert!(err.to_string().contains("port"));
}
#[test]
fn multiple_violations_aggregated() {
let schema = parse(
"type: object
required: [port, host]
properties:
port:
type: integer
host:
type: string
",
);
let value = parse("port: not-int\n");
let err = validate_against_schema(&value, &schema).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("schema violations"), "got: {msg}");
assert!(msg.contains("port"));
assert!(msg.contains("host"));
}
#[test]
fn invalid_schema_distinguished_from_invalid_data() {
let schema = parse("type: 42\n");
let value = parse("anything: 1\n");
let err = validate_against_schema(&value, &schema).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("not a valid JSON Schema"),
"expected schema-side error, got: {msg}"
);
}
#[test]
fn enum_constraint_enforced() {
let schema = parse(
"type: object
properties:
level:
enum: [trace, debug, info, warn, error]
",
);
assert!(validate_against_schema(&parse("level: warn\n"), &schema).is_ok());
assert!(validate_against_schema(&parse("level: ULTRA\n"), &schema).is_err());
}
#[test]
fn integer_bounds_enforced() {
let schema = parse(
"type: object
properties:
port:
type: integer
minimum: 0
maximum: 65535
",
);
assert!(validate_against_schema(&parse("port: 8080\n"), &schema).is_ok());
assert!(validate_against_schema(&parse("port: 70000\n"), &schema).is_err());
assert!(validate_against_schema(&parse("port: -1\n"), &schema).is_err());
}
#[test]
fn nested_object_validated() {
let schema = parse(
"type: object
properties:
db:
type: object
required: [host]
properties:
host:
type: string
",
);
let good = parse("db:\n host: localhost\n");
let bad = parse("db: {}\n");
assert!(validate_against_schema(&good, &schema).is_ok());
assert!(validate_against_schema(&bad, &schema).is_err());
}
#[test]
fn validate_against_schema_str_parses_both_inputs() {
let schema = "type: object\nrequired: [port]\n";
let yaml = "port: 8080\n";
assert!(validate_against_schema_str(yaml, schema).is_ok());
}
#[test]
fn schema_for_codegen_round_trip_validates_self() {
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, crate::JsonSchema)]
#[allow(dead_code)]
struct Cfg {
port: u16,
#[serde(default)]
host: String,
}
let schema = crate::schema_for::<Cfg>().unwrap();
let good = parse("port: 8080\nhost: localhost\n");
assert!(validate_against_schema(&good, &schema).is_ok());
let bad = parse("host: localhost\n"); let err = validate_against_schema(&bad, &schema).unwrap_err();
assert!(err.to_string().contains("port"));
}
}