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        // Record the active validation mode for the Contracts pillar dashboard.
197        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        // Try to make a request to the endpoint
238        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                // Check if status code matches spec
263                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    /// Compare two OpenAPI specs and detect breaking changes
310    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        // Check for removed endpoints
318        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        // Compare operations for each path present in both specs
330        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                                // Operation was removed
347                                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                                // Check for new required parameters
359                                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                                                } => &parameter_data.name,
393                                            };
394                                            // Check if this required param existed in old spec
395                                            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                                                        } => &parameter_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                                // New operation added (not breaking) or both absent
438                                result.add_success();
439                            }
440                        }
441                    }
442                }
443            }
444        }
445
446        result
447    }
448
449    /// Generate validation report
450    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}