|
1 | 1 | //! Response validation for MCP test harness |
2 | 2 |
|
3 | | -use super::schema::{ExpectedOutput, FieldValidation}; |
| 3 | +use super::schema::{ExpectedOutput, FieldValidation, SchemaValidator}; |
4 | 4 | use crate::types::ValidationResult; |
| 5 | +use jsonpath_lib as jsonpath; |
| 6 | +use regex::Regex; |
| 7 | +use serde_json::Value; |
| 8 | +use std::collections::HashMap; |
5 | 9 |
|
6 | 10 | /// Response validator for test expectations |
7 | 11 | pub struct ResponseValidator { |
8 | | - // FUTURE: Add JSONPath evaluation, pattern matching for enhanced validation (tracked in #122) |
| 12 | + /// Schema validator for JSON schema-based validation |
| 13 | + schema_validator: SchemaValidator, |
| 14 | + /// Compiled regex patterns cache |
| 15 | + regex_cache: HashMap<String, Regex>, |
9 | 16 | } |
10 | 17 |
|
11 | 18 | impl ResponseValidator { |
12 | 19 | /// Create a new response validator |
13 | 20 | pub fn new() -> Self { |
14 | | - Self {} |
| 21 | + Self { |
| 22 | + schema_validator: SchemaValidator::new(), |
| 23 | + regex_cache: HashMap::new(), |
| 24 | + } |
| 25 | + } |
| 26 | + |
| 27 | + /// Create a response validator with a base directory for schema files |
| 28 | + pub fn with_base_dir<P: AsRef<std::path::Path>>(base_dir: P) -> Self { |
| 29 | + Self { |
| 30 | + schema_validator: SchemaValidator::with_base_dir(base_dir), |
| 31 | + regex_cache: HashMap::new(), |
| 32 | + } |
15 | 33 | } |
16 | 34 |
|
17 | 35 | /// Validate a response against expected output specification |
18 | 36 | pub fn validate_response( |
| 37 | + &mut self, |
| 38 | + response: &serde_json::Value, |
| 39 | + expected: &ExpectedOutput, |
| 40 | + ) -> ValidationResult { |
| 41 | + let mut errors = Vec::new(); |
| 42 | + let mut warnings = Vec::new(); |
| 43 | + |
| 44 | + // 1. Validate error expectation |
| 45 | + let error_validation = self.validate_error_expectation(response, expected); |
| 46 | + if !error_validation.valid { |
| 47 | + errors.extend(error_validation.errors); |
| 48 | + } |
| 49 | + warnings.extend(error_validation.warnings); |
| 50 | + |
| 51 | + // 2. Validate against JSON schema if provided |
| 52 | + if let Some(schema_validation) = self.validate_schema(response, expected) { |
| 53 | + if !schema_validation.valid { |
| 54 | + errors.extend(schema_validation.errors); |
| 55 | + } |
| 56 | + warnings.extend(schema_validation.warnings); |
| 57 | + } |
| 58 | + |
| 59 | + // 3. Validate specific fields |
| 60 | + for field in &expected.fields { |
| 61 | + let field_validation = self.validate_field(response, field); |
| 62 | + if !field_validation.valid { |
| 63 | + errors.extend(field_validation.errors); |
| 64 | + } |
| 65 | + warnings.extend(field_validation.warnings); |
| 66 | + } |
| 67 | + |
| 68 | + // 4. Check for extra fields if not allowed |
| 69 | + if !expected.allow_extra_fields { |
| 70 | + let extra_validation = self.validate_no_extra_fields(response, expected); |
| 71 | + if !extra_validation.valid { |
| 72 | + errors.extend(extra_validation.errors); |
| 73 | + } |
| 74 | + warnings.extend(extra_validation.warnings); |
| 75 | + } |
| 76 | + |
| 77 | + if errors.is_empty() { |
| 78 | + let mut result = ValidationResult::success(); |
| 79 | + for warning in warnings { |
| 80 | + result = result.with_warning(warning); |
| 81 | + } |
| 82 | + result |
| 83 | + } else { |
| 84 | + let mut result = ValidationResult::error(errors.join("; ")); |
| 85 | + for warning in warnings { |
| 86 | + result = result.with_warning(warning); |
| 87 | + } |
| 88 | + result |
| 89 | + } |
| 90 | + } |
| 91 | + |
| 92 | + /// Validate error expectation (whether error should or shouldn't occur) |
| 93 | + fn validate_error_expectation( |
19 | 94 | &self, |
20 | | - _response: &serde_json::Value, |
21 | | - _expected: &ExpectedOutput, |
| 95 | + response: &Value, |
| 96 | + expected: &ExpectedOutput, |
22 | 97 | ) -> ValidationResult { |
23 | | - // FUTURE: Implement comprehensive response validation (tracked in #122) |
| 98 | + let has_error = response.get("error").is_some(); |
| 99 | + |
| 100 | + if expected.error && !has_error { |
| 101 | + return ValidationResult::error("Expected error response but got success"); |
| 102 | + } |
| 103 | + |
| 104 | + if !expected.error && has_error { |
| 105 | + return ValidationResult::error("Expected success response but got error"); |
| 106 | + } |
| 107 | + |
| 108 | + // If error is expected, validate error details |
| 109 | + if expected.error && has_error { |
| 110 | + let error_obj = response.get("error").unwrap(); |
| 111 | + |
| 112 | + // Validate error code if specified |
| 113 | + if let Some(expected_code) = expected.error_code { |
| 114 | + if let Some(code) = error_obj.get("code").and_then(|c| c.as_i64()) { |
| 115 | + if code != expected_code as i64 { |
| 116 | + return ValidationResult::error(format!( |
| 117 | + "Expected error code {} but got {}", |
| 118 | + expected_code, code |
| 119 | + )); |
| 120 | + } |
| 121 | + } else { |
| 122 | + return ValidationResult::error("Error response missing 'code' field"); |
| 123 | + } |
| 124 | + } |
| 125 | + |
| 126 | + // Validate error message contains expected text |
| 127 | + if let Some(expected_message) = &expected.error_message_contains { |
| 128 | + if let Some(message) = error_obj.get("message").and_then(|m| m.as_str()) { |
| 129 | + if !message.contains(expected_message) { |
| 130 | + return ValidationResult::error(format!( |
| 131 | + "Error message '{}' does not contain expected text '{}'", |
| 132 | + message, expected_message |
| 133 | + )); |
| 134 | + } |
| 135 | + } else { |
| 136 | + return ValidationResult::error("Error response missing 'message' field"); |
| 137 | + } |
| 138 | + } |
| 139 | + } |
| 140 | + |
24 | 141 | ValidationResult::success() |
25 | 142 | } |
26 | 143 |
|
27 | | - /// Validate a specific field |
| 144 | + /// Validate response against JSON schema |
| 145 | + fn validate_schema( |
| 146 | + &mut self, |
| 147 | + response: &Value, |
| 148 | + expected: &ExpectedOutput, |
| 149 | + ) -> Option<ValidationResult> { |
| 150 | + // Try schema file first, then inline schema |
| 151 | + if let Some(schema_file) = &expected.schema_file { |
| 152 | + match self.schema_validator.validate(response, schema_file) { |
| 153 | + Ok(_) => Some(ValidationResult::success()), |
| 154 | + Err(e) => Some(ValidationResult::error(format!( |
| 155 | + "Schema validation failed: {}", |
| 156 | + e |
| 157 | + ))), |
| 158 | + } |
| 159 | + } else if let Some(schema) = &expected.schema { |
| 160 | + match self.schema_validator.validate_inline(response, schema) { |
| 161 | + Ok(_) => Some(ValidationResult::success()), |
| 162 | + Err(e) => Some(ValidationResult::error(format!( |
| 163 | + "Inline schema validation failed: {}", |
| 164 | + e |
| 165 | + ))), |
| 166 | + } |
| 167 | + } else { |
| 168 | + None |
| 169 | + } |
| 170 | + } |
| 171 | + |
| 172 | + /// Validate a specific field using JSONPath |
28 | 173 | pub fn validate_field( |
| 174 | + &mut self, |
| 175 | + response: &Value, |
| 176 | + field: &FieldValidation, |
| 177 | + ) -> ValidationResult { |
| 178 | + // Extract field value using JSONPath |
| 179 | + let field_values = match jsonpath::select(response, &field.path) { |
| 180 | + Ok(values) => values, |
| 181 | + Err(e) => { |
| 182 | + if field.required { |
| 183 | + return ValidationResult::error(format!( |
| 184 | + "Required field '{}' not found: {}", |
| 185 | + field.path, e |
| 186 | + )); |
| 187 | + } else { |
| 188 | + return ValidationResult::success(); // Optional field not found is OK |
| 189 | + } |
| 190 | + } |
| 191 | + }; |
| 192 | + |
| 193 | + // Check if field is required but not found |
| 194 | + if field.required && field_values.is_empty() { |
| 195 | + return ValidationResult::error(format!("Required field '{}' not found", field.path)); |
| 196 | + } |
| 197 | + |
| 198 | + // If optional field not found, validation passes |
| 199 | + if field_values.is_empty() { |
| 200 | + return ValidationResult::success(); |
| 201 | + } |
| 202 | + |
| 203 | + // Validate each found value |
| 204 | + let mut errors = Vec::new(); |
| 205 | + let mut warnings = Vec::new(); |
| 206 | + |
| 207 | + for value in field_values { |
| 208 | + let field_validation = self.validate_field_value(value, field); |
| 209 | + if !field_validation.valid { |
| 210 | + errors.extend(field_validation.errors); |
| 211 | + } |
| 212 | + warnings.extend(field_validation.warnings); |
| 213 | + } |
| 214 | + |
| 215 | + if errors.is_empty() { |
| 216 | + let mut result = ValidationResult::success(); |
| 217 | + for warning in warnings { |
| 218 | + result = result.with_warning(warning); |
| 219 | + } |
| 220 | + result |
| 221 | + } else { |
| 222 | + let mut result = ValidationResult::error(errors.join("; ")); |
| 223 | + for warning in warnings { |
| 224 | + result = result.with_warning(warning); |
| 225 | + } |
| 226 | + result |
| 227 | + } |
| 228 | + } |
| 229 | + |
| 230 | + /// Validate a single field value against field validation rules |
| 231 | + fn validate_field_value(&mut self, value: &Value, field: &FieldValidation) -> ValidationResult { |
| 232 | + // Check exact value match |
| 233 | + if let Some(expected_value) = &field.value { |
| 234 | + if value != expected_value { |
| 235 | + return ValidationResult::error(format!( |
| 236 | + "Field '{}' expected value {:?} but got {:?}", |
| 237 | + field.path, expected_value, value |
| 238 | + )); |
| 239 | + } |
| 240 | + } |
| 241 | + |
| 242 | + // Check field type |
| 243 | + if let Some(expected_type) = &field.field_type { |
| 244 | + if !self.check_field_type(value, expected_type) { |
| 245 | + return ValidationResult::error(format!( |
| 246 | + "Field '{}' expected type '{}' but got type '{}'", |
| 247 | + field.path, |
| 248 | + expected_type, |
| 249 | + self.get_value_type(value) |
| 250 | + )); |
| 251 | + } |
| 252 | + } |
| 253 | + |
| 254 | + // Check pattern for strings |
| 255 | + if let Some(pattern) = &field.pattern { |
| 256 | + if let Some(string_value) = value.as_str() { |
| 257 | + match self.get_or_compile_regex(pattern) { |
| 258 | + Ok(regex) => { |
| 259 | + if !regex.is_match(string_value) { |
| 260 | + return ValidationResult::error(format!( |
| 261 | + "Field '{}' value '{}' does not match pattern '{}'", |
| 262 | + field.path, string_value, pattern |
| 263 | + )); |
| 264 | + } |
| 265 | + } |
| 266 | + Err(e) => { |
| 267 | + return ValidationResult::error(format!( |
| 268 | + "Invalid regex pattern '{}': {}", |
| 269 | + pattern, e |
| 270 | + )); |
| 271 | + } |
| 272 | + } |
| 273 | + } else { |
| 274 | + return ValidationResult::error(format!( |
| 275 | + "Field '{}' pattern validation requires string value, got {}", |
| 276 | + field.path, |
| 277 | + self.get_value_type(value) |
| 278 | + )); |
| 279 | + } |
| 280 | + } |
| 281 | + |
| 282 | + // Check numeric ranges |
| 283 | + if let Some(min_val) = field.min { |
| 284 | + if let Some(num_value) = value.as_f64() { |
| 285 | + if num_value < min_val { |
| 286 | + return ValidationResult::error(format!( |
| 287 | + "Field '{}' value {} is less than minimum {}", |
| 288 | + field.path, num_value, min_val |
| 289 | + )); |
| 290 | + } |
| 291 | + } |
| 292 | + } |
| 293 | + |
| 294 | + if let Some(max_val) = field.max { |
| 295 | + if let Some(num_value) = value.as_f64() { |
| 296 | + if num_value > max_val { |
| 297 | + return ValidationResult::error(format!( |
| 298 | + "Field '{}' value {} is greater than maximum {}", |
| 299 | + field.path, num_value, max_val |
| 300 | + )); |
| 301 | + } |
| 302 | + } |
| 303 | + } |
| 304 | + |
| 305 | + ValidationResult::success() |
| 306 | + } |
| 307 | + |
| 308 | + /// Check if extra fields are present when they shouldn't be |
| 309 | + fn validate_no_extra_fields( |
29 | 310 | &self, |
30 | | - _response: &serde_json::Value, |
31 | | - _field: &FieldValidation, |
| 311 | + _response: &Value, |
| 312 | + _expected: &ExpectedOutput, |
32 | 313 | ) -> ValidationResult { |
33 | | - // FUTURE: Implement field validation using JSONPath (tracked in #122) |
| 314 | + // FUTURE: Implement comprehensive extra field validation (tracked in #124) |
| 315 | + // This would require comparing response fields against expected schema structure. |
| 316 | + // Currently, we rely on JSON schema validation to catch unauthorized extra fields. |
34 | 317 | ValidationResult::success() |
35 | 318 | } |
| 319 | + |
| 320 | + /// Check if a value matches the expected type |
| 321 | + fn check_field_type(&self, value: &Value, expected_type: &str) -> bool { |
| 322 | + match expected_type.to_lowercase().as_str() { |
| 323 | + "string" => value.is_string(), |
| 324 | + "number" | "numeric" => value.is_number(), |
| 325 | + "integer" | "int" => value.is_i64() || value.is_u64(), |
| 326 | + "float" | "double" => value.is_f64(), |
| 327 | + "boolean" | "bool" => value.is_boolean(), |
| 328 | + "array" => value.is_array(), |
| 329 | + "object" => value.is_object(), |
| 330 | + "null" => value.is_null(), |
| 331 | + _ => false, // Unknown type |
| 332 | + } |
| 333 | + } |
| 334 | + |
| 335 | + /// Get the type name of a JSON value |
| 336 | + fn get_value_type(&self, value: &Value) -> &'static str { |
| 337 | + match value { |
| 338 | + Value::String(_) => "string", |
| 339 | + Value::Number(_) => "number", |
| 340 | + Value::Bool(_) => "boolean", |
| 341 | + Value::Array(_) => "array", |
| 342 | + Value::Object(_) => "object", |
| 343 | + Value::Null => "null", |
| 344 | + } |
| 345 | + } |
| 346 | + |
| 347 | + /// Get or compile a regex pattern, using cache for performance |
| 348 | + fn get_or_compile_regex(&mut self, pattern: &str) -> Result<&Regex, regex::Error> { |
| 349 | + if !self.regex_cache.contains_key(pattern) { |
| 350 | + let regex = Regex::new(pattern)?; |
| 351 | + self.regex_cache.insert(pattern.to_string(), regex); |
| 352 | + } |
| 353 | + Ok(self.regex_cache.get(pattern).unwrap()) |
| 354 | + } |
36 | 355 | } |
37 | 356 |
|
38 | 357 | impl Default for ResponseValidator { |
|
0 commit comments