1use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ValidationResult {
12 pub passed: bool,
14 pub total_checks: usize,
16 pub passed_checks: usize,
18 pub failed_checks: usize,
20 pub warnings: Vec<ValidationWarning>,
22 pub errors: Vec<ValidationError>,
24 pub breaking_changes: Vec<BreakingChange>,
26}
27
28impl ValidationResult {
29 pub fn new() -> Self {
31 Self {
32 passed: true,
33 total_checks: 0,
34 passed_checks: 0,
35 failed_checks: 0,
36 warnings: Vec::new(),
37 errors: Vec::new(),
38 breaking_changes: Vec::new(),
39 }
40 }
41
42 pub fn add_error(&mut self, error: ValidationError) {
44 self.errors.push(error);
45 self.failed_checks += 1;
46 self.total_checks += 1;
47 self.passed = false;
48 }
49
50 pub fn add_warning(&mut self, warning: ValidationWarning) {
52 self.warnings.push(warning);
53 self.passed_checks += 1;
54 self.total_checks += 1;
55 }
56
57 pub fn add_breaking_change(&mut self, change: BreakingChange) {
59 self.breaking_changes.push(change);
60 self.passed = false;
61 }
62
63 pub fn add_success(&mut self) {
65 self.passed_checks += 1;
66 self.total_checks += 1;
67 }
68}
69
70impl Default for ValidationResult {
71 fn default() -> Self {
72 Self::new()
73 }
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct ValidationWarning {
79 pub path: String,
81 pub message: String,
83 pub severity: WarningSeverity,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(rename_all = "lowercase")]
90pub enum WarningSeverity {
91 Info,
93 Warning,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ValidationError {
100 pub path: String,
102 pub message: String,
104 pub expected: Option<String>,
106 pub actual: Option<String>,
108 #[serde(skip_serializing_if = "Option::is_none")]
110 pub contract_diff_id: Option<String>,
111 #[serde(default)]
113 pub is_breaking_change: bool,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct BreakingChange {
119 pub change_type: BreakingChangeType,
121 pub path: String,
123 pub description: String,
125 pub severity: ChangeSeverity,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
131#[serde(rename_all = "snake_case")]
132pub enum BreakingChangeType {
133 EndpointRemoved,
135 RequiredFieldAdded,
137 FieldTypeChanged,
139 FieldRemoved,
141 ResponseCodeChanged,
143 AuthenticationChanged,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149#[serde(rename_all = "lowercase")]
150pub enum ChangeSeverity {
151 Critical,
153 Major,
155 Minor,
157}
158
159pub struct ContractValidator {
161 strict_mode: bool,
163 ignore_optional_fields: bool,
165}
166
167impl ContractValidator {
168 pub fn new() -> Self {
170 Self {
171 strict_mode: false,
172 ignore_optional_fields: false,
173 }
174 }
175
176 pub fn with_strict_mode(mut self, strict: bool) -> Self {
178 self.strict_mode = strict;
179 self
180 }
181
182 pub fn with_ignore_optional_fields(mut self, ignore: bool) -> Self {
184 self.ignore_optional_fields = ignore;
185 self
186 }
187
188 pub async fn validate_openapi(
190 &self,
191 spec: &crate::openapi::OpenApiSpec,
192 base_url: &str,
193 ) -> ValidationResult {
194 let mut result = ValidationResult::new();
195
196 let mode = if self.strict_mode { "enforce" } else { "warn" };
198 crate::pillar_tracking::record_contracts_usage(
199 None,
200 None,
201 "validation_mode",
202 serde_json::json!({ "mode": mode }),
203 )
204 .await;
205
206 for (path, path_item_ref) in &spec.spec.paths.paths {
207 if let openapiv3::ReferenceOr::Item(path_item) = path_item_ref {
208 let operations = vec![
209 ("GET", path_item.get.as_ref()),
210 ("POST", path_item.post.as_ref()),
211 ("PUT", path_item.put.as_ref()),
212 ("DELETE", path_item.delete.as_ref()),
213 ("PATCH", path_item.patch.as_ref()),
214 ];
215
216 for (method, op_opt) in operations {
217 if let Some(op) = op_opt {
218 self.validate_endpoint(&mut result, base_url, method, path, op).await;
219 }
220 }
221 }
222 }
223
224 result
225 }
226
227 async fn validate_endpoint(
228 &self,
229 result: &mut ValidationResult,
230 base_url: &str,
231 method: &str,
232 path: &str,
233 operation: &openapiv3::Operation,
234 ) {
235 let url = format!("{}{}", base_url, path);
236
237 let client = reqwest::Client::new();
239 let request = match method {
240 "GET" => client.get(&url),
241 "POST" => client.post(&url),
242 "PUT" => client.put(&url),
243 "DELETE" => client.delete(&url),
244 "PATCH" => client.patch(&url),
245 _ => {
246 result.add_error(ValidationError {
247 path: path.to_string(),
248 message: format!("Unsupported HTTP method: {}", method),
249 expected: None,
250 actual: None,
251 contract_diff_id: None,
252 is_breaking_change: false,
253 });
254 return;
255 }
256 };
257
258 match request.send().await {
259 Ok(response) => {
260 let status = response.status();
261
262 let expected_codes: Vec<u16> = operation
264 .responses
265 .responses
266 .keys()
267 .filter_map(|k| match k {
268 openapiv3::StatusCode::Code(code) => Some(*code),
269 _ => None,
270 })
271 .collect();
272
273 if !expected_codes.contains(&status.as_u16()) {
274 result.add_warning(ValidationWarning {
275 path: format!("{} {}", method, path),
276 message: format!(
277 "Status code {} not in spec (expected: {:?})",
278 status.as_u16(),
279 expected_codes
280 ),
281 severity: WarningSeverity::Warning,
282 });
283 } else {
284 result.add_success();
285 }
286 }
287 Err(e) => {
288 if self.strict_mode {
289 result.add_error(ValidationError {
290 path: format!("{} {}", method, path),
291 message: format!("Failed to reach endpoint: {}", e),
292 expected: Some("2xx response".to_string()),
293 actual: Some("connection error".to_string()),
294 contract_diff_id: None,
295 is_breaking_change: false,
296 });
297 } else {
298 result.add_warning(ValidationWarning {
299 path: format!("{} {}", method, path),
300 message: format!("Endpoint not reachable: {}", e),
301 severity: WarningSeverity::Info,
302 });
303 result.add_success();
304 }
305 }
306 }
307 }
308
309 pub fn compare_specs(
311 &self,
312 old_spec: &crate::openapi::OpenApiSpec,
313 new_spec: &crate::openapi::OpenApiSpec,
314 ) -> ValidationResult {
315 let mut result = ValidationResult::new();
316
317 for (path, _) in &old_spec.spec.paths.paths {
319 if !new_spec.spec.paths.paths.contains_key(path) {
320 result.add_breaking_change(BreakingChange {
321 change_type: BreakingChangeType::EndpointRemoved,
322 path: path.clone(),
323 description: format!("Endpoint {} was removed", path),
324 severity: ChangeSeverity::Critical,
325 });
326 }
327 }
328
329 for (path, new_path_item_ref) in &new_spec.spec.paths.paths {
331 if let openapiv3::ReferenceOr::Item(new_path_item) = new_path_item_ref {
332 if let Some(openapiv3::ReferenceOr::Item(old_path_item)) =
333 old_spec.spec.paths.paths.get(path)
334 {
335 let operations = [
336 ("GET", old_path_item.get.as_ref(), new_path_item.get.as_ref()),
337 ("POST", old_path_item.post.as_ref(), new_path_item.post.as_ref()),
338 ("PUT", old_path_item.put.as_ref(), new_path_item.put.as_ref()),
339 ("DELETE", old_path_item.delete.as_ref(), new_path_item.delete.as_ref()),
340 ("PATCH", old_path_item.patch.as_ref(), new_path_item.patch.as_ref()),
341 ];
342
343 for (method, old_op, new_op) in &operations {
344 match (old_op, new_op) {
345 (Some(_), None) => {
346 result.add_breaking_change(BreakingChange {
348 change_type: BreakingChangeType::EndpointRemoved,
349 path: format!("{} {}", method, path),
350 description: format!(
351 "{} {} operation was removed",
352 method, path
353 ),
354 severity: ChangeSeverity::Critical,
355 });
356 }
357 (Some(old), Some(new)) => {
358 for new_param_ref in &new.parameters {
360 if let openapiv3::ReferenceOr::Item(new_param) = new_param_ref {
361 let is_required = match new_param {
362 openapiv3::Parameter::Query {
363 parameter_data, ..
364 }
365 | openapiv3::Parameter::Header {
366 parameter_data, ..
367 }
368 | openapiv3::Parameter::Path {
369 parameter_data, ..
370 }
371 | openapiv3::Parameter::Cookie {
372 parameter_data, ..
373 } => parameter_data.required,
374 };
375 if is_required {
376 let param_name = match new_param {
377 openapiv3::Parameter::Query {
378 parameter_data,
379 ..
380 }
381 | openapiv3::Parameter::Header {
382 parameter_data,
383 ..
384 }
385 | openapiv3::Parameter::Path {
386 parameter_data,
387 ..
388 }
389 | openapiv3::Parameter::Cookie {
390 parameter_data,
391 ..
392 } => ¶meter_data.name,
393 };
394 let existed_before = old.parameters.iter().any(|p| {
396 if let openapiv3::ReferenceOr::Item(old_p) = p {
397 let old_name = match old_p {
398 openapiv3::Parameter::Query {
399 parameter_data,
400 ..
401 }
402 | openapiv3::Parameter::Header {
403 parameter_data,
404 ..
405 }
406 | openapiv3::Parameter::Path {
407 parameter_data,
408 ..
409 }
410 | openapiv3::Parameter::Cookie {
411 parameter_data,
412 ..
413 } => ¶meter_data.name,
414 };
415 old_name == param_name
416 } else {
417 false
418 }
419 });
420 if !existed_before {
421 result.add_breaking_change(BreakingChange {
422 change_type: BreakingChangeType::RequiredFieldAdded,
423 path: format!("{} {}", method, path),
424 description: format!(
425 "New required parameter '{}' added to {} {}",
426 param_name, method, path
427 ),
428 severity: ChangeSeverity::Major,
429 });
430 }
431 }
432 }
433 }
434 result.add_success();
435 }
436 _ => {
437 result.add_success();
439 }
440 }
441 }
442 }
443 }
444 }
445
446 result
447 }
448
449 pub fn generate_report(&self, result: &ValidationResult) -> String {
451 let mut report = String::new();
452
453 report.push_str("# Contract Validation Report\n\n");
454 report.push_str(&format!(
455 "**Status**: {}\n",
456 if result.passed {
457 "✓ PASSED"
458 } else {
459 "✗ FAILED"
460 }
461 ));
462 report.push_str(&format!("**Total Checks**: {}\n", result.total_checks));
463 report.push_str(&format!("**Passed**: {}\n", result.passed_checks));
464 report.push_str(&format!("**Failed**: {}\n\n", result.failed_checks));
465
466 if !result.breaking_changes.is_empty() {
467 report.push_str("## Breaking Changes\n\n");
468 for change in &result.breaking_changes {
469 report.push_str(&format!(
470 "- **{:?}** ({:?}): {} - {}\n",
471 change.change_type, change.severity, change.path, change.description
472 ));
473 }
474 report.push('\n');
475 }
476
477 if !result.errors.is_empty() {
478 report.push_str("## Errors\n\n");
479 for error in &result.errors {
480 report.push_str(&format!("- **{}**: {}\n", error.path, error.message));
481 if let Some(expected) = &error.expected {
482 report.push_str(&format!(" - Expected: {}\n", expected));
483 }
484 if let Some(actual) = &error.actual {
485 report.push_str(&format!(" - Actual: {}\n", actual));
486 }
487 }
488 report.push('\n');
489 }
490
491 if !result.warnings.is_empty() {
492 report.push_str("## Warnings\n\n");
493 for warning in &result.warnings {
494 report.push_str(&format!(
495 "- **{}** ({:?}): {}\n",
496 warning.path, warning.severity, warning.message
497 ));
498 }
499 }
500
501 report
502 }
503}
504
505impl Default for ContractValidator {
506 fn default() -> Self {
507 Self::new()
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 #[test]
516 fn test_validation_result_creation() {
517 let result = ValidationResult::new();
518 assert!(result.passed);
519 assert_eq!(result.total_checks, 0);
520 assert_eq!(result.errors.len(), 0);
521 }
522
523 #[test]
524 fn test_add_error() {
525 let mut result = ValidationResult::new();
526 result.add_error(ValidationError {
527 path: "/api/test".to_string(),
528 message: "Test error".to_string(),
529 expected: None,
530 actual: None,
531 contract_diff_id: None,
532 is_breaking_change: false,
533 });
534
535 assert!(!result.passed);
536 assert_eq!(result.failed_checks, 1);
537 assert_eq!(result.errors.len(), 1);
538 }
539
540 #[test]
541 fn test_add_breaking_change() {
542 let mut result = ValidationResult::new();
543 result.add_breaking_change(BreakingChange {
544 change_type: BreakingChangeType::EndpointRemoved,
545 path: "/api/removed".to_string(),
546 description: "Endpoint was removed".to_string(),
547 severity: ChangeSeverity::Critical,
548 });
549
550 assert!(!result.passed);
551 assert_eq!(result.breaking_changes.len(), 1);
552 }
553
554 #[test]
555 fn test_contract_validator_creation() {
556 let validator = ContractValidator::new();
557 assert!(!validator.strict_mode);
558 assert!(!validator.ignore_optional_fields);
559 }
560
561 #[test]
562 fn test_contract_validator_with_options() {
563 let validator = ContractValidator::new()
564 .with_strict_mode(true)
565 .with_ignore_optional_fields(true);
566
567 assert!(validator.strict_mode);
568 assert!(validator.ignore_optional_fields);
569 }
570
571 #[test]
572 fn test_generate_report() {
573 let mut result = ValidationResult::new();
574 result.add_error(ValidationError {
575 path: "/api/test".to_string(),
576 message: "Test failed".to_string(),
577 expected: Some("200".to_string()),
578 actual: Some("404".to_string()),
579 contract_diff_id: None,
580 is_breaking_change: false,
581 });
582
583 let validator = ContractValidator::new();
584 let report = validator.generate_report(&result);
585
586 assert!(report.contains("FAILED"));
587 assert!(report.contains("/api/test"));
588 assert!(report.contains("Test failed"));
589 }
590}