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 fn is_strict_mode(&self) -> bool {
190 self.strict_mode
191 }
192
193 pub fn is_ignore_optional_fields(&self) -> bool {
195 self.ignore_optional_fields
196 }
197
198 pub async fn validate_openapi(
200 &self,
201 spec: &openapiv3::OpenAPI,
202 base_url: &str,
203 ) -> ValidationResult {
204 let mut result = ValidationResult::new();
205
206 for (path, path_item_ref) in &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 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 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 pub fn compare_specs(
311 &self,
312 old_spec: &openapiv3::OpenAPI,
313 new_spec: &openapiv3::OpenAPI,
314 ) -> ValidationResult {
315 let mut result = ValidationResult::new();
316
317 for (path, _) in &old_spec.paths.paths {
319 if !new_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 for (path, new_path_item_ref) in &new_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.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 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 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 } => ¶meter_data.name,
393 };
394 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 } => ¶meter_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 result.add_success();
439 }
440 }
441 }
442 }
443 }
444 }
445
446 result
447 }
448
449 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 { "PASSED" } else { "FAILED" }
457 ));
458 report.push_str(&format!("**Total Checks**: {}\n", result.total_checks));
459 report.push_str(&format!("**Passed**: {}\n", result.passed_checks));
460 report.push_str(&format!("**Failed**: {}\n\n", result.failed_checks));
461
462 if !result.breaking_changes.is_empty() {
463 report.push_str("## Breaking Changes\n\n");
464 for change in &result.breaking_changes {
465 report.push_str(&format!(
466 "- **{:?}** ({:?}): {} - {}\n",
467 change.change_type, change.severity, change.path, change.description
468 ));
469 }
470 report.push('\n');
471 }
472
473 if !result.errors.is_empty() {
474 report.push_str("## Errors\n\n");
475 for error in &result.errors {
476 report.push_str(&format!("- **{}**: {}\n", error.path, error.message));
477 if let Some(expected) = &error.expected {
478 report.push_str(&format!(" - Expected: {}\n", expected));
479 }
480 if let Some(actual) = &error.actual {
481 report.push_str(&format!(" - Actual: {}\n", actual));
482 }
483 }
484 report.push('\n');
485 }
486
487 if !result.warnings.is_empty() {
488 report.push_str("## Warnings\n\n");
489 for warning in &result.warnings {
490 report.push_str(&format!(
491 "- **{}** ({:?}): {}\n",
492 warning.path, warning.severity, warning.message
493 ));
494 }
495 }
496
497 report
498 }
499}
500
501impl Default for ContractValidator {
502 fn default() -> Self {
503 Self::new()
504 }
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 #[test]
512 fn test_validation_result_creation() {
513 let result = ValidationResult::new();
514 assert!(result.passed);
515 assert_eq!(result.total_checks, 0);
516 assert_eq!(result.errors.len(), 0);
517 }
518
519 #[test]
520 fn test_add_error() {
521 let mut result = ValidationResult::new();
522 result.add_error(ValidationError {
523 path: "/api/test".to_string(),
524 message: "Test error".to_string(),
525 expected: None,
526 actual: None,
527 contract_diff_id: None,
528 is_breaking_change: false,
529 });
530
531 assert!(!result.passed);
532 assert_eq!(result.failed_checks, 1);
533 assert_eq!(result.errors.len(), 1);
534 }
535
536 #[test]
537 fn test_add_breaking_change() {
538 let mut result = ValidationResult::new();
539 result.add_breaking_change(BreakingChange {
540 change_type: BreakingChangeType::EndpointRemoved,
541 path: "/api/removed".to_string(),
542 description: "Endpoint was removed".to_string(),
543 severity: ChangeSeverity::Critical,
544 });
545
546 assert!(!result.passed);
547 assert_eq!(result.breaking_changes.len(), 1);
548 }
549
550 #[test]
551 fn test_contract_validator_creation() {
552 let validator = ContractValidator::new();
553 assert!(!validator.strict_mode);
554 assert!(!validator.ignore_optional_fields);
555 }
556
557 #[test]
558 fn test_contract_validator_with_options() {
559 let validator = ContractValidator::new()
560 .with_strict_mode(true)
561 .with_ignore_optional_fields(true);
562
563 assert!(validator.strict_mode);
564 assert!(validator.ignore_optional_fields);
565 }
566
567 #[test]
568 fn test_generate_report() {
569 let mut result = ValidationResult::new();
570 result.add_error(ValidationError {
571 path: "/api/test".to_string(),
572 message: "Test failed".to_string(),
573 expected: Some("200".to_string()),
574 actual: Some("404".to_string()),
575 contract_diff_id: None,
576 is_breaking_change: false,
577 });
578
579 let validator = ContractValidator::new();
580 let report = validator.generate_report(&result);
581
582 assert!(report.contains("FAILED"));
583 assert!(report.contains("/api/test"));
584 assert!(report.contains("Test failed"));
585 }
586}