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(openapiv3::ReferenceOr::Item(old_path_item)) =
323 old_spec.spec.paths.paths.get(path)
324 {
325 let operations = [
326 ("GET", old_path_item.get.as_ref(), new_path_item.get.as_ref()),
327 ("POST", old_path_item.post.as_ref(), new_path_item.post.as_ref()),
328 ("PUT", old_path_item.put.as_ref(), new_path_item.put.as_ref()),
329 ("DELETE", old_path_item.delete.as_ref(), new_path_item.delete.as_ref()),
330 ("PATCH", old_path_item.patch.as_ref(), new_path_item.patch.as_ref()),
331 ];
332
333 for (method, old_op, new_op) in &operations {
334 match (old_op, new_op) {
335 (Some(_), None) => {
336 result.add_breaking_change(BreakingChange {
338 change_type: BreakingChangeType::EndpointRemoved,
339 path: format!("{} {}", method, path),
340 description: format!(
341 "{} {} operation was removed",
342 method, path
343 ),
344 severity: ChangeSeverity::Critical,
345 });
346 }
347 (Some(old), Some(new)) => {
348 for new_param_ref in &new.parameters {
350 if let openapiv3::ReferenceOr::Item(new_param) = new_param_ref {
351 let is_required = match new_param {
352 openapiv3::Parameter::Query {
353 parameter_data, ..
354 }
355 | openapiv3::Parameter::Header {
356 parameter_data, ..
357 }
358 | openapiv3::Parameter::Path {
359 parameter_data, ..
360 }
361 | openapiv3::Parameter::Cookie {
362 parameter_data, ..
363 } => parameter_data.required,
364 };
365 if is_required {
366 let param_name = match new_param {
367 openapiv3::Parameter::Query {
368 parameter_data,
369 ..
370 }
371 | openapiv3::Parameter::Header {
372 parameter_data,
373 ..
374 }
375 | openapiv3::Parameter::Path {
376 parameter_data,
377 ..
378 }
379 | openapiv3::Parameter::Cookie {
380 parameter_data,
381 ..
382 } => ¶meter_data.name,
383 };
384 let existed_before = old.parameters.iter().any(|p| {
386 if let openapiv3::ReferenceOr::Item(old_p) = p {
387 let old_name = match old_p {
388 openapiv3::Parameter::Query {
389 parameter_data,
390 ..
391 }
392 | openapiv3::Parameter::Header {
393 parameter_data,
394 ..
395 }
396 | openapiv3::Parameter::Path {
397 parameter_data,
398 ..
399 }
400 | openapiv3::Parameter::Cookie {
401 parameter_data,
402 ..
403 } => ¶meter_data.name,
404 };
405 old_name == param_name
406 } else {
407 false
408 }
409 });
410 if !existed_before {
411 result.add_breaking_change(BreakingChange {
412 change_type: BreakingChangeType::RequiredFieldAdded,
413 path: format!("{} {}", method, path),
414 description: format!(
415 "New required parameter '{}' added to {} {}",
416 param_name, method, path
417 ),
418 severity: ChangeSeverity::Major,
419 });
420 }
421 }
422 }
423 }
424 result.add_success();
425 }
426 _ => {
427 result.add_success();
429 }
430 }
431 }
432 }
433 }
434 }
435
436 result
437 }
438
439 pub fn generate_report(&self, result: &ValidationResult) -> String {
441 let mut report = String::new();
442
443 report.push_str("# Contract Validation Report\n\n");
444 report.push_str(&format!(
445 "**Status**: {}\n",
446 if result.passed {
447 "✓ PASSED"
448 } else {
449 "✗ FAILED"
450 }
451 ));
452 report.push_str(&format!("**Total Checks**: {}\n", result.total_checks));
453 report.push_str(&format!("**Passed**: {}\n", result.passed_checks));
454 report.push_str(&format!("**Failed**: {}\n\n", result.failed_checks));
455
456 if !result.breaking_changes.is_empty() {
457 report.push_str("## Breaking Changes\n\n");
458 for change in &result.breaking_changes {
459 report.push_str(&format!(
460 "- **{:?}** ({:?}): {} - {}\n",
461 change.change_type, change.severity, change.path, change.description
462 ));
463 }
464 report.push('\n');
465 }
466
467 if !result.errors.is_empty() {
468 report.push_str("## Errors\n\n");
469 for error in &result.errors {
470 report.push_str(&format!("- **{}**: {}\n", error.path, error.message));
471 if let Some(expected) = &error.expected {
472 report.push_str(&format!(" - Expected: {}\n", expected));
473 }
474 if let Some(actual) = &error.actual {
475 report.push_str(&format!(" - Actual: {}\n", actual));
476 }
477 }
478 report.push('\n');
479 }
480
481 if !result.warnings.is_empty() {
482 report.push_str("## Warnings\n\n");
483 for warning in &result.warnings {
484 report.push_str(&format!(
485 "- **{}** ({:?}): {}\n",
486 warning.path, warning.severity, warning.message
487 ));
488 }
489 }
490
491 report
492 }
493}
494
495impl Default for ContractValidator {
496 fn default() -> Self {
497 Self::new()
498 }
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 #[test]
506 fn test_validation_result_creation() {
507 let result = ValidationResult::new();
508 assert!(result.passed);
509 assert_eq!(result.total_checks, 0);
510 assert_eq!(result.errors.len(), 0);
511 }
512
513 #[test]
514 fn test_add_error() {
515 let mut result = ValidationResult::new();
516 result.add_error(ValidationError {
517 path: "/api/test".to_string(),
518 message: "Test error".to_string(),
519 expected: None,
520 actual: None,
521 contract_diff_id: None,
522 is_breaking_change: false,
523 });
524
525 assert!(!result.passed);
526 assert_eq!(result.failed_checks, 1);
527 assert_eq!(result.errors.len(), 1);
528 }
529
530 #[test]
531 fn test_add_breaking_change() {
532 let mut result = ValidationResult::new();
533 result.add_breaking_change(BreakingChange {
534 change_type: BreakingChangeType::EndpointRemoved,
535 path: "/api/removed".to_string(),
536 description: "Endpoint was removed".to_string(),
537 severity: ChangeSeverity::Critical,
538 });
539
540 assert!(!result.passed);
541 assert_eq!(result.breaking_changes.len(), 1);
542 }
543
544 #[test]
545 fn test_contract_validator_creation() {
546 let validator = ContractValidator::new();
547 assert!(!validator.strict_mode);
548 assert!(!validator.ignore_optional_fields);
549 }
550
551 #[test]
552 fn test_contract_validator_with_options() {
553 let validator = ContractValidator::new()
554 .with_strict_mode(true)
555 .with_ignore_optional_fields(true);
556
557 assert!(validator.strict_mode);
558 assert!(validator.ignore_optional_fields);
559 }
560
561 #[test]
562 fn test_generate_report() {
563 let mut result = ValidationResult::new();
564 result.add_error(ValidationError {
565 path: "/api/test".to_string(),
566 message: "Test failed".to_string(),
567 expected: Some("200".to_string()),
568 actual: Some("404".to_string()),
569 contract_diff_id: None,
570 is_breaking_change: false,
571 });
572
573 let validator = ContractValidator::new();
574 let report = validator.generate_report(&result);
575
576 assert!(report.contains("FAILED"));
577 assert!(report.contains("/api/test"));
578 assert!(report.contains("Test failed"));
579 }
580}