1use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ValidationResult {
12 pub passed: bool,
14 pub total_checks: usize,
16 pub passed_checks: usize,
18 pub failed_checks: usize,
20 pub warnings: Vec<ValidationWarning>,
22 pub errors: Vec<ValidationError>,
24 pub breaking_changes: Vec<BreakingChange>,
26}
27
28impl ValidationResult {
29 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 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 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 pub fn add_breaking_change(&mut self, change: BreakingChange) {
59 self.breaking_changes.push(change);
60 self.passed = false;
61 }
62
63 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#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct ValidationWarning {
79 pub path: String,
81 pub message: String,
83 pub severity: WarningSeverity,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(rename_all = "lowercase")]
90pub enum WarningSeverity {
91 Info,
93 Warning,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ValidationError {
100 pub path: String,
102 pub message: String,
104 pub expected: Option<String>,
106 pub actual: Option<String>,
108 #[serde(skip_serializing_if = "Option::is_none")]
110 pub contract_diff_id: Option<String>,
111 #[serde(default)]
113 pub is_breaking_change: bool,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct BreakingChange {
119 pub change_type: BreakingChangeType,
121 pub path: String,
123 pub description: String,
125 pub severity: ChangeSeverity,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
131#[serde(rename_all = "snake_case")]
132pub enum BreakingChangeType {
133 EndpointRemoved,
135 RequiredFieldAdded,
137 FieldTypeChanged,
139 FieldRemoved,
141 ResponseCodeChanged,
143 AuthenticationChanged,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149#[serde(rename_all = "lowercase")]
150pub enum ChangeSeverity {
151 Critical,
153 Major,
155 Minor,
157}
158
159pub struct ContractValidator {
161 strict_mode: bool,
163 ignore_optional_fields: bool,
165}
166
167impl ContractValidator {
168 pub fn new() -> Self {
170 Self {
171 strict_mode: false,
172 ignore_optional_fields: false,
173 }
174 }
175
176 pub fn with_strict_mode(mut self, strict: bool) -> Self {
178 self.strict_mode = strict;
179 self
180 }
181
182 pub fn with_ignore_optional_fields(mut self, ignore: bool) -> Self {
184 self.ignore_optional_fields = ignore;
185 self
186 }
187
188 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 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 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 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 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 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 result.add_success();
326 }
327 }
328 }
329
330 result
331 }
332
333 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}