use std::fmt;
use std::sync::Arc;
use jsonschema::Validator;
use serde_json::Value;
use thiserror::Error;
pub const MAX_TOOL_VALUE_BYTES: usize = 256 * 1024;
pub const MAX_TOOL_VALUE_DEPTH: usize = 32;
pub const MAX_SCHEMA_ERRORS: usize = 16;
const MAX_SCHEMA_ERROR_MESSAGE_BYTES: usize = 4096;
#[derive(Clone)]
pub struct CompiledToolSchema {
source: Value,
validator: Arc<Validator>,
}
impl CompiledToolSchema {
pub fn compile(source: Value) -> Result<Self, SchemaCompilationError> {
validate_json_bounds(&source).map_err(|()| SchemaCompilationError::SchemaOutOfBounds)?;
if contains_external_reference(&source) {
return Err(SchemaCompilationError::ExternalReference);
}
let validator = jsonschema::draft202012::options()
.build(&source)
.map_err(|_| SchemaCompilationError::InvalidSchema)?;
Ok(Self {
source,
validator: Arc::new(validator),
})
}
pub fn validate(&self, value: &Value) -> Result<(), SchemaValidationFailure> {
validate_json_bounds(value).map_err(|()| SchemaValidationFailure::ValueOutOfBounds)?;
let mut errors = self
.validator
.iter_errors(value)
.map(|error| {
let instance_path = error.instance_path.to_string();
let schema_path = error.schema_path.to_string();
let code = schema_keyword(&schema_path);
let message = truncate_utf8(&error.to_string(), MAX_SCHEMA_ERROR_MESSAGE_BYTES);
SchemaValidationError {
code,
instance_path,
schema_path,
message,
}
})
.collect::<Vec<_>>();
errors.sort_by(|left, right| {
(
left.instance_path.as_str(),
left.schema_path.as_str(),
left.message.as_str(),
)
.cmp(&(
right.instance_path.as_str(),
right.schema_path.as_str(),
right.message.as_str(),
))
});
errors.truncate(MAX_SCHEMA_ERRORS);
if errors.is_empty() {
Ok(())
} else {
Err(SchemaValidationFailure::Invalid { errors })
}
}
#[must_use]
pub const fn source(&self) -> &Value {
&self.source
}
}
impl fmt::Debug for CompiledToolSchema {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CompiledToolSchema")
.field("source", &self.source)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum SchemaCompilationError {
#[error("tool schema exceeds supported bounds")]
SchemaOutOfBounds,
#[error("tool schema contains an external reference")]
ExternalReference,
#[error("tool schema is invalid")]
InvalidSchema,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SchemaValidationError {
code: String,
instance_path: String,
schema_path: String,
message: String,
}
impl SchemaValidationError {
#[must_use]
pub fn code(&self) -> &str {
&self.code
}
#[must_use]
pub fn instance_path(&self) -> &str {
&self.instance_path
}
#[must_use]
pub fn schema_path(&self) -> &str {
&self.schema_path
}
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum SchemaValidationFailure {
#[error("tool value exceeds supported bounds")]
ValueOutOfBounds,
#[error("tool value violates its JSON Schema")]
Invalid {
errors: Vec<SchemaValidationError>,
},
}
impl SchemaValidationFailure {
#[must_use]
pub fn errors(&self) -> &[SchemaValidationError] {
match self {
Self::ValueOutOfBounds => &[],
Self::Invalid { errors } => errors,
}
}
}
fn validate_json_bounds(value: &Value) -> Result<(), ()> {
if serde_json::to_vec(value).map_err(|_| ())?.len() > MAX_TOOL_VALUE_BYTES
|| json_depth(value) > MAX_TOOL_VALUE_DEPTH
{
Err(())
} else {
Ok(())
}
}
fn contains_external_reference(value: &Value) -> bool {
match value {
Value::Array(values) => values.iter().any(contains_external_reference),
Value::Object(values) => values.iter().any(|(key, value)| {
(key == "$ref"
&& value
.as_str()
.is_some_and(|reference| !reference.starts_with('#')))
|| contains_external_reference(value)
}),
_ => false,
}
}
fn json_depth(value: &Value) -> usize {
match value {
Value::Array(values) => 1 + values.iter().map(json_depth).max().unwrap_or(0),
Value::Object(values) => 1 + values.values().map(json_depth).max().unwrap_or(0),
_ => 1,
}
}
fn schema_keyword(schema_path: &str) -> String {
schema_path
.rsplit('/')
.find(|segment| !segment.is_empty() && !segment.bytes().all(|byte| byte.is_ascii_digit()))
.unwrap_or("schema_validation")
.replace('~', "_")
}
fn truncate_utf8(value: &str, max_bytes: usize) -> String {
if value.len() <= max_bytes {
return value.to_owned();
}
let mut end = max_bytes;
while !value.is_char_boundary(end) {
end -= 1;
}
value[..end].to_owned()
}