1use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ValidationResult {
10 pub passed: bool,
12 pub total_checks: usize,
14 pub passed_checks: usize,
16 pub failed_checks: usize,
18 pub warnings: Vec<ValidationWarning>,
20 pub errors: Vec<ValidationError>,
22 pub breaking_changes: Vec<BreakingChange>,
24}
25
26impl ValidationResult {
27 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 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 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 pub fn add_breaking_change(&mut self, change: BreakingChange) {
57 self.breaking_changes.push(change);
58 self.passed = false;
59 }
60
61 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#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ValidationWarning {
77 pub path: String,
79 pub message: String,
81 pub severity: WarningSeverity,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
87#[serde(rename_all = "lowercase")]
88pub enum WarningSeverity {
89 Info,
91 Warning,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct ValidationError {
98 pub path: String,
100 pub message: String,
102 pub expected: Option<String>,
104 pub actual: Option<String>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct BreakingChange {
111 pub change_type: BreakingChangeType,
113 pub path: String,
115 pub description: String,
117 pub severity: ChangeSeverity,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123#[serde(rename_all = "snake_case")]
124pub enum BreakingChangeType {
125 EndpointRemoved,
127 RequiredFieldAdded,
129 FieldTypeChanged,
131 FieldRemoved,
133 ResponseCodeChanged,
135 AuthenticationChanged,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(rename_all = "lowercase")]
142pub enum ChangeSeverity {
143 Critical,
145 Major,
147 Minor,
149}
150
151pub struct ContractValidator {
153 strict_mode: bool,
155 ignore_optional_fields: bool,
157}
158
159impl ContractValidator {
160 pub fn new() -> Self {
162 Self {
163 strict_mode: false,
164 ignore_optional_fields: false,
165 }
166 }
167
168 pub fn with_strict_mode(mut self, strict: bool) -> Self {
170 self.strict_mode = strict;
171 self
172 }
173
174 pub fn with_ignore_optional_fields(mut self, ignore: bool) -> Self {
176 self.ignore_optional_fields = ignore;
177 self
178 }
179
180 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 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 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 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 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 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 result.add_success();
314 }
315 }
316 }
317
318 result
319 }
320
321 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}