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        // Check for new required fields (this is a simplified check)
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(_old_path_item_ref) = old_spec.spec.paths.paths.get(path) {
323                    // In a real implementation, we'd do deep comparison of schemas
324                    // This is a placeholder for demonstration
325                    result.add_success();
326                }
327            }
328        }
329
330        result
331    }
332
333    /// Generate validation report
334    pub fn generate_report(&self, result: &ValidationResult) -> String {
335        let mut report = String::new();
336
337        report.push_str("# Contract Validation Report\n\n");
338        report.push_str(&format!(
339            "**Status**: {}\n",
340            if result.passed {
341                "✓ PASSED"
342            } else {
343                "✗ FAILED"
344            }
345        ));
346        report.push_str(&format!("**Total Checks**: {}\n", result.total_checks));
347        report.push_str(&format!("**Passed**: {}\n", result.passed_checks));
348        report.push_str(&format!("**Failed**: {}\n\n", result.failed_checks));
349
350        if !result.breaking_changes.is_empty() {
351            report.push_str("## Breaking Changes\n\n");
352            for change in &result.breaking_changes {
353                report.push_str(&format!(
354                    "- **{:?}** ({:?}): {} - {}\n",
355                    change.change_type, change.severity, change.path, change.description
356                ));
357            }
358            report.push('\n');
359        }
360
361        if !result.errors.is_empty() {
362            report.push_str("## Errors\n\n");
363            for error in &result.errors {
364                report.push_str(&format!("- **{}**: {}\n", error.path, error.message));
365                if let Some(expected) = &error.expected {
366                    report.push_str(&format!("  - Expected: {}\n", expected));
367                }
368                if let Some(actual) = &error.actual {
369                    report.push_str(&format!("  - Actual: {}\n", actual));
370                }
371            }
372            report.push('\n');
373        }
374
375        if !result.warnings.is_empty() {
376            report.push_str("## Warnings\n\n");
377            for warning in &result.warnings {
378                report.push_str(&format!(
379                    "- **{}** ({:?}): {}\n",
380                    warning.path, warning.severity, warning.message
381                ));
382            }
383        }
384
385        report
386    }
387}
388
389impl Default for ContractValidator {
390    fn default() -> Self {
391        Self::new()
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn test_validation_result_creation() {
401        let result = ValidationResult::new();
402        assert!(result.passed);
403        assert_eq!(result.total_checks, 0);
404        assert_eq!(result.errors.len(), 0);
405    }
406
407    #[test]
408    fn test_add_error() {
409        let mut result = ValidationResult::new();
410        result.add_error(ValidationError {
411            path: "/api/test".to_string(),
412            message: "Test error".to_string(),
413            expected: None,
414            actual: None,
415            contract_diff_id: None,
416            is_breaking_change: false,
417        });
418
419        assert!(!result.passed);
420        assert_eq!(result.failed_checks, 1);
421        assert_eq!(result.errors.len(), 1);
422    }
423
424    #[test]
425    fn test_add_breaking_change() {
426        let mut result = ValidationResult::new();
427        result.add_breaking_change(BreakingChange {
428            change_type: BreakingChangeType::EndpointRemoved,
429            path: "/api/removed".to_string(),
430            description: "Endpoint was removed".to_string(),
431            severity: ChangeSeverity::Critical,
432        });
433
434        assert!(!result.passed);
435        assert_eq!(result.breaking_changes.len(), 1);
436    }
437
438    #[test]
439    fn test_contract_validator_creation() {
440        let validator = ContractValidator::new();
441        assert!(!validator.strict_mode);
442        assert!(!validator.ignore_optional_fields);
443    }
444
445    #[test]
446    fn test_contract_validator_with_options() {
447        let validator = ContractValidator::new()
448            .with_strict_mode(true)
449            .with_ignore_optional_fields(true);
450
451        assert!(validator.strict_mode);
452        assert!(validator.ignore_optional_fields);
453    }
454
455    #[test]
456    fn test_generate_report() {
457        let mut result = ValidationResult::new();
458        result.add_error(ValidationError {
459            path: "/api/test".to_string(),
460            message: "Test failed".to_string(),
461            expected: Some("200".to_string()),
462            actual: Some("404".to_string()),
463            contract_diff_id: None,
464            is_breaking_change: false,
465        });
466
467        let validator = ContractValidator::new();
468        let report = validator.generate_report(&result);
469
470        assert!(report.contains("FAILED"));
471        assert!(report.contains("/api/test"));
472        assert!(report.contains("Test failed"));
473    }
474}