mockforge_core/
contract_validation.rs

1/// Contract validation for CI/CD pipelines
2///
3/// Validates that mock configurations match live API responses
4/// and detects breaking changes in API contracts
5use serde::{Deserialize, Serialize};
6
7/// Validation result
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ValidationResult {
10    pub passed: bool,
11    pub total_checks: usize,
12    pub passed_checks: usize,
13    pub failed_checks: usize,
14    pub warnings: Vec<ValidationWarning>,
15    pub errors: Vec<ValidationError>,
16    pub breaking_changes: Vec<BreakingChange>,
17}
18
19impl ValidationResult {
20    pub fn new() -> Self {
21        Self {
22            passed: true,
23            total_checks: 0,
24            passed_checks: 0,
25            failed_checks: 0,
26            warnings: Vec::new(),
27            errors: Vec::new(),
28            breaking_changes: Vec::new(),
29        }
30    }
31
32    pub fn add_error(&mut self, error: ValidationError) {
33        self.errors.push(error);
34        self.failed_checks += 1;
35        self.total_checks += 1;
36        self.passed = false;
37    }
38
39    pub fn add_warning(&mut self, warning: ValidationWarning) {
40        self.warnings.push(warning);
41        self.passed_checks += 1;
42        self.total_checks += 1;
43    }
44
45    pub fn add_breaking_change(&mut self, change: BreakingChange) {
46        self.breaking_changes.push(change);
47        self.passed = false;
48    }
49
50    pub fn add_success(&mut self) {
51        self.passed_checks += 1;
52        self.total_checks += 1;
53    }
54}
55
56impl Default for ValidationResult {
57    fn default() -> Self {
58        Self::new()
59    }
60}
61
62/// Validation warning
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ValidationWarning {
65    pub path: String,
66    pub message: String,
67    pub severity: WarningSeverity,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(rename_all = "lowercase")]
72pub enum WarningSeverity {
73    Info,
74    Warning,
75}
76
77/// Validation error
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ValidationError {
80    pub path: String,
81    pub message: String,
82    pub expected: Option<String>,
83    pub actual: Option<String>,
84}
85
86/// Breaking change detected
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct BreakingChange {
89    pub change_type: BreakingChangeType,
90    pub path: String,
91    pub description: String,
92    pub severity: ChangeSeverity,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum BreakingChangeType {
98    EndpointRemoved,
99    RequiredFieldAdded,
100    FieldTypeChanged,
101    FieldRemoved,
102    ResponseCodeChanged,
103    AuthenticationChanged,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107#[serde(rename_all = "lowercase")]
108pub enum ChangeSeverity {
109    Critical,
110    Major,
111    Minor,
112}
113
114/// Contract validator
115pub struct ContractValidator {
116    strict_mode: bool,
117    ignore_optional_fields: bool,
118}
119
120impl ContractValidator {
121    pub fn new() -> Self {
122        Self {
123            strict_mode: false,
124            ignore_optional_fields: false,
125        }
126    }
127
128    pub fn with_strict_mode(mut self, strict: bool) -> Self {
129        self.strict_mode = strict;
130        self
131    }
132
133    pub fn with_ignore_optional_fields(mut self, ignore: bool) -> Self {
134        self.ignore_optional_fields = ignore;
135        self
136    }
137
138    /// Validate OpenAPI spec against live API
139    pub async fn validate_openapi(
140        &self,
141        spec: &crate::openapi::OpenApiSpec,
142        base_url: &str,
143    ) -> ValidationResult {
144        let mut result = ValidationResult::new();
145
146        for (path, path_item_ref) in &spec.spec.paths.paths {
147            if let openapiv3::ReferenceOr::Item(path_item) = path_item_ref {
148                let operations = vec![
149                    ("GET", path_item.get.as_ref()),
150                    ("POST", path_item.post.as_ref()),
151                    ("PUT", path_item.put.as_ref()),
152                    ("DELETE", path_item.delete.as_ref()),
153                    ("PATCH", path_item.patch.as_ref()),
154                ];
155
156                for (method, op_opt) in operations {
157                    if let Some(op) = op_opt {
158                        self.validate_endpoint(&mut result, base_url, method, path, op).await;
159                    }
160                }
161            }
162        }
163
164        result
165    }
166
167    async fn validate_endpoint(
168        &self,
169        result: &mut ValidationResult,
170        base_url: &str,
171        method: &str,
172        path: &str,
173        operation: &openapiv3::Operation,
174    ) {
175        let url = format!("{}{}", base_url, path);
176
177        // Try to make a request to the endpoint
178        let client = reqwest::Client::new();
179        let request = match method {
180            "GET" => client.get(&url),
181            "POST" => client.post(&url),
182            "PUT" => client.put(&url),
183            "DELETE" => client.delete(&url),
184            "PATCH" => client.patch(&url),
185            _ => {
186                result.add_error(ValidationError {
187                    path: path.to_string(),
188                    message: format!("Unsupported HTTP method: {}", method),
189                    expected: None,
190                    actual: None,
191                });
192                return;
193            }
194        };
195
196        match request.send().await {
197            Ok(response) => {
198                let status = response.status();
199
200                // Check if status code matches spec
201                let expected_codes: Vec<u16> = operation
202                    .responses
203                    .responses
204                    .keys()
205                    .filter_map(|k| match k {
206                        openapiv3::StatusCode::Code(code) => Some(*code),
207                        _ => None,
208                    })
209                    .collect();
210
211                if !expected_codes.contains(&status.as_u16()) {
212                    result.add_warning(ValidationWarning {
213                        path: format!("{} {}", method, path),
214                        message: format!(
215                            "Status code {} not in spec (expected: {:?})",
216                            status.as_u16(),
217                            expected_codes
218                        ),
219                        severity: WarningSeverity::Warning,
220                    });
221                } else {
222                    result.add_success();
223                }
224            }
225            Err(e) => {
226                if self.strict_mode {
227                    result.add_error(ValidationError {
228                        path: format!("{} {}", method, path),
229                        message: format!("Failed to reach endpoint: {}", e),
230                        expected: Some("2xx response".to_string()),
231                        actual: Some("connection error".to_string()),
232                    });
233                } else {
234                    result.add_warning(ValidationWarning {
235                        path: format!("{} {}", method, path),
236                        message: format!("Endpoint not reachable: {}", e),
237                        severity: WarningSeverity::Info,
238                    });
239                    result.add_success();
240                }
241            }
242        }
243    }
244
245    /// Compare two OpenAPI specs and detect breaking changes
246    pub fn compare_specs(
247        &self,
248        old_spec: &crate::openapi::OpenApiSpec,
249        new_spec: &crate::openapi::OpenApiSpec,
250    ) -> ValidationResult {
251        let mut result = ValidationResult::new();
252
253        // Check for removed endpoints
254        for (path, _) in &old_spec.spec.paths.paths {
255            if !new_spec.spec.paths.paths.contains_key(path) {
256                result.add_breaking_change(BreakingChange {
257                    change_type: BreakingChangeType::EndpointRemoved,
258                    path: path.clone(),
259                    description: format!("Endpoint {} was removed", path),
260                    severity: ChangeSeverity::Critical,
261                });
262            }
263        }
264
265        // Check for new required fields (this is a simplified check)
266        for (path, new_path_item_ref) in &new_spec.spec.paths.paths {
267            if let openapiv3::ReferenceOr::Item(_new_path_item) = new_path_item_ref {
268                if let Some(_old_path_item_ref) = old_spec.spec.paths.paths.get(path) {
269                    // In a real implementation, we'd do deep comparison of schemas
270                    // This is a placeholder for demonstration
271                    result.add_success();
272                }
273            }
274        }
275
276        result
277    }
278
279    /// Generate validation report
280    pub fn generate_report(&self, result: &ValidationResult) -> String {
281        let mut report = String::new();
282
283        report.push_str("# Contract Validation Report\n\n");
284        report.push_str(&format!(
285            "**Status**: {}\n",
286            if result.passed {
287                "✓ PASSED"
288            } else {
289                "✗ FAILED"
290            }
291        ));
292        report.push_str(&format!("**Total Checks**: {}\n", result.total_checks));
293        report.push_str(&format!("**Passed**: {}\n", result.passed_checks));
294        report.push_str(&format!("**Failed**: {}\n\n", result.failed_checks));
295
296        if !result.breaking_changes.is_empty() {
297            report.push_str("## Breaking Changes\n\n");
298            for change in &result.breaking_changes {
299                report.push_str(&format!(
300                    "- **{:?}** ({:?}): {} - {}\n",
301                    change.change_type, change.severity, change.path, change.description
302                ));
303            }
304            report.push('\n');
305        }
306
307        if !result.errors.is_empty() {
308            report.push_str("## Errors\n\n");
309            for error in &result.errors {
310                report.push_str(&format!("- **{}**: {}\n", error.path, error.message));
311                if let Some(expected) = &error.expected {
312                    report.push_str(&format!("  - Expected: {}\n", expected));
313                }
314                if let Some(actual) = &error.actual {
315                    report.push_str(&format!("  - Actual: {}\n", actual));
316                }
317            }
318            report.push('\n');
319        }
320
321        if !result.warnings.is_empty() {
322            report.push_str("## Warnings\n\n");
323            for warning in &result.warnings {
324                report.push_str(&format!(
325                    "- **{}** ({:?}): {}\n",
326                    warning.path, warning.severity, warning.message
327                ));
328            }
329        }
330
331        report
332    }
333}
334
335impl Default for ContractValidator {
336    fn default() -> Self {
337        Self::new()
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn test_validation_result_creation() {
347        let result = ValidationResult::new();
348        assert!(result.passed);
349        assert_eq!(result.total_checks, 0);
350        assert_eq!(result.errors.len(), 0);
351    }
352
353    #[test]
354    fn test_add_error() {
355        let mut result = ValidationResult::new();
356        result.add_error(ValidationError {
357            path: "/api/test".to_string(),
358            message: "Test error".to_string(),
359            expected: None,
360            actual: None,
361        });
362
363        assert!(!result.passed);
364        assert_eq!(result.failed_checks, 1);
365        assert_eq!(result.errors.len(), 1);
366    }
367
368    #[test]
369    fn test_add_breaking_change() {
370        let mut result = ValidationResult::new();
371        result.add_breaking_change(BreakingChange {
372            change_type: BreakingChangeType::EndpointRemoved,
373            path: "/api/removed".to_string(),
374            description: "Endpoint was removed".to_string(),
375            severity: ChangeSeverity::Critical,
376        });
377
378        assert!(!result.passed);
379        assert_eq!(result.breaking_changes.len(), 1);
380    }
381
382    #[test]
383    fn test_contract_validator_creation() {
384        let validator = ContractValidator::new();
385        assert!(!validator.strict_mode);
386        assert!(!validator.ignore_optional_fields);
387    }
388
389    #[test]
390    fn test_contract_validator_with_options() {
391        let validator = ContractValidator::new()
392            .with_strict_mode(true)
393            .with_ignore_optional_fields(true);
394
395        assert!(validator.strict_mode);
396        assert!(validator.ignore_optional_fields);
397    }
398
399    #[test]
400    fn test_generate_report() {
401        let mut result = ValidationResult::new();
402        result.add_error(ValidationError {
403            path: "/api/test".to_string(),
404            message: "Test failed".to_string(),
405            expected: Some("200".to_string()),
406            actual: Some("404".to_string()),
407        });
408
409        let validator = ContractValidator::new();
410        let report = validator.generate_report(&result);
411
412        assert!(report.contains("FAILED"));
413        assert!(report.contains("/api/test"));
414        assert!(report.contains("Test failed"));
415    }
416}