Skip to main content

mockforge_core/openapi/
multi_spec.rs

1//! Multi-spec loading and merging utilities
2//!
3//! This module provides functionality to load multiple OpenAPI specifications,
4//! group them by version, detect conflicts, and merge them according to
5//! configurable strategies.
6
7use crate::openapi::spec::OpenApiSpec;
8use crate::{Error, Result};
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use tracing::{debug, info, warn};
12
13/// Conflict resolution strategy for merging specs
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ConflictStrategy {
16    /// Fail fast on conflicts (default)
17    Error,
18    /// First file wins
19    First,
20    /// Last file wins
21    Last,
22}
23
24impl From<&str> for ConflictStrategy {
25    fn from(s: &str) -> Self {
26        match s {
27            "first" => ConflictStrategy::First,
28            "last" => ConflictStrategy::Last,
29            _ => ConflictStrategy::Error,
30        }
31    }
32}
33
34/// A detected conflict between specs
35#[derive(Debug, Clone)]
36pub enum Conflict {
37    /// Route conflict: same METHOD + PATH in multiple files
38    RouteConflict {
39        /// HTTP method
40        method: String,
41        /// API path
42        path: String,
43        /// Files containing this route
44        files: Vec<PathBuf>,
45    },
46    /// Component conflict: same key with different definitions
47    ComponentConflict {
48        /// Type of component (schemas, responses, etc.)
49        component_type: String,
50        /// Component key/name
51        key: String,
52        /// Files containing this component
53        files: Vec<PathBuf>,
54    },
55}
56
57/// Error type for merge conflicts
58#[derive(Debug)]
59pub enum MergeConflictError {
60    /// Route conflict error
61    RouteConflict {
62        /// HTTP method
63        method: String,
64        /// API path
65        path: String,
66        /// Files containing this route
67        files: Vec<PathBuf>,
68    },
69    /// Component conflict error
70    ComponentConflict {
71        /// Type of component (schemas, responses, etc.)
72        component_type: String,
73        /// Component key/name
74        key: String,
75        /// Files containing this component
76        files: Vec<PathBuf>,
77    },
78    /// Multiple conflicts detected
79    MultipleConflicts {
80        /// All detected conflicts
81        conflicts: Vec<Conflict>,
82    },
83}
84
85impl std::fmt::Display for MergeConflictError {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        match self {
88            MergeConflictError::MultipleConflicts { conflicts } => {
89                writeln!(f, "Found {} spec conflict(s):\n", conflicts.len())?;
90                for (i, conflict) in conflicts.iter().enumerate() {
91                    match conflict {
92                        Conflict::RouteConflict {
93                            method,
94                            path,
95                            files,
96                        } => {
97                            writeln!(f, "  {}. {} {} defined in:", i + 1, method, path)?;
98                            for file in files {
99                                writeln!(f, "     - {}", file.display())?;
100                            }
101                        }
102                        Conflict::ComponentConflict {
103                            component_type,
104                            key,
105                            files,
106                        } => {
107                            writeln!(
108                                f,
109                                "  {}. components.{}.{} defined in:",
110                                i + 1,
111                                component_type,
112                                key
113                            )?;
114                            for file in files {
115                                writeln!(f, "     - {}", file.display())?;
116                            }
117                        }
118                    }
119                }
120                writeln!(f)?;
121                write!(
122                    f,
123                    "Resolution options:\n\
124                     - Use --merge-conflicts=first to keep the first definition\n\
125                     - Use --merge-conflicts=last to keep the last definition\n\
126                     - Remove duplicate routes/components from conflicting spec files"
127                )
128            }
129            MergeConflictError::RouteConflict {
130                method,
131                path,
132                files,
133            } => {
134                write!(
135                    f,
136                    "Conflict: {} {} defined in {}",
137                    method,
138                    path,
139                    files.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(" and ")
140                )
141            }
142            MergeConflictError::ComponentConflict {
143                component_type,
144                key,
145                files,
146            } => {
147                write!(
148                    f,
149                    "Conflict: components.{}.{} defined differently in {}",
150                    component_type,
151                    key,
152                    files.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(" and ")
153                )
154            }
155        }
156    }
157}
158
159impl std::error::Error for MergeConflictError {}
160
161/// Load all OpenAPI spec files from a directory
162///
163/// Discovers all `.json`, `.yaml`, `.yml` files recursively,
164/// sorts them lexicographically for deterministic ordering,
165/// and loads each spec.
166pub async fn load_specs_from_directory(dir: &Path) -> Result<Vec<(PathBuf, OpenApiSpec)>> {
167    use globwalk::GlobWalkerBuilder;
168
169    info!("Discovering OpenAPI specs in directory: {}", dir.display());
170
171    if !dir.exists() {
172        return Err(Error::internal(format!("Directory does not exist: {}", dir.display())));
173    }
174
175    if !dir.is_dir() {
176        return Err(Error::internal(format!("Path is not a directory: {}", dir.display())));
177    }
178
179    // Discover all spec files
180    let mut spec_files = Vec::new();
181    let walker = GlobWalkerBuilder::from_patterns(dir, &["**/*.json", "**/*.yaml", "**/*.yml"])
182        .build()
183        .map_err(|e| Error::internal(format!("Failed to walk directory: {}", e)))?;
184
185    for entry in walker {
186        let entry = entry.map_err(|e| Error::io_with_context("directory entry", e.to_string()))?;
187        let path = entry.path();
188        if path.is_file() {
189            spec_files.push(path.to_path_buf());
190        }
191    }
192
193    // Sort lexicographically for deterministic ordering
194    spec_files.sort();
195
196    if spec_files.is_empty() {
197        warn!("No OpenAPI spec files found in directory: {}", dir.display());
198        return Ok(Vec::new());
199    }
200
201    info!("Found {} spec files, loading...", spec_files.len());
202
203    // Load each spec file
204    let mut specs = Vec::new();
205    for file_path in spec_files {
206        match OpenApiSpec::from_file(&file_path).await {
207            Ok(spec) => {
208                debug!("Loaded spec from: {}", file_path.display());
209                specs.push((file_path, spec));
210            }
211            Err(e) => {
212                warn!("Failed to load spec from {}: {}", file_path.display(), e);
213                // Continue with other files
214            }
215        }
216    }
217
218    info!("Successfully loaded {} specs from directory", specs.len());
219    Ok(specs)
220}
221
222/// Load OpenAPI specs from a list of file paths
223pub async fn load_specs_from_files(files: Vec<PathBuf>) -> Result<Vec<(PathBuf, OpenApiSpec)>> {
224    info!("Loading {} OpenAPI spec files", files.len());
225
226    let mut specs = Vec::new();
227    for file_path in files {
228        match OpenApiSpec::from_file(&file_path).await {
229            Ok(spec) => {
230                debug!("Loaded spec from: {}", file_path.display());
231                specs.push((file_path, spec));
232            }
233            Err(e) => {
234                return Err(Error::internal(format!(
235                    "Failed to load spec from {}: {}",
236                    file_path.display(),
237                    e
238                )));
239            }
240        }
241    }
242
243    info!("Successfully loaded {} specs", specs.len());
244    Ok(specs)
245}
246
247/// Group specs by OpenAPI document version (the `openapi` field)
248///
249/// Returns a map from OpenAPI version (e.g., "3.0.0") to lists of (path, spec) tuples.
250pub fn group_specs_by_openapi_version(
251    specs: Vec<(PathBuf, OpenApiSpec)>,
252) -> HashMap<String, Vec<(PathBuf, OpenApiSpec)>> {
253    let mut groups: HashMap<String, Vec<(PathBuf, OpenApiSpec)>> = HashMap::new();
254
255    for (path, spec) in specs {
256        // Extract OpenAPI version from the spec
257        let version = spec
258            .raw_document
259            .as_ref()
260            .and_then(|doc| doc.get("openapi"))
261            .and_then(|v| v.as_str())
262            .map(|s| s.to_string())
263            .unwrap_or_else(|| "unknown".to_string());
264
265        groups.entry(version.clone()).or_default().push((path, spec));
266    }
267
268    info!("Grouped specs into {} OpenAPI version groups", groups.len());
269    for (version, specs_in_group) in &groups {
270        info!("  OpenAPI {}: {} specs", version, specs_in_group.len());
271    }
272
273    groups
274}
275
276/// Group specs by API version (the `info.version` field)
277///
278/// Returns a map from API version (e.g., "1.0", "2.0") to lists of (path, spec) tuples.
279/// Specs without `info.version` are grouped under "unknown".
280pub fn group_specs_by_api_version(
281    specs: Vec<(PathBuf, OpenApiSpec)>,
282) -> HashMap<String, Vec<(PathBuf, OpenApiSpec)>> {
283    let mut groups: HashMap<String, Vec<(PathBuf, OpenApiSpec)>> = HashMap::new();
284
285    for (path, spec) in specs {
286        // Extract API version from info.version
287        let api_version = spec
288            .raw_document
289            .as_ref()
290            .and_then(|doc| doc.get("info"))
291            .and_then(|info| info.get("version"))
292            .and_then(|v| v.as_str())
293            .map(|s| s.to_string())
294            .unwrap_or_else(|| "unknown".to_string());
295
296        groups.entry(api_version.clone()).or_default().push((path, spec));
297    }
298
299    info!("Grouped specs into {} API version groups", groups.len());
300    for (version, specs_in_group) in &groups {
301        info!("  API version {}: {} specs", version, specs_in_group.len());
302    }
303
304    groups
305}
306
307/// Detect conflicts between specs
308///
309/// Returns a list of all detected conflicts (route and component conflicts).
310pub fn detect_conflicts(specs: &[(PathBuf, OpenApiSpec)]) -> Vec<Conflict> {
311    let mut conflicts = Vec::new();
312
313    // Detect route conflicts (same METHOD + PATH)
314    let mut routes: HashMap<(String, String), Vec<PathBuf>> = HashMap::new();
315    for (path, spec) in specs {
316        for (route_path, path_item_ref) in &spec.spec.paths.paths {
317            if let openapiv3::ReferenceOr::Item(path_item) = path_item_ref {
318                // Check all HTTP methods
319                let methods = vec![
320                    ("GET", path_item.get.as_ref()),
321                    ("POST", path_item.post.as_ref()),
322                    ("PUT", path_item.put.as_ref()),
323                    ("DELETE", path_item.delete.as_ref()),
324                    ("PATCH", path_item.patch.as_ref()),
325                    ("HEAD", path_item.head.as_ref()),
326                    ("OPTIONS", path_item.options.as_ref()),
327                ];
328
329                for (method, operation) in methods {
330                    if operation.is_some() {
331                        let key = (method.to_string(), route_path.clone());
332                        routes.entry(key).or_default().push(path.clone());
333                    }
334                }
335            }
336        }
337    }
338
339    // Find route conflicts (same route in multiple files)
340    for ((method, route_path), files) in routes {
341        if files.len() > 1 {
342            conflicts.push(Conflict::RouteConflict {
343                method,
344                path: route_path,
345                files,
346            });
347        }
348    }
349
350    // Detect component conflicts
351    for component_type in &[
352        "schemas",
353        "parameters",
354        "responses",
355        "requestBodies",
356        "headers",
357        "examples",
358        "links",
359        "callbacks",
360    ] {
361        let mut components: HashMap<String, Vec<PathBuf>> = HashMap::new();
362
363        for (path, spec) in specs {
364            if let Some(components_obj) = spec
365                .raw_document
366                .as_ref()
367                .and_then(|doc| doc.get("components"))
368                .and_then(|c| c.get(component_type))
369            {
370                if let Some(components_map) = components_obj.as_object() {
371                    for key in components_map.keys() {
372                        components.entry(key.clone()).or_default().push(path.clone());
373                    }
374                }
375            }
376        }
377
378        // Check for conflicts (same key in multiple files with potentially different definitions)
379        for (key, files) in components {
380            if files.len() > 1 {
381                // Check if definitions are identical
382                let mut definitions = Vec::new();
383                for (file_path, spec) in specs {
384                    if files.contains(file_path) {
385                        if let Some(def) = spec
386                            .raw_document
387                            .as_ref()
388                            .and_then(|doc| doc.get("components"))
389                            .and_then(|c| c.get(component_type))
390                            .and_then(|ct| ct.get(&key))
391                        {
392                            definitions.push((file_path.clone(), def.clone()));
393                        }
394                    }
395                }
396
397                // Check if all definitions are byte-for-byte identical
398                let first_def = &definitions[0].1;
399                let all_identical = definitions.iter().all(|(_, def)| {
400                    serde_json::to_string(def).ok() == serde_json::to_string(first_def).ok()
401                });
402
403                if !all_identical {
404                    conflicts.push(Conflict::ComponentConflict {
405                        component_type: component_type.to_string(),
406                        key,
407                        files,
408                    });
409                }
410            }
411        }
412    }
413
414    conflicts
415}
416
417/// Merge multiple OpenAPI specs according to the conflict strategy
418///
419/// This function merges paths and components from all specs.
420/// Conflicts are handled according to the provided strategy.
421pub fn merge_specs(
422    specs: Vec<(PathBuf, OpenApiSpec)>,
423    conflict_strategy: ConflictStrategy,
424) -> std::result::Result<OpenApiSpec, MergeConflictError> {
425    if specs.is_empty() {
426        return Err(MergeConflictError::ComponentConflict {
427            component_type: "general".to_string(),
428            key: "no_specs".to_string(),
429            files: Vec::new(),
430        });
431    }
432
433    if specs.len() == 1 {
434        // No merging needed — safe because we just checked len() == 1
435        return specs.into_iter().next().map(|(_, spec)| spec).ok_or_else(|| {
436            MergeConflictError::ComponentConflict {
437                component_type: "general".to_string(),
438                key: "no_specs".to_string(),
439                files: Vec::new(),
440            }
441        });
442    }
443
444    // Detect conflicts first
445    let conflicts = detect_conflicts(&specs);
446
447    // Handle conflicts based on strategy
448    match conflict_strategy {
449        ConflictStrategy::Error => {
450            if !conflicts.is_empty() {
451                // Return all conflicts as an error for comprehensive feedback
452                return Err(MergeConflictError::MultipleConflicts {
453                    conflicts: conflicts.clone(),
454                });
455            }
456        }
457        ConflictStrategy::First | ConflictStrategy::Last => {
458            // Log warnings for conflicts
459            for conflict in &conflicts {
460                match conflict {
461                    Conflict::RouteConflict {
462                        method,
463                        path,
464                        files,
465                    } => {
466                        warn!(
467                            "Route conflict: {} {} defined in multiple files: {:?}. Using {} definition.",
468                            method, path, files,
469                            if conflict_strategy == ConflictStrategy::First { "first" } else { "last" }
470                        );
471                    }
472                    Conflict::ComponentConflict {
473                        component_type,
474                        key,
475                        files,
476                    } => {
477                        warn!(
478                            "Component conflict: components.{} defined in multiple files: {}. Using {} definition (strategy: {}).",
479                            component_type, key, files.iter().map(|f| f.display().to_string()).collect::<Vec<_>>().join(", "),
480                            if conflict_strategy == ConflictStrategy::First { "first" } else { "last" }
481                        );
482                    }
483                }
484            }
485        }
486    }
487
488    // Collect file paths before processing (needed for error messages)
489    let all_file_paths: Vec<PathBuf> = specs.iter().map(|(p, _)| p.clone()).collect();
490
491    // Start with the first spec as the base
492    let base_spec = specs.first().map(|(_, spec)| spec.clone()).ok_or_else(|| {
493        MergeConflictError::ComponentConflict {
494            component_type: "general".to_string(),
495            key: "no_specs".to_string(),
496            files: Vec::new(),
497        }
498    })?;
499    let mut base_doc = base_spec
500        .raw_document
501        .as_ref()
502        .cloned()
503        .unwrap_or_else(|| serde_json::json!({}));
504
505    // Skip the first spec (used as base) and merge the rest
506    let specs_to_merge: Vec<&(PathBuf, OpenApiSpec)> = specs.iter().skip(1).collect();
507
508    // Merge each subsequent spec
509    for (_file_path, spec) in specs_to_merge {
510        let spec_doc = spec.raw_document.as_ref().cloned().unwrap_or_else(|| serde_json::json!({}));
511
512        // Merge paths
513        if let Some(paths) = spec_doc.get("paths").and_then(|p| p.as_object()) {
514            if base_doc.get("paths").is_none() {
515                base_doc["paths"] = serde_json::json!({});
516            }
517            let base_paths = base_doc["paths"].as_object_mut().ok_or_else(|| {
518                MergeConflictError::ComponentConflict {
519                    component_type: "paths".to_string(),
520                    key: "invalid_type".to_string(),
521                    files: all_file_paths.clone(),
522                }
523            })?;
524            for (path, path_item) in paths {
525                if base_paths.contains_key(path) {
526                    // Conflict - handle based on strategy
527                    if conflict_strategy == ConflictStrategy::Last {
528                        base_paths.insert(path.clone(), path_item.clone());
529                    }
530                    // For First and Error, we already handled it above
531                } else {
532                    base_paths.insert(path.clone(), path_item.clone());
533                }
534            }
535        }
536
537        // Merge components
538        if let Some(components) = spec_doc.get("components").and_then(|c| c.as_object()) {
539            if base_doc.get("components").is_none() {
540                base_doc["components"] = serde_json::json!({});
541            }
542            let base_components = base_doc["components"].as_object_mut().ok_or_else(|| {
543                MergeConflictError::ComponentConflict {
544                    component_type: "components".to_string(),
545                    key: "invalid_type".to_string(),
546                    files: all_file_paths.clone(),
547                }
548            })?;
549            for (component_type, component_obj) in components {
550                if let Some(component_map) = component_obj.as_object() {
551                    let base_component_map = base_components
552                        .entry(component_type.clone())
553                        .or_insert_with(|| serde_json::json!({}))
554                        .as_object_mut()
555                        .ok_or_else(|| MergeConflictError::ComponentConflict {
556                            component_type: component_type.clone(),
557                            key: "invalid_type".to_string(),
558                            files: all_file_paths.clone(),
559                        })?;
560
561                    for (key, value) in component_map {
562                        if let Some(existing) = base_component_map.get(key) {
563                            // Check if identical
564                            if serde_json::to_string(existing).ok()
565                                != serde_json::to_string(value).ok()
566                            {
567                                // Different - handle based on strategy
568                                if conflict_strategy == ConflictStrategy::Last {
569                                    base_component_map.insert(key.clone(), value.clone());
570                                }
571                                // For First and Error, we already handled it above
572                            }
573                            // If identical, no action needed
574                        } else {
575                            base_component_map.insert(key.clone(), value.clone());
576                        }
577                    }
578                }
579            }
580        }
581    }
582
583    // Re-parse the merged document
584    let merged_spec: openapiv3::OpenAPI =
585        serde_json::from_value(base_doc.clone()).map_err(|e| {
586            MergeConflictError::ComponentConflict {
587                component_type: "parsing".to_string(),
588                key: format!("merge_error: {}", e),
589                files: all_file_paths,
590            }
591        })?;
592
593    Ok(OpenApiSpec {
594        spec: merged_spec,
595        file_path: None, // Merged spec has no single file path
596        raw_document: Some(base_doc),
597    })
598}