use anyhow::Result;
use serde_json::Value;
use std::collections::HashMap;
use thiserror::Error;
#[derive(Error, Debug, Clone)]
pub enum ValidationError {
#[error("Schema compilation failed: {0}")]
SchemaError(String),
#[error("Parameter '{field}' is required but missing")]
MissingRequired {
field: String,
},
#[error("Parameter '{field}' validation failed: {reason}")]
ValidationFailed {
field: String,
reason: String,
},
#[error("Value transformation failed for '{field}': {reason}")]
TransformationFailed {
field: String,
reason: String,
},
#[error("JSON Schema is invalid: {0}")]
InvalidSchema(String),
}
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub is_valid: bool,
pub errors: Vec<ValidationError>,
pub warnings: Vec<String>,
pub validated_params: Value,
pub transformations: Vec<String>,
}
pub struct ParameterValidator {
pub auto_transform: bool,
pub strict_mode: bool,
}
impl Default for ParameterValidator {
fn default() -> Self {
Self {
auto_transform: true,
strict_mode: false,
}
}
}
impl ParameterValidator {
pub fn new() -> Self {
Self::default()
}
pub fn strict() -> Self {
Self {
auto_transform: false,
strict_mode: true,
}
}
pub fn validate(&self, schema: &Value, params: &Value) -> ValidationResult {
let mut result = ValidationResult {
is_valid: true,
errors: Vec::new(),
warnings: Vec::new(),
validated_params: params.clone(),
transformations: Vec::new(),
};
if let Err(e) = self.validate_schema_syntax(schema) {
result.is_valid = false;
result.errors.push(e);
return result;
}
if self.auto_transform {
if let Err(e) = self.apply_transformations(schema, &mut result) {
result.is_valid = false;
result.errors.push(e);
return result;
}
}
if let Err(e) = self.validate_against_schema(schema, &result.validated_params) {
result.is_valid = false;
result.errors.push(e);
}
if let Err(e) = self.check_required_fields(schema, &result.validated_params) {
result.is_valid = false;
result.errors.push(e);
}
result
}
fn validate_schema_syntax(&self, schema: &Value) -> Result<(), ValidationError> {
if !schema.is_object() {
return Err(ValidationError::InvalidSchema(
"Schema must be a JSON object".to_string(),
));
}
Ok(())
}
fn validate_against_schema(
&self,
schema: &Value,
params: &Value,
) -> Result<(), ValidationError> {
if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
if let Some(params_obj) = params.as_object() {
for (field_name, field_schema) in properties {
if let Some(param_value) = params_obj.get(field_name) {
if let Some(expected_type) =
field_schema.get("type").and_then(|t| t.as_str())
{
let valid_type = match expected_type {
"string" => param_value.is_string(),
"number" => param_value.is_number(),
"integer" => {
param_value.is_number()
&& param_value.as_f64().is_some_and(|n| n.fract() == 0.0)
}
"boolean" => param_value.is_boolean(),
"array" => param_value.is_array(),
"object" => param_value.is_object(),
_ => true, };
if !valid_type {
return Err(ValidationError::ValidationFailed {
field: field_name.clone(),
reason: format!(
"Expected type '{}' but got '{}'",
expected_type,
if param_value.is_string() {
"string"
} else if param_value.is_number() {
"number"
} else if param_value.is_boolean() {
"boolean"
} else if param_value.is_array() {
"array"
} else if param_value.is_object() {
"object"
} else {
"null"
}
),
});
}
}
}
}
}
}
Ok(())
}
fn apply_transformations(
&self,
schema: &Value,
result: &mut ValidationResult,
) -> Result<(), ValidationError> {
if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
if let Value::Object(ref mut params_map) = result.validated_params {
let mut transformations = Vec::new();
for (field_name, field_schema) in properties {
if let Some(param_value) = params_map.get_mut(field_name) {
let field_transformations =
self.transform_field_value(field_name, field_schema, param_value)?;
transformations.extend(field_transformations);
}
}
result.transformations.extend(transformations);
}
}
Ok(())
}
fn transform_field_value(
&self,
field_name: &str,
field_schema: &Value,
param_value: &mut Value,
) -> Result<Vec<String>, ValidationError> {
let mut transformations = Vec::new();
if let Some("string") = field_schema.get("type").and_then(|t| t.as_str()) {
if let Some(description) = field_schema.get("description").and_then(|d| d.as_str()) {
let desc_lower = description.to_lowercase();
if desc_lower.contains("url")
|| desc_lower.contains("uri")
|| field_name.to_lowercase().contains("url")
{
if let Value::String(url_str) = param_value {
let original_url = url_str.clone();
if let Some(fixed_url) = self.auto_fix_url(&original_url) {
*param_value = Value::String(fixed_url.clone());
transformations.push(format!(
"Auto-prefixed URL in '{field_name}': '{original_url}' → '{fixed_url}'"
));
}
}
}
}
}
if let Some("number") = field_schema.get("type").and_then(|t| t.as_str()) {
if let Value::String(str_val) = param_value {
let original_str = str_val.clone();
if let Ok(num_val) = original_str.parse::<f64>() {
*param_value = Value::Number(serde_json::Number::from_f64(num_val).unwrap());
transformations.push(format!(
"Converted string to number in '{field_name}': '{original_str}' → {num_val}"
));
}
}
}
if let Some("integer") = field_schema.get("type").and_then(|t| t.as_str()) {
if let Value::String(str_val) = param_value {
let original_str = str_val.clone();
if let Ok(int_val) = original_str.parse::<i64>() {
*param_value = Value::Number(serde_json::Number::from(int_val));
transformations.push(format!(
"Converted string to integer in '{field_name}': '{original_str}' → {int_val}"
));
}
}
}
if let Some("boolean") = field_schema.get("type").and_then(|t| t.as_str()) {
if let Value::String(str_val) = param_value {
let original_str = str_val.clone();
let bool_val = match original_str.to_lowercase().as_str() {
"true" | "yes" | "1" | "on" => Some(true),
"false" | "no" | "0" | "off" => Some(false),
_ => None,
};
if let Some(bool_val) = bool_val {
*param_value = Value::Bool(bool_val);
transformations.push(format!(
"Converted string to boolean in '{field_name}': '{original_str}' → {bool_val}"
));
}
}
}
Ok(transformations)
}
fn auto_fix_url(&self, url: &str) -> Option<String> {
if url.is_empty() || url.starts_with("http://") || url.starts_with("https://") {
return None; }
if url.starts_with("localhost")
|| url.starts_with("127.0.0.1")
|| url.starts_with("0.0.0.0")
{
Some(format!("http://{url}"))
} else if url.contains('.') && !url.contains(' ') {
Some(format!("https://{url}"))
} else {
None
}
}
fn check_required_fields(&self, schema: &Value, params: &Value) -> Result<(), ValidationError> {
if let Some(required) = schema.get("required").and_then(|r| r.as_array()) {
if let Some(params_obj) = params.as_object() {
for required_field in required {
if let Some(field_name) = required_field.as_str() {
if !params_obj.contains_key(field_name) {
return Err(ValidationError::MissingRequired {
field: field_name.to_string(),
});
}
}
}
}
}
Ok(())
}
pub fn is_valid(&self, schema: &Value, params: &Value) -> bool {
self.validate(schema, params).is_valid
}
pub fn extract_parameter_hints(&self, schema: &Value) -> HashMap<String, ParameterHint> {
let mut hints = HashMap::new();
if let Some(properties) = schema.get("properties").and_then(|p| p.as_object()) {
let required_fields: Vec<String> = schema
.get("required")
.and_then(|r| r.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
for (field_name, field_schema) in properties {
let hint = ParameterHint {
name: field_name.clone(),
param_type: field_schema
.get("type")
.and_then(|t| t.as_str())
.unwrap_or("string")
.to_string(),
description: field_schema
.get("description")
.and_then(|d| d.as_str())
.map(|s| s.to_string()),
required: required_fields.contains(field_name),
default_value: field_schema.get("default").cloned(),
enum_values: field_schema.get("enum").and_then(|e| e.as_array()).cloned(),
format: field_schema
.get("format")
.and_then(|f| f.as_str())
.map(|s| s.to_string()),
pattern: field_schema
.get("pattern")
.and_then(|p| p.as_str())
.map(|s| s.to_string()),
min_length: field_schema.get("minLength").and_then(|m| m.as_u64()),
max_length: field_schema.get("maxLength").and_then(|m| m.as_u64()),
};
hints.insert(field_name.clone(), hint);
}
}
hints
}
}
#[derive(Debug, Clone)]
pub struct ParameterHint {
pub name: String,
pub param_type: String,
pub description: Option<String>,
pub required: bool,
pub default_value: Option<Value>,
pub enum_values: Option<Vec<Value>>,
pub format: Option<String>,
pub pattern: Option<String>,
pub min_length: Option<u64>,
pub max_length: Option<u64>,
}
pub fn validate_parameters(schema: &Value, params: &Value) -> ValidationResult {
ParameterValidator::new().validate(schema, params)
}
pub fn validate_parameters_strict(schema: &Value, params: &Value) -> ValidationResult {
ParameterValidator::strict().validate(schema, params)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_url_auto_prefixing() {
let schema = json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The URL to navigate to"
}
},
"required": ["url"]
});
let params = json!({"url": "www.google.com"});
let validator = ParameterValidator::new();
let result = validator.validate(&schema, ¶ms);
assert!(result.is_valid);
assert_eq!(result.validated_params["url"], "https://www.google.com");
assert!(!result.transformations.is_empty());
}
#[test]
fn test_localhost_url_prefixing() {
let schema = json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The URL to navigate to"
}
}
});
let params = json!({"url": "localhost:3000"});
let validator = ParameterValidator::new();
let result = validator.validate(&schema, ¶ms);
assert!(result.is_valid);
assert_eq!(result.validated_params["url"], "http://localhost:3000");
}
#[test]
fn test_type_coercion() {
let schema = json!({
"type": "object",
"properties": {
"width": {"type": "number"},
"height": {"type": "integer"},
"visible": {"type": "boolean"}
}
});
let params = json!({
"width": "800.5",
"height": "600",
"visible": "true"
});
let validator = ParameterValidator::new();
let result = validator.validate(&schema, ¶ms);
assert!(result.is_valid);
assert_eq!(result.validated_params["width"], 800.5);
assert_eq!(result.validated_params["height"], 600);
assert_eq!(result.validated_params["visible"], true);
assert_eq!(result.transformations.len(), 3);
}
#[test]
fn test_required_field_validation() {
let schema = json!({
"type": "object",
"properties": {
"url": {"type": "string"}
},
"required": ["url"]
});
let params = json!({});
let validator = ParameterValidator::new();
let result = validator.validate(&schema, ¶ms);
assert!(!result.is_valid);
assert!(result
.errors
.iter()
.any(|e| matches!(e, ValidationError::MissingRequired { field } if field == "url")));
}
#[test]
fn test_strict_mode_no_transforms() {
let schema = json!({
"type": "object",
"properties": {
"url": {"type": "string"}
}
});
let params = json!({"url": "www.google.com"});
let validator = ParameterValidator::strict();
let result = validator.validate(&schema, ¶ms);
assert_eq!(result.validated_params["url"], "www.google.com");
assert!(result.transformations.is_empty());
}
}