use crate::DefinitionError;
use serde_json::{Map, Value};
pub(crate) const MAX_INTEGER: u64 = 1 << 53;
pub(crate) const RESERVED: &[&str] = &["__proto__", "constructor", "prototype"];
pub(crate) fn object<'a>(
value: &'a Value,
label: &str,
) -> Result<&'a Map<String, Value>, DefinitionError> {
value
.as_object()
.ok_or_else(|| DefinitionError::new(format!("{label} must be an object")))
}
fn keys<'a>(
value: &'a Value,
allowed: &[&str],
label: &str,
) -> Result<&'a Map<String, Value>, DefinitionError> {
let map = object(value, label)?;
for key in map.keys() {
if !allowed.contains(&key.as_str()) {
return Err(DefinitionError::new(format!(
"unsupported {label} key: {key}"
)));
}
}
Ok(map)
}
pub(crate) fn validate_metadata(value: &Value) -> Result<(), DefinitionError> {
match value {
Value::Object(map) => {
for item in map.values() {
validate_metadata(item)?;
}
}
Value::Array(items) => {
for item in items {
validate_metadata(item)?;
}
}
Value::Number(number) => {
let valid = number
.as_i64()
.map(|n| n.unsigned_abs() <= MAX_INTEGER)
.or_else(|| number.as_u64().map(|n| n <= MAX_INTEGER))
.unwrap_or(false);
if !valid {
return Err(DefinitionError::new(
"metadata numbers must be integers within +/-2^53; floats are not supported",
));
}
}
_ => {}
}
Ok(())
}
pub(crate) fn validate(schema: &Value) -> Result<(), DefinitionError> {
validate_metadata(schema)?;
let root = keys(
schema,
&["type", "properties", "required", "description"],
"input_schema",
)?;
if schema["type"] != "object" {
return Err(DefinitionError::new("input_schema type must be object"));
}
let empty = Map::new();
let properties = match root.get("properties") {
Some(properties) => object(properties, "properties")?,
None => &empty,
};
for (name, property) in properties {
if RESERVED.contains(&name.as_str()) {
return Err(DefinitionError::new(format!(
"reserved property name: {name}"
)));
}
validate_property(property, name, false)?;
}
if root.get("description").is_some_and(|v| !v.is_string()) {
return Err(DefinitionError::new("schema description must be a string"));
}
if let Some(required) = root.get("required") {
let mut seen = std::collections::BTreeSet::new();
let valid = required.as_array().is_some_and(|items| {
items.iter().all(|v| {
v.as_str()
.is_some_and(|name| properties.contains_key(name) && seen.insert(name))
})
});
if !valid {
return Err(DefinitionError::new(
"required must contain unique declared property names",
));
}
}
Ok(())
}
fn validate_property(
property: &Value,
label: &str,
scalar_only: bool,
) -> Result<(), DefinitionError> {
let map = keys(
property,
&[
"type",
"enum",
"description",
"default",
"minimum",
"maximum",
"maxLength",
"maxItems",
"items",
],
label,
)?;
let kind = property["type"].as_str().unwrap_or("");
if !["string", "number", "integer", "boolean"].contains(&kind)
&& (scalar_only || kind != "array")
{
return Err(DefinitionError::new(format!(
"{label}: only scalar properties and arrays of scalars are supported"
)));
}
if kind == "array" {
validate_property(&property["items"], &format!("{label}.items"), true)?;
} else if map.contains_key("items") {
return Err(DefinitionError::new(format!(
"{label}: items requires array type"
)));
}
if map.get("description").is_some_and(|v| !v.is_string()) {
return Err(DefinitionError::new(format!(
"{label}: description must be a string"
)));
}
for key in ["minimum", "maximum", "maxLength", "maxItems"] {
if let Some(value) = map.get(key) {
if value.as_i64().is_none()
|| (["maxLength", "maxItems"].contains(&key) && value.as_u64().is_none())
{
return Err(DefinitionError::new(format!(
"{label}: {key} must be an integer (lengths must be nonnegative)"
)));
}
}
}
if let Some(values) = map.get("enum") {
if !values.as_array().is_some_and(|items| {
!items.is_empty() && items.iter().all(|item| matches(item, property))
}) {
return Err(DefinitionError::new(format!(
"{label}: enum must be a nonempty array matching the property type"
)));
}
}
if map
.get("default")
.is_some_and(|value| !matches(value, property))
{
return Err(DefinitionError::new(format!(
"{label}: default must match the property type"
)));
}
Ok(())
}
fn matches(value: &Value, schema: &Value) -> bool {
match schema["type"].as_str() {
Some("string") => value.is_string(),
Some("number" | "integer") => value.is_i64() || value.is_u64(),
Some("boolean") => value.is_boolean(),
Some("array") => value
.as_array()
.is_some_and(|items| items.iter().all(|item| matches(item, &schema["items"]))),
_ => false,
}
}