Skip to main content

mockforge_core/
contract_validation.rs

1//! Pillars: [Contracts]
2//!
3//! Contract validation for CI/CD pipelines
4//!
5//! Validates that mock configurations match live API responses
6//! and detects breaking changes in API contracts
7use serde::{Deserialize, Serialize};
8
9/// Result of contract validation with detailed breakdown
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ValidationResult {
12    /// Whether all validation checks passed
13    pub passed: bool,
14    /// Total number of validation checks performed
15    pub total_checks: usize,
16    /// Number of checks that passed
17    pub passed_checks: usize,
18    /// Number of checks that failed
19    pub failed_checks: usize,
20    /// Non-blocking warnings encountered during validation
21    pub warnings: Vec<ValidationWarning>,
22    /// Validation errors that prevent the contract from being valid
23    pub errors: Vec<ValidationError>,
24    /// Breaking changes detected compared to previous contract version
25    pub breaking_changes: Vec<BreakingChange>,
26}
27
28impl ValidationResult {
29    /// Create a new empty validation result
30    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    /// Add a validation error (marks result as failed)
43    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    /// Add a validation warning (does not fail the result)
51    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    /// Add a breaking change (marks result as failed)
58    pub fn add_breaking_change(&mut self, change: BreakingChange) {
59        self.breaking_changes.push(change);
60        self.passed = false;
61    }
62
63    /// Record a successful validation check
64    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/// Validation warning for non-blocking issues
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct ValidationWarning {
79    /// JSON path or endpoint path where the warning occurred
80    pub path: String,
81    /// Human-readable warning message
82    pub message: String,
83    /// Severity level of the warning
84    pub severity: WarningSeverity,
85}
86
87/// Severity level for validation warnings
88#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(rename_all = "lowercase")]
90pub enum WarningSeverity {
91    /// Informational message (low priority)
92    Info,
93    /// Warning that should be reviewed (medium priority)
94    Warning,
95}
96
97/// Validation error for contract violations
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ValidationError {
100    /// JSON path or endpoint path where the error occurred
101    pub path: String,
102    /// Human-readable error message
103    pub message: String,
104    /// Expected value or format (if applicable)
105    pub expected: Option<String>,
106    /// Actual value found (if applicable)
107    pub actual: Option<String>,
108    /// Link to contract diff entry (if available)
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub contract_diff_id: Option<String>,
111    /// Whether this is a breaking change
112    #[serde(default)]
113    pub is_breaking_change: bool,
114}
115
116/// Breaking change detected between contract versions
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct BreakingChange {
119    /// Type of breaking change detected
120    pub change_type: BreakingChangeType,
121    /// Path or endpoint affected by the change
122    pub path: String,
123    /// Human-readable description of the breaking change
124    pub description: String,
125    /// Severity of the breaking change
126    pub severity: ChangeSeverity,
127}
128
129/// Types of breaking changes that can be detected
130#[derive(Debug, Clone, Serialize, Deserialize)]
131#[serde(rename_all = "snake_case")]
132pub enum BreakingChangeType {
133    /// An API endpoint was removed
134    EndpointRemoved,
135    /// A required field was added to a request/response
136    RequiredFieldAdded,
137    /// A field's data type was changed
138    FieldTypeChanged,
139    /// A field was removed from a request/response
140    FieldRemoved,
141    /// An HTTP response status code was changed
142    ResponseCodeChanged,
143    /// Authentication requirements were changed
144    AuthenticationChanged,
145}
146
147/// Severity level for breaking changes
148#[derive(Debug, Clone, Serialize, Deserialize)]
149#[serde(rename_all = "lowercase")]
150pub enum ChangeSeverity {
151    /// Critical breaking change - will break all clients
152    Critical,
153    /// Major breaking change - will break most clients
154    Major,
155    /// Minor breaking change - may break some clients
156    Minor,
157}
158
159/// Contract validator for validating OpenAPI specs against live APIs
160pub struct ContractValidator {
161    /// Whether to use strict validation mode (fails on warnings)
162    strict_mode: bool,
163    /// Whether to ignore optional fields during validation
164    ignore_optional_fields: bool,
165}
166
167impl ContractValidator {
168    /// Create a new contract validator with default settings
169    pub fn new() -> Self {
170        Self {
171            strict_mode: false,
172            ignore_optional_fields: false,
173        }
174    }
175
176    /// Configure strict validation mode (fails validation on warnings)
177    pub fn with_strict_mode(mut self, strict: bool) -> Self {
178        self.strict_mode = strict;
179        self
180    }
181
182    /// Configure whether to ignore optional fields during validation
183    pub fn with_ignore_optional_fields(mut self, ignore: bool) -> Self {
184        self.ignore_optional_fields = ignore;
185        self
186    }
187
188    /// Validate OpenAPI spec against live API
189    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        for (path, path_item_ref) in &spec.spec.paths.paths {
197            if let openapiv3::ReferenceOr::Item(path_item) = path_item_ref {
198                let operations = vec![
199                    ("GET", path_item.get.as_ref()),
200                    ("POST", path_item.post.as_ref()),
201                    ("PUT", path_item.put.as_ref()),
202                    ("DELETE", path_item.delete.as_ref()),
203                    ("PATCH", path_item.patch.as_ref()),
204                ];
205
206                for (method, op_opt) in operations {
207                    if let Some(op) = op_opt {
208                        self.validate_endpoint(&mut result, base_url, method, path, op).await;
209                    }
210                }
211            }
212        }
213
214        result
215    }
216
217    async fn validate_endpoint(
218        &self,
219        result: &mut ValidationResult,
220        base_url: &str,
221        method: &str,
222        path: &str,
223        operation: &openapiv3::Operation,
224    ) {
225        let url = format!("{}{}", base_url, path);
226
227        // Try to make a request to the endpoint
228        let client = reqwest::Client::new();
229        let request = match method {
230            "GET" => client.get(&url),
231            "POST" => client.post(&url),
232            "PUT" => client.put(&url),
233            "DELETE" => client.delete(&url),
234            "PATCH" => client.patch(&url),
235            _ => {
236                result.add_error(ValidationError {
237                    path: path.to_string(),
238                    message: format!("Unsupported HTTP method: {}", method),
239                    expected: None,
240                    actual: None,
241                    contract_diff_id: None,
242                    is_breaking_change: false,
243                });
244                return;
245            }
246        };
247
248        match request.send().await {
249            Ok(response) => {
250                let status = response.status();
251
252                // Check if status code matches spec
253                let expected_codes: Vec<u16> = operation
254                    .responses
255                    .responses
256                    .keys()
257                    .filter_map(|k| match k {
258                        openapiv3::StatusCode::Code(code) => Some(*code),
259                        _ => None,
260                    })
261                    .collect();
262
263                if !expected_codes.contains(&status.as_u16()) {
264                    result.add_warning(ValidationWarning {
265                        path: format!("{} {}", method, path),
266                        message: format!(
267                            "Status code {} not in spec (expected: {:?})",
268                            status.as_u16(),
269                            expected_codes
270                        ),
271                        severity: WarningSeverity::Warning,
272                    });
273                } else {
274                    result.add_success();
275                }
276            }
277            Err(e) => {
278                if self.strict_mode {
279                    result.add_error(ValidationError {
280                        path: format!("{} {}", method, path),
281                        message: format!("Failed to reach endpoint: {}", e),
282                        expected: Some("2xx response".to_string()),
283                        actual: Some("connection error".to_string()),
284                        contract_diff_id: None,
285                        is_breaking_change: false,
286                    });
287                } else {
288                    result.add_warning(ValidationWarning {
289                        path: format!("{} {}", method, path),
290                        message: format!("Endpoint not reachable: {}", e),
291                        severity: WarningSeverity::Info,
292                    });
293                    result.add_success();
294                }
295            }
296        }
297    }
298
299    /// Compare two OpenAPI specs and detect breaking changes
300    pub fn compare_specs(
301        &self,
302        old_spec: &crate::openapi::OpenApiSpec,
303        new_spec: &crate::openapi::OpenApiSpec,
304    ) -> ValidationResult {
305        let mut result = ValidationResult::new();
306
307        // Check for removed endpoints
308        for (path, _) in &old_spec.spec.paths.paths {
309            if !new_spec.spec.paths.paths.contains_key(path) {
310                result.add_breaking_change(BreakingChange {
311                    change_type: BreakingChangeType::EndpointRemoved,
312                    path: path.clone(),
313                    description: format!("Endpoint {} was removed", path),
314                    severity: ChangeSeverity::Critical,
315                });
316            }
317        }
318
319        // Compare operations for each path present in both specs
320        for (path, new_path_item_ref) in &new_spec.spec.paths.paths {
321            if let openapiv3::ReferenceOr::Item(new_path_item) = new_path_item_ref {
322                if let Some(openapiv3::ReferenceOr::Item(old_path_item)) =
323                    old_spec.spec.paths.paths.get(path)
324                {
325                    let operations = [
326                        ("GET", old_path_item.get.as_ref(), new_path_item.get.as_ref()),
327                        ("POST", old_path_item.post.as_ref(), new_path_item.post.as_ref()),
328                        ("PUT", old_path_item.put.as_ref(), new_path_item.put.as_ref()),
329                        ("DELETE", old_path_item.delete.as_ref(), new_path_item.delete.as_ref()),
330                        ("PATCH", old_path_item.patch.as_ref(), new_path_item.patch.as_ref()),
331                    ];
332
333                    for (method, old_op, new_op) in &operations {
334                        match (old_op, new_op) {
335                            (Some(_), None) => {
336                                // Operation was removed
337                                result.add_breaking_change(BreakingChange {
338                                    change_type: BreakingChangeType::EndpointRemoved,
339                                    path: format!("{} {}", method, path),
340                                    description: format!(
341                                        "{} {} operation was removed",
342                                        method, path
343                                    ),
344                                    severity: ChangeSeverity::Critical,
345                                });
346                            }
347                            (Some(old), Some(new)) => {
348                                // Check for new required parameters
349                                for new_param_ref in &new.parameters {
350                                    if let openapiv3::ReferenceOr::Item(new_param) = new_param_ref {
351                                        let is_required = match new_param {
352                                            openapiv3::Parameter::Query {
353                                                parameter_data, ..
354                                            }
355                                            | openapiv3::Parameter::Header {
356                                                parameter_data, ..
357                                            }
358                                            | openapiv3::Parameter::Path {
359                                                parameter_data, ..
360                                            }
361                                            | openapiv3::Parameter::Cookie {
362                                                parameter_data, ..
363                                            } => parameter_data.required,
364                                        };
365                                        if is_required {
366                                            let param_name = match new_param {
367                                                openapiv3::Parameter::Query {
368                                                    parameter_data,
369                                                    ..
370                                                }
371                                                | openapiv3::Parameter::Header {
372                                                    parameter_data,
373                                                    ..
374                                                }
375                                                | openapiv3::Parameter::Path {
376                                                    parameter_data,
377                                                    ..
378                                                }
379                                                | openapiv3::Parameter::Cookie {
380                                                    parameter_data,
381                                                    ..
382                                                } => &parameter_data.name,
383                                            };
384                                            // Check if this required param existed in old spec
385                                            let existed_before = old.parameters.iter().any(|p| {
386                                                if let openapiv3::ReferenceOr::Item(old_p) = p {
387                                                    let old_name = match old_p {
388                                                        openapiv3::Parameter::Query {
389                                                            parameter_data,
390                                                            ..
391                                                        }
392                                                        | openapiv3::Parameter::Header {
393                                                            parameter_data,
394                                                            ..
395                                                        }
396                                                        | openapiv3::Parameter::Path {
397                                                            parameter_data,
398                                                            ..
399                                                        }
400                                                        | openapiv3::Parameter::Cookie {
401                                                            parameter_data,
402                                                            ..
403                                                        } => &parameter_data.name,
404                                                    };
405                                                    old_name == param_name
406                                                } else {
407                                                    false
408                                                }
409                                            });
410                                            if !existed_before {
411                                                result.add_breaking_change(BreakingChange {
412                                                    change_type: BreakingChangeType::RequiredFieldAdded,
413                                                    path: format!("{} {}", method, path),
414                                                    description: format!(
415                                                        "New required parameter '{}' added to {} {}",
416                                                        param_name, method, path
417                                                    ),
418                                                    severity: ChangeSeverity::Major,
419                                                });
420                                            }
421                                        }
422                                    }
423                                }
424                                result.add_success();
425                            }
426                            _ => {
427                                // New operation added (not breaking) or both absent
428                                result.add_success();
429                            }
430                        }
431                    }
432                }
433            }
434        }
435
436        result
437    }
438
439    /// Generate validation report
440    pub fn generate_report(&self, result: &ValidationResult) -> String {
441        let mut report = String::new();
442
443        report.push_str("# Contract Validation Report\n\n");
444        report.push_str(&format!(
445            "**Status**: {}\n",
446            if result.passed {
447                "✓ PASSED"
448            } else {
449                "✗ FAILED"
450            }
451        ));
452        report.push_str(&format!("**Total Checks**: {}\n", result.total_checks));
453        report.push_str(&format!("**Passed**: {}\n", result.passed_checks));
454        report.push_str(&format!("**Failed**: {}\n\n", result.failed_checks));
455
456        if !result.breaking_changes.is_empty() {
457            report.push_str("## Breaking Changes\n\n");
458            for change in &result.breaking_changes {
459                report.push_str(&format!(
460                    "- **{:?}** ({:?}): {} - {}\n",
461                    change.change_type, change.severity, change.path, change.description
462                ));
463            }
464            report.push('\n');
465        }
466
467        if !result.errors.is_empty() {
468            report.push_str("## Errors\n\n");
469            for error in &result.errors {
470                report.push_str(&format!("- **{}**: {}\n", error.path, error.message));
471                if let Some(expected) = &error.expected {
472                    report.push_str(&format!("  - Expected: {}\n", expected));
473                }
474                if let Some(actual) = &error.actual {
475                    report.push_str(&format!("  - Actual: {}\n", actual));
476                }
477            }
478            report.push('\n');
479        }
480
481        if !result.warnings.is_empty() {
482            report.push_str("## Warnings\n\n");
483            for warning in &result.warnings {
484                report.push_str(&format!(
485                    "- **{}** ({:?}): {}\n",
486                    warning.path, warning.severity, warning.message
487                ));
488            }
489        }
490
491        report
492    }
493}
494
495impl Default for ContractValidator {
496    fn default() -> Self {
497        Self::new()
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    #[test]
506    fn test_validation_result_creation() {
507        let result = ValidationResult::new();
508        assert!(result.passed);
509        assert_eq!(result.total_checks, 0);
510        assert_eq!(result.errors.len(), 0);
511    }
512
513    #[test]
514    fn test_add_error() {
515        let mut result = ValidationResult::new();
516        result.add_error(ValidationError {
517            path: "/api/test".to_string(),
518            message: "Test error".to_string(),
519            expected: None,
520            actual: None,
521            contract_diff_id: None,
522            is_breaking_change: false,
523        });
524
525        assert!(!result.passed);
526        assert_eq!(result.failed_checks, 1);
527        assert_eq!(result.errors.len(), 1);
528    }
529
530    #[test]
531    fn test_add_breaking_change() {
532        let mut result = ValidationResult::new();
533        result.add_breaking_change(BreakingChange {
534            change_type: BreakingChangeType::EndpointRemoved,
535            path: "/api/removed".to_string(),
536            description: "Endpoint was removed".to_string(),
537            severity: ChangeSeverity::Critical,
538        });
539
540        assert!(!result.passed);
541        assert_eq!(result.breaking_changes.len(), 1);
542    }
543
544    #[test]
545    fn test_contract_validator_creation() {
546        let validator = ContractValidator::new();
547        assert!(!validator.strict_mode);
548        assert!(!validator.ignore_optional_fields);
549    }
550
551    #[test]
552    fn test_contract_validator_with_options() {
553        let validator = ContractValidator::new()
554            .with_strict_mode(true)
555            .with_ignore_optional_fields(true);
556
557        assert!(validator.strict_mode);
558        assert!(validator.ignore_optional_fields);
559    }
560
561    #[test]
562    fn test_generate_report() {
563        let mut result = ValidationResult::new();
564        result.add_error(ValidationError {
565            path: "/api/test".to_string(),
566            message: "Test failed".to_string(),
567            expected: Some("200".to_string()),
568            actual: Some("404".to_string()),
569            contract_diff_id: None,
570            is_breaking_change: false,
571        });
572
573        let validator = ContractValidator::new();
574        let report = validator.generate_report(&result);
575
576        assert!(report.contains("FAILED"));
577        assert!(report.contains("/api/test"));
578        assert!(report.contains("Test failed"));
579    }
580}