1use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ValidationResult {
10 pub passed: bool,
11 pub total_checks: usize,
12 pub passed_checks: usize,
13 pub failed_checks: usize,
14 pub warnings: Vec<ValidationWarning>,
15 pub errors: Vec<ValidationError>,
16 pub breaking_changes: Vec<BreakingChange>,
17}
18
19impl ValidationResult {
20 pub fn new() -> Self {
21 Self {
22 passed: true,
23 total_checks: 0,
24 passed_checks: 0,
25 failed_checks: 0,
26 warnings: Vec::new(),
27 errors: Vec::new(),
28 breaking_changes: Vec::new(),
29 }
30 }
31
32 pub fn add_error(&mut self, error: ValidationError) {
33 self.errors.push(error);
34 self.failed_checks += 1;
35 self.total_checks += 1;
36 self.passed = false;
37 }
38
39 pub fn add_warning(&mut self, warning: ValidationWarning) {
40 self.warnings.push(warning);
41 self.passed_checks += 1;
42 self.total_checks += 1;
43 }
44
45 pub fn add_breaking_change(&mut self, change: BreakingChange) {
46 self.breaking_changes.push(change);
47 self.passed = false;
48 }
49
50 pub fn add_success(&mut self) {
51 self.passed_checks += 1;
52 self.total_checks += 1;
53 }
54}
55
56impl Default for ValidationResult {
57 fn default() -> Self {
58 Self::new()
59 }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct ValidationWarning {
65 pub path: String,
66 pub message: String,
67 pub severity: WarningSeverity,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(rename_all = "lowercase")]
72pub enum WarningSeverity {
73 Info,
74 Warning,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ValidationError {
80 pub path: String,
81 pub message: String,
82 pub expected: Option<String>,
83 pub actual: Option<String>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct BreakingChange {
89 pub change_type: BreakingChangeType,
90 pub path: String,
91 pub description: String,
92 pub severity: ChangeSeverity,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum BreakingChangeType {
98 EndpointRemoved,
99 RequiredFieldAdded,
100 FieldTypeChanged,
101 FieldRemoved,
102 ResponseCodeChanged,
103 AuthenticationChanged,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107#[serde(rename_all = "lowercase")]
108pub enum ChangeSeverity {
109 Critical,
110 Major,
111 Minor,
112}
113
114pub struct ContractValidator {
116 strict_mode: bool,
117 ignore_optional_fields: bool,
118}
119
120impl ContractValidator {
121 pub fn new() -> Self {
122 Self {
123 strict_mode: false,
124 ignore_optional_fields: false,
125 }
126 }
127
128 pub fn with_strict_mode(mut self, strict: bool) -> Self {
129 self.strict_mode = strict;
130 self
131 }
132
133 pub fn with_ignore_optional_fields(mut self, ignore: bool) -> Self {
134 self.ignore_optional_fields = ignore;
135 self
136 }
137
138 pub async fn validate_openapi(
140 &self,
141 spec: &crate::openapi::OpenApiSpec,
142 base_url: &str,
143 ) -> ValidationResult {
144 let mut result = ValidationResult::new();
145
146 for (path, path_item_ref) in &spec.spec.paths.paths {
147 if let openapiv3::ReferenceOr::Item(path_item) = path_item_ref {
148 let operations = vec![
149 ("GET", path_item.get.as_ref()),
150 ("POST", path_item.post.as_ref()),
151 ("PUT", path_item.put.as_ref()),
152 ("DELETE", path_item.delete.as_ref()),
153 ("PATCH", path_item.patch.as_ref()),
154 ];
155
156 for (method, op_opt) in operations {
157 if let Some(op) = op_opt {
158 self.validate_endpoint(&mut result, base_url, method, path, op).await;
159 }
160 }
161 }
162 }
163
164 result
165 }
166
167 async fn validate_endpoint(
168 &self,
169 result: &mut ValidationResult,
170 base_url: &str,
171 method: &str,
172 path: &str,
173 operation: &openapiv3::Operation,
174 ) {
175 let url = format!("{}{}", base_url, path);
176
177 let client = reqwest::Client::new();
179 let request = match method {
180 "GET" => client.get(&url),
181 "POST" => client.post(&url),
182 "PUT" => client.put(&url),
183 "DELETE" => client.delete(&url),
184 "PATCH" => client.patch(&url),
185 _ => {
186 result.add_error(ValidationError {
187 path: path.to_string(),
188 message: format!("Unsupported HTTP method: {}", method),
189 expected: None,
190 actual: None,
191 });
192 return;
193 }
194 };
195
196 match request.send().await {
197 Ok(response) => {
198 let status = response.status();
199
200 let expected_codes: Vec<u16> = operation
202 .responses
203 .responses
204 .keys()
205 .filter_map(|k| match k {
206 openapiv3::StatusCode::Code(code) => Some(*code),
207 _ => None,
208 })
209 .collect();
210
211 if !expected_codes.contains(&status.as_u16()) {
212 result.add_warning(ValidationWarning {
213 path: format!("{} {}", method, path),
214 message: format!(
215 "Status code {} not in spec (expected: {:?})",
216 status.as_u16(),
217 expected_codes
218 ),
219 severity: WarningSeverity::Warning,
220 });
221 } else {
222 result.add_success();
223 }
224 }
225 Err(e) => {
226 if self.strict_mode {
227 result.add_error(ValidationError {
228 path: format!("{} {}", method, path),
229 message: format!("Failed to reach endpoint: {}", e),
230 expected: Some("2xx response".to_string()),
231 actual: Some("connection error".to_string()),
232 });
233 } else {
234 result.add_warning(ValidationWarning {
235 path: format!("{} {}", method, path),
236 message: format!("Endpoint not reachable: {}", e),
237 severity: WarningSeverity::Info,
238 });
239 result.add_success();
240 }
241 }
242 }
243 }
244
245 pub fn compare_specs(
247 &self,
248 old_spec: &crate::openapi::OpenApiSpec,
249 new_spec: &crate::openapi::OpenApiSpec,
250 ) -> ValidationResult {
251 let mut result = ValidationResult::new();
252
253 for (path, _) in &old_spec.spec.paths.paths {
255 if !new_spec.spec.paths.paths.contains_key(path) {
256 result.add_breaking_change(BreakingChange {
257 change_type: BreakingChangeType::EndpointRemoved,
258 path: path.clone(),
259 description: format!("Endpoint {} was removed", path),
260 severity: ChangeSeverity::Critical,
261 });
262 }
263 }
264
265 for (path, new_path_item_ref) in &new_spec.spec.paths.paths {
267 if let openapiv3::ReferenceOr::Item(_new_path_item) = new_path_item_ref {
268 if let Some(_old_path_item_ref) = old_spec.spec.paths.paths.get(path) {
269 result.add_success();
272 }
273 }
274 }
275
276 result
277 }
278
279 pub fn generate_report(&self, result: &ValidationResult) -> String {
281 let mut report = String::new();
282
283 report.push_str("# Contract Validation Report\n\n");
284 report.push_str(&format!(
285 "**Status**: {}\n",
286 if result.passed {
287 "✓ PASSED"
288 } else {
289 "✗ FAILED"
290 }
291 ));
292 report.push_str(&format!("**Total Checks**: {}\n", result.total_checks));
293 report.push_str(&format!("**Passed**: {}\n", result.passed_checks));
294 report.push_str(&format!("**Failed**: {}\n\n", result.failed_checks));
295
296 if !result.breaking_changes.is_empty() {
297 report.push_str("## Breaking Changes\n\n");
298 for change in &result.breaking_changes {
299 report.push_str(&format!(
300 "- **{:?}** ({:?}): {} - {}\n",
301 change.change_type, change.severity, change.path, change.description
302 ));
303 }
304 report.push('\n');
305 }
306
307 if !result.errors.is_empty() {
308 report.push_str("## Errors\n\n");
309 for error in &result.errors {
310 report.push_str(&format!("- **{}**: {}\n", error.path, error.message));
311 if let Some(expected) = &error.expected {
312 report.push_str(&format!(" - Expected: {}\n", expected));
313 }
314 if let Some(actual) = &error.actual {
315 report.push_str(&format!(" - Actual: {}\n", actual));
316 }
317 }
318 report.push('\n');
319 }
320
321 if !result.warnings.is_empty() {
322 report.push_str("## Warnings\n\n");
323 for warning in &result.warnings {
324 report.push_str(&format!(
325 "- **{}** ({:?}): {}\n",
326 warning.path, warning.severity, warning.message
327 ));
328 }
329 }
330
331 report
332 }
333}
334
335impl Default for ContractValidator {
336 fn default() -> Self {
337 Self::new()
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 #[test]
346 fn test_validation_result_creation() {
347 let result = ValidationResult::new();
348 assert!(result.passed);
349 assert_eq!(result.total_checks, 0);
350 assert_eq!(result.errors.len(), 0);
351 }
352
353 #[test]
354 fn test_add_error() {
355 let mut result = ValidationResult::new();
356 result.add_error(ValidationError {
357 path: "/api/test".to_string(),
358 message: "Test error".to_string(),
359 expected: None,
360 actual: None,
361 });
362
363 assert!(!result.passed);
364 assert_eq!(result.failed_checks, 1);
365 assert_eq!(result.errors.len(), 1);
366 }
367
368 #[test]
369 fn test_add_breaking_change() {
370 let mut result = ValidationResult::new();
371 result.add_breaking_change(BreakingChange {
372 change_type: BreakingChangeType::EndpointRemoved,
373 path: "/api/removed".to_string(),
374 description: "Endpoint was removed".to_string(),
375 severity: ChangeSeverity::Critical,
376 });
377
378 assert!(!result.passed);
379 assert_eq!(result.breaking_changes.len(), 1);
380 }
381
382 #[test]
383 fn test_contract_validator_creation() {
384 let validator = ContractValidator::new();
385 assert!(!validator.strict_mode);
386 assert!(!validator.ignore_optional_fields);
387 }
388
389 #[test]
390 fn test_contract_validator_with_options() {
391 let validator = ContractValidator::new()
392 .with_strict_mode(true)
393 .with_ignore_optional_fields(true);
394
395 assert!(validator.strict_mode);
396 assert!(validator.ignore_optional_fields);
397 }
398
399 #[test]
400 fn test_generate_report() {
401 let mut result = ValidationResult::new();
402 result.add_error(ValidationError {
403 path: "/api/test".to_string(),
404 message: "Test failed".to_string(),
405 expected: Some("200".to_string()),
406 actual: Some("404".to_string()),
407 });
408
409 let validator = ContractValidator::new();
410 let report = validator.generate_report(&result);
411
412 assert!(report.contains("FAILED"));
413 assert!(report.contains("/api/test"));
414 assert!(report.contains("Test failed"));
415 }
416}