rusty_schema_diff/analyzer/
openapi.rs

1//! OpenAPI specific analyzer implementation
2//!
3//! This module provides functionality for analyzing OpenAPI changes and
4//! generating compatibility reports and migration paths.
5
6use 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
14/// Analyzes OpenAPI changes and generates compatibility reports.
15pub struct OpenApiAnalyzer;
16
17impl SchemaAnalyzer for OpenApiAnalyzer {
18    /// Analyzes compatibility between two OpenAPI versions.
19    ///
20    /// # Arguments
21    ///
22    /// * `old` - The original OpenAPI version.
23    /// * `new` - The new OpenAPI version to compare against.
24    ///
25    /// # Returns
26    ///
27    /// A `CompatibilityReport` detailing the differences and compatibility status.
28    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        // Compare paths
38        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        // Check for new paths
59        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        // Compare versions
76        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    /// Generates a migration path between OpenAPI versions.
100    ///
101    /// # Arguments
102    ///
103    /// * `old` - The source OpenAPI version.
104    /// * `new` - The target OpenAPI version.
105    ///
106    /// # Returns
107    ///
108    /// A `MigrationPlan` detailing the required changes.
109    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    /// Parses OpenAPI content
139    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    /// Compares two OpenAPI specifications
145    fn compare_apis(&self, old: &OpenAPI, new: &OpenAPI, changes: &mut Vec<SchemaChange>) -> Result<()> {
146        // Compare paths
147        self.compare_paths(old, new, changes);
148        // Compare components
149        self.compare_components(old, new, changes);
150        // Compare security schemes
151        self.compare_security(old, new, changes);
152        
153        Ok(())
154    }
155
156    /// Compares API paths
157    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    /// Compares API operations
187    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        // Compare HTTP methods
195        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    /// Gets operation for a specific HTTP method
228    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    /// Calculates compatibility score
256    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    /// Builds validation context for changes
299    fn build_validation_context(&self, changes: &[SchemaChange]) -> HashMap<String, String> {
300        let mut context = HashMap::new();
301        
302        // Count changes by type
303        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    /// Compares components between OpenAPI versions
327    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            // Compare schemas
335            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            // Check for new schemas
359            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    /// Compares security schemes
373    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            // Compare security schemes
381            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    /// Compares operation details
407    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        // Compare parameters
416        self.compare_parameters(path, method, &old_op.parameters, &new_op.parameters, changes);
417
418        // Compare request body
419        self.compare_request_bodies(path, method, &old_op.request_body, &new_op.request_body, changes);
420
421        // Compare responses
422        self.compare_responses(path, method, &old_op.responses, &new_op.responses, changes);
423    }
424
425    /// Compares operation parameters
426    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, .. } => &parameter_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, .. } => &parameter_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    /// Compares request bodies
490    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    /// Compares operation responses
530    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        // Compare responses
539        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        // Check for new responses
563        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                // Handle reference cases if needed
588            }
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                // Check if this is a parameter becoming required or type change
601                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;