1use openapiv3::{OpenAPI, ReferenceOr, Parameter, RequestBody, Responses};
7use crate::analyzer::{SchemaAnalyzer, SchemaChange, ChangeType};
8use crate::report::{CompatibilityIssue, IssueSeverity, ValidationError};
9use crate::{Schema, CompatibilityReport, MigrationPlan, ValidationResult};
10use crate::error::Result;
11use std::collections::HashMap;
12use crate::error::SchemaDiffError;
13
14pub struct OpenApiAnalyzer;
16
17impl SchemaAnalyzer for OpenApiAnalyzer {
18 fn analyze_compatibility(&self, old: &Schema, new: &Schema) -> Result<CompatibilityReport> {
29 let mut changes = Vec::new();
30 let mut metadata = HashMap::new();
31
32 let old_spec: OpenAPI = serde_yaml::from_str(&old.content)
33 .map_err(|e| SchemaDiffError::ParseError(format!("Failed to parse OpenAPI: {}", e)))?;
34 let new_spec: OpenAPI = serde_yaml::from_str(&new.content)
35 .map_err(|e| SchemaDiffError::ParseError(format!("Failed to parse OpenAPI: {}", e)))?;
36
37 for (path, old_path_item) in old_spec.paths.paths.iter() {
39 if let ReferenceOr::Item(old_item) = old_path_item {
40 if let Some(new_path_item) = new_spec.paths.paths.get(path) {
41 if let ReferenceOr::Item(new_item) = new_path_item {
42 self.compare_operations(path, old_item, new_item, &mut changes);
43 }
44 } else {
45 let mut metadata = HashMap::new();
46 metadata.insert("path".to_string(), path.to_string());
47
48 changes.push(SchemaChange::new(
49 ChangeType::Removal,
50 format!("paths/{}", path),
51 format!("Path '{}' was removed", path),
52 metadata,
53 ));
54 }
55 }
56 }
57
58 for (path, new_path_item) in new_spec.paths.paths.iter() {
60 if let ReferenceOr::Item(_) = new_path_item {
61 if !old_spec.paths.paths.contains_key(path) {
62 let mut metadata = HashMap::new();
63 metadata.insert("path".to_string(), path.to_string());
64
65 changes.push(SchemaChange::new(
66 ChangeType::Addition,
67 format!("paths/{}", path),
68 format!("New path '{}' was added", path),
69 metadata,
70 ));
71 }
72 }
73 }
74
75 metadata.insert("new_version".to_string(), new_spec.info.version.to_string());
77 metadata.insert("old_version".to_string(), old_spec.info.version.to_string());
78
79 let compatibility_score = self.calculate_compatibility_score(&changes);
80 let validation_result = self.validate_changes(&changes)?;
81
82 Ok(CompatibilityReport {
83 changes,
84 compatibility_score: compatibility_score as u8,
85 is_compatible: compatibility_score >= 80,
86 metadata,
87 issues: validation_result.errors.into_iter().map(|err| CompatibilityIssue {
88 severity: match err.code.as_str() {
89 "API001" => IssueSeverity::Error,
90 "API002" => IssueSeverity::Warning,
91 _ => IssueSeverity::Info,
92 },
93 description: err.message,
94 location: err.path.clone(),
95 }).collect(),
96 })
97 }
98
99 fn generate_migration_path(&self, old: &Schema, new: &Schema) -> Result<MigrationPlan> {
110 let old_api = self.parse_openapi(&old.content)?;
111 let new_api = self.parse_openapi(&new.content)?;
112
113 let mut changes = Vec::new();
114 self.compare_apis(&old_api, &new_api, &mut changes)?;
115
116 Ok(MigrationPlan::new(
117 old.version.to_string(),
118 new.version.to_string(),
119 changes,
120 ))
121 }
122
123 fn validate_changes(&self, changes: &[SchemaChange]) -> Result<ValidationResult> {
124 let errors = changes
125 .iter()
126 .filter_map::<ValidationError, _>(|change| self.validate_change(change))
127 .collect::<Vec<ValidationError>>();
128
129 Ok(ValidationResult {
130 is_valid: errors.is_empty(),
131 errors,
132 context: self.build_validation_context(changes),
133 })
134 }
135}
136
137impl OpenApiAnalyzer {
138 fn parse_openapi(&self, content: &str) -> Result<OpenAPI> {
140 serde_json::from_str(content)
141 .map_err(|e| SchemaDiffError::ParseError(format!("Failed to parse OpenAPI: {}", e)))
142 }
143
144 fn compare_apis(&self, old: &OpenAPI, new: &OpenAPI, changes: &mut Vec<SchemaChange>) -> Result<()> {
146 self.compare_paths(old, new, changes);
148 self.compare_components(old, new, changes);
150 self.compare_security(old, new, changes);
152
153 Ok(())
154 }
155
156 fn compare_paths(&self, old: &OpenAPI, new: &OpenAPI, changes: &mut Vec<SchemaChange>) {
158 for (path, old_item) in old.paths.paths.iter() {
159 match new.paths.paths.get(path) {
160 Some(new_item) => {
161 self.compare_path_items(path, old_item, new_item, changes);
162 }
163 None => {
164 changes.push(SchemaChange::new(
165 ChangeType::Removal,
166 format!("/paths/{}", path),
167 format!("Removed path: {}", path),
168 HashMap::new(),
169 ));
170 }
171 }
172 }
173
174 for path in new.paths.paths.keys() {
175 if !old.paths.paths.contains_key(path) {
176 changes.push(SchemaChange::new(
177 ChangeType::Addition,
178 format!("/paths/{}", path),
179 format!("Added path: {}", path),
180 HashMap::new(),
181 ));
182 }
183 }
184 }
185
186 fn compare_operations(
188 &self,
189 path: &str,
190 old_item: &openapiv3::PathItem,
191 new_item: &openapiv3::PathItem,
192 changes: &mut Vec<SchemaChange>,
193 ) {
194 let methods = ["get", "post", "put", "delete", "patch", "head", "options"];
196
197 for method in methods.iter() {
198 let old_op = self.get_operation(old_item, method);
199 let new_op = self.get_operation(new_item, method);
200
201 match (old_op, new_op) {
202 (Some(old_op), Some(new_op)) => {
203 self.compare_parameters(path, method, &old_op.parameters, &new_op.parameters, changes);
204 self.compare_operation_details(path, method, old_op, new_op, changes);
205 }
206 (Some(_), None) => {
207 changes.push(SchemaChange::new(
208 ChangeType::Removal,
209 format!("/paths{}/{}", path, method),
210 format!("HTTP method '{}' was removed from '{}'", method, path),
211 HashMap::new()
212 ));
213 }
214 (None, Some(_)) => {
215 changes.push(SchemaChange::new(
216 ChangeType::Addition,
217 format!("/paths{}/{}", path, method),
218 format!("HTTP method '{}' was added to '{}'", method, path),
219 HashMap::new(),
220 ));
221 }
222 (None, None) => {}
223 }
224 }
225 }
226
227 fn get_operation<'a>(
229 &self,
230 item: &'a openapiv3::PathItem,
231 method: &str,
232 ) -> Option<&'a openapiv3::Operation> {
233 match method {
234 "get" => item.get.as_ref(),
235 "post" => item.post.as_ref(),
236 "put" => item.put.as_ref(),
237 "delete" => item.delete.as_ref(),
238 "patch" => item.patch.as_ref(),
239 "head" => item.head.as_ref(),
240 "options" => item.options.as_ref(),
241 _ => None,
242 }
243 }
244
245 #[allow(dead_code)]
246 fn extract_metadata(&self, old: &OpenAPI, new: &OpenAPI) -> HashMap<String, String> {
247 let mut metadata = HashMap::new();
248
249 metadata.insert("new_version".to_string(), new.info.version.to_string());
250 metadata.insert("old_version".to_string(), old.info.version.to_string());
251
252 metadata
253 }
254
255 fn calculate_compatibility_score(&self, changes: &[SchemaChange]) -> i32 {
257 let base_score: i32 = 100;
258 let mut deductions: i32 = 0;
259
260 for change in changes {
261 match change.change_type {
262 ChangeType::Addition => deductions += 5,
263 ChangeType::Removal => deductions += 20,
264 ChangeType::Modification => {
265 if change.description.contains("optional to required") {
266 deductions += 25;
267 } else {
268 deductions += 10;
269 }
270 }
271 ChangeType::Rename => deductions += 8,
272 }
273 }
274
275 base_score.saturating_sub(deductions)
276 }
277
278 #[allow(dead_code)]
279 fn detect_issues(&self, changes: &[SchemaChange]) -> Vec<CompatibilityIssue> {
280 changes.iter()
281 .filter_map(|change| {
282 let severity = match change.change_type {
283 ChangeType::Removal => IssueSeverity::Error,
284 ChangeType::Modification => IssueSeverity::Warning,
285 ChangeType::Rename => IssueSeverity::Info,
286 ChangeType::Addition => IssueSeverity::Info,
287 };
288
289 Some(CompatibilityIssue {
290 severity,
291 description: change.description.clone(),
292 location: change.location.clone(),
293 })
294 })
295 .collect()
296 }
297
298 fn build_validation_context(&self, changes: &[SchemaChange]) -> HashMap<String, String> {
300 let mut context = HashMap::new();
301
302 let mut additions = 0;
304 let mut removals = 0;
305 let mut modifications = 0;
306 let mut renames = 0;
307
308 for change in changes {
309 match change.change_type {
310 ChangeType::Addition => additions += 1,
311 ChangeType::Removal => removals += 1,
312 ChangeType::Modification => modifications += 1,
313 ChangeType::Rename => renames += 1,
314 }
315 }
316
317 context.insert("additions".to_string(), additions.to_string());
318 context.insert("removals".to_string(), removals.to_string());
319 context.insert("modifications".to_string(), modifications.to_string());
320 context.insert("renames".to_string(), renames.to_string());
321 context.insert("total_changes".to_string(), changes.len().to_string());
322
323 context
324 }
325
326 fn compare_components(
328 &self,
329 old: &OpenAPI,
330 new: &OpenAPI,
331 changes: &mut Vec<SchemaChange>,
332 ) {
333 if let (Some(old_components), Some(new_components)) = (&old.components, &new.components) {
334 for (name, old_schema) in &old_components.schemas {
336 match new_components.schemas.get(name) {
337 Some(new_schema) => {
338 if old_schema != new_schema {
339 changes.push(SchemaChange::new(
340 ChangeType::Modification,
341 format!("/components/schemas/{}", name),
342 format!("Schema '{}' was modified", name),
343 HashMap::new(),
344 ));
345 }
346 }
347 None => {
348 changes.push(SchemaChange::new(
349 ChangeType::Removal,
350 format!("/components/schemas/{}", name),
351 format!("Schema '{}' was removed", name),
352 HashMap::new(),
353 ));
354 }
355 }
356 }
357
358 for name in new_components.schemas.keys() {
360 if !old_components.schemas.contains_key(name) {
361 changes.push(SchemaChange::new(
362 ChangeType::Addition,
363 format!("/components/schemas/{}", name),
364 format!("Schema '{}' was added", name),
365 HashMap::new(),
366 ));
367 }
368 }
369 }
370 }
371
372 fn compare_security(
374 &self,
375 old: &OpenAPI,
376 new: &OpenAPI,
377 changes: &mut Vec<SchemaChange>,
378 ) {
379 if let (Some(old_components), Some(new_components)) = (&old.components, &new.components) {
380 for (name, old_scheme) in &old_components.security_schemes {
382 match new_components.security_schemes.get(name) {
383 Some(new_scheme) => {
384 if old_scheme != new_scheme {
385 changes.push(SchemaChange::new(
386 ChangeType::Modification,
387 format!("/components/securitySchemes/{}", name),
388 format!("Security scheme '{}' was modified", name),
389 HashMap::new(),
390 ));
391 }
392 }
393 None => {
394 changes.push(SchemaChange::new(
395 ChangeType::Removal,
396 format!("/components/securitySchemes/{}", name),
397 format!("Security scheme '{}' was removed", name),
398 HashMap::new(),
399 ));
400 }
401 }
402 }
403 }
404 }
405
406 fn compare_operation_details(
408 &self,
409 path: &str,
410 method: &str,
411 old_op: &openapiv3::Operation,
412 new_op: &openapiv3::Operation,
413 changes: &mut Vec<SchemaChange>,
414 ) {
415 self.compare_parameters(path, method, &old_op.parameters, &new_op.parameters, changes);
417
418 self.compare_request_bodies(path, method, &old_op.request_body, &new_op.request_body, changes);
420
421 self.compare_responses(path, method, &old_op.responses, &new_op.responses, changes);
423 }
424
425 fn compare_parameters(
427 &self,
428 path: &str,
429 method: &str,
430 old_params: &[ReferenceOr<Parameter>],
431 new_params: &[ReferenceOr<Parameter>],
432 changes: &mut Vec<SchemaChange>,
433 ) {
434 for old_param in old_params {
435 if let ReferenceOr::Item(old_param) = old_param {
436 let param_name = match old_param {
437 Parameter::Path { parameter_data, .. } |
438 Parameter::Query { parameter_data, .. } |
439 Parameter::Header { parameter_data, .. } |
440 Parameter::Cookie { parameter_data, .. } => ¶meter_data.name,
441 };
442
443 if let Some(new_param) = new_params.iter().find(|p| {
444 if let ReferenceOr::Item(p) = p {
445 match p {
446 Parameter::Path { parameter_data, .. } |
447 Parameter::Query { parameter_data, .. } |
448 Parameter::Header { parameter_data, .. } |
449 Parameter::Cookie { parameter_data, .. } => ¶meter_data.name == param_name
450 }
451 } else {
452 false
453 }
454 }) {
455 if let ReferenceOr::Item(new_param) = new_param {
456 let old_required = match old_param {
457 Parameter::Path { parameter_data, .. } |
458 Parameter::Query { parameter_data, .. } |
459 Parameter::Header { parameter_data, .. } |
460 Parameter::Cookie { parameter_data, .. } => parameter_data.required,
461 };
462
463 let new_required = match new_param {
464 Parameter::Path { parameter_data, .. } |
465 Parameter::Query { parameter_data, .. } |
466 Parameter::Header { parameter_data, .. } |
467 Parameter::Cookie { parameter_data, .. } => parameter_data.required,
468 };
469
470 if !old_required && new_required {
471 let mut metadata = HashMap::new();
472 metadata.insert("path".to_string(), path.to_string());
473 metadata.insert("method".to_string(), method.to_string());
474 metadata.insert("parameter".to_string(), param_name.to_string());
475
476 changes.push(SchemaChange::new(
477 ChangeType::Modification,
478 format!("paths/{}/{}/parameters/{}", path, method, param_name),
479 format!("Parameter '{}' changed from optional to required", param_name),
480 metadata,
481 ));
482 }
483 }
484 }
485 }
486 }
487 }
488
489 fn compare_request_bodies(
491 &self,
492 path: &str,
493 method: &str,
494 old_body: &Option<ReferenceOr<RequestBody>>,
495 new_body: &Option<ReferenceOr<RequestBody>>,
496 changes: &mut Vec<SchemaChange>,
497 ) {
498 match (old_body, new_body) {
499 (Some(_), None) => {
500 changes.push(SchemaChange::new(
501 ChangeType::Removal,
502 format!("/paths{}/{}/requestBody", path, method),
503 "Request body was removed".to_string(),
504 HashMap::new(),
505 ));
506 }
507 (None, Some(_)) => {
508 changes.push(SchemaChange::new(
509 ChangeType::Addition,
510 format!("/paths{}/{}/requestBody", path, method),
511 "Request body was added".to_string(),
512 HashMap::new(),
513 ));
514 }
515 (Some(old_body), Some(new_body)) => {
516 if old_body != new_body {
517 changes.push(SchemaChange::new(
518 ChangeType::Modification,
519 format!("/paths{}/{}/requestBody", path, method),
520 "Request body was modified".to_string(),
521 HashMap::new(),
522 ));
523 }
524 }
525 (None, None) => {}
526 }
527 }
528
529 fn compare_responses(
531 &self,
532 path: &str,
533 method: &str,
534 old_responses: &Responses,
535 new_responses: &Responses,
536 changes: &mut Vec<SchemaChange>,
537 ) {
538 for (status, old_response) in &old_responses.responses {
540 match new_responses.responses.get(status) {
541 Some(new_response) => {
542 if old_response != new_response {
543 changes.push(SchemaChange::new(
544 ChangeType::Modification,
545 format!("/paths{}/{}/responses/{}", path, method, status),
546 format!("Response '{}' was modified", status),
547 HashMap::new(),
548 ));
549 }
550 }
551 None => {
552 changes.push(SchemaChange::new(
553 ChangeType::Removal,
554 format!("/paths{}/{}/responses/{}", path, method, status),
555 format!("Response '{}' was removed", status),
556 HashMap::new(),
557 ));
558 }
559 }
560 }
561
562 for status in new_responses.responses.keys() {
564 if !old_responses.responses.contains_key(status) {
565 changes.push(SchemaChange::new(
566 ChangeType::Addition,
567 format!("/paths{}/{}/responses/{}", path, method, status),
568 format!("Response '{}' was added", status),
569 HashMap::new(),
570 ));
571 }
572 }
573 }
574
575 fn compare_path_items(
576 &self,
577 path: &str,
578 old_item: &ReferenceOr<openapiv3::PathItem>,
579 new_item: &ReferenceOr<openapiv3::PathItem>,
580 changes: &mut Vec<SchemaChange>
581 ) {
582 match (old_item, new_item) {
583 (ReferenceOr::Item(old_item), ReferenceOr::Item(new_item)) => {
584 self.compare_operations(path, old_item, new_item, changes);
585 }
586 _ => {
587 }
589 }
590 }
591
592 fn validate_change(&self, change: &SchemaChange) -> Option<ValidationError> {
593 match change.change_type {
594 ChangeType::Removal => Some(ValidationError {
595 message: format!("Breaking change: {}", change.description),
596 path: change.location.clone(),
597 code: "API001".to_string(),
598 }),
599 ChangeType::Modification => {
600 if (change.location.contains("parameters") && change.description.contains("required")) ||
602 (change.location.contains("schema") && change.description.contains("type")) {
603 Some(ValidationError {
604 message: format!("Breaking change: {}", change.description),
605 path: change.location.clone(),
606 code: "API002".to_string(),
607 })
608 } else {
609 None
610 }
611 },
612 _ => None
613 }
614 }
615}
616
617#[cfg(test)]
618mod tests;