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