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::generic(format!("Directory does not exist: {}", dir.display())));
173    }
174
175    if !dir.is_dir() {
176        return Err(Error::generic(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::generic(format!("Failed to walk directory: {}", e)))?;
184
185    for entry in walker {
186        let entry =
187            entry.map_err(|e| Error::generic(format!("Failed to read directory entry: {}", e)))?;
188        let path = entry.path();
189        if path.is_file() {
190            spec_files.push(path.to_path_buf());
191        }
192    }
193
194    // Sort lexicographically for deterministic ordering
195    spec_files.sort();
196
197    if spec_files.is_empty() {
198        warn!("No OpenAPI spec files found in directory: {}", dir.display());
199        return Ok(Vec::new());
200    }
201
202    info!("Found {} spec files, loading...", spec_files.len());
203
204    // Load each spec file
205    let mut specs = Vec::new();
206    for file_path in spec_files {
207        match OpenApiSpec::from_file(&file_path).await {
208            Ok(spec) => {
209                debug!("Loaded spec from: {}", file_path.display());
210                specs.push((file_path, spec));
211            }
212            Err(e) => {
213                warn!("Failed to load spec from {}: {}", file_path.display(), e);
214                // Continue with other files
215            }
216        }
217    }
218
219    info!("Successfully loaded {} specs from directory", specs.len());
220    Ok(specs)
221}
222
223/// Load OpenAPI specs from a list of file paths
224pub async fn load_specs_from_files(files: Vec<PathBuf>) -> Result<Vec<(PathBuf, OpenApiSpec)>> {
225    info!("Loading {} OpenAPI spec files", files.len());
226
227    let mut specs = Vec::new();
228    for file_path in files {
229        match OpenApiSpec::from_file(&file_path).await {
230            Ok(spec) => {
231                debug!("Loaded spec from: {}", file_path.display());
232                specs.push((file_path, spec));
233            }
234            Err(e) => {
235                return Err(Error::generic(format!(
236                    "Failed to load spec from {}: {}",
237                    file_path.display(),
238                    e
239                )));
240            }
241        }
242    }
243
244    info!("Successfully loaded {} specs", specs.len());
245    Ok(specs)
246}
247
248/// Group specs by OpenAPI document version (the `openapi` field)
249///
250/// Returns a map from OpenAPI version (e.g., "3.0.0") to lists of (path, spec) tuples.
251pub fn group_specs_by_openapi_version(
252    specs: Vec<(PathBuf, OpenApiSpec)>,
253) -> HashMap<String, Vec<(PathBuf, OpenApiSpec)>> {
254    let mut groups: HashMap<String, Vec<(PathBuf, OpenApiSpec)>> = HashMap::new();
255
256    for (path, spec) in specs {
257        // Extract OpenAPI version from the spec
258        let version = spec
259            .raw_document
260            .as_ref()
261            .and_then(|doc| doc.get("openapi"))
262            .and_then(|v| v.as_str())
263            .map(|s| s.to_string())
264            .unwrap_or_else(|| "unknown".to_string());
265
266        groups.entry(version.clone()).or_default().push((path, spec));
267    }
268
269    info!("Grouped specs into {} OpenAPI version groups", groups.len());
270    for (version, specs_in_group) in &groups {
271        info!("  OpenAPI {}: {} specs", version, specs_in_group.len());
272    }
273
274    groups
275}
276
277/// Group specs by API version (the `info.version` field)
278///
279/// Returns a map from API version (e.g., "1.0", "2.0") to lists of (path, spec) tuples.
280/// Specs without `info.version` are grouped under "unknown".
281pub fn group_specs_by_api_version(
282    specs: Vec<(PathBuf, OpenApiSpec)>,
283) -> HashMap<String, Vec<(PathBuf, OpenApiSpec)>> {
284    let mut groups: HashMap<String, Vec<(PathBuf, OpenApiSpec)>> = HashMap::new();
285
286    for (path, spec) in specs {
287        // Extract API version from info.version
288        let api_version = spec
289            .raw_document
290            .as_ref()
291            .and_then(|doc| doc.get("info"))
292            .and_then(|info| info.get("version"))
293            .and_then(|v| v.as_str())
294            .map(|s| s.to_string())
295            .unwrap_or_else(|| "unknown".to_string());
296
297        groups.entry(api_version.clone()).or_default().push((path, spec));
298    }
299
300    info!("Grouped specs into {} API version groups", groups.len());
301    for (version, specs_in_group) in &groups {
302        info!("  API version {}: {} specs", version, specs_in_group.len());
303    }
304
305    groups
306}
307
308/// Detect conflicts between specs
309///
310/// Returns a list of all detected conflicts (route and component conflicts).
311pub fn detect_conflicts(specs: &[(PathBuf, OpenApiSpec)]) -> Vec<Conflict> {
312    let mut conflicts = Vec::new();
313
314    // Detect route conflicts (same METHOD + PATH)
315    let mut routes: HashMap<(String, String), Vec<PathBuf>> = HashMap::new();
316    for (path, spec) in specs {
317        for (route_path, path_item_ref) in &spec.spec.paths.paths {
318            if let openapiv3::ReferenceOr::Item(path_item) = path_item_ref {
319                // Check all HTTP methods
320                let methods = vec![
321                    ("GET", path_item.get.as_ref()),
322                    ("POST", path_item.post.as_ref()),
323                    ("PUT", path_item.put.as_ref()),
324                    ("DELETE", path_item.delete.as_ref()),
325                    ("PATCH", path_item.patch.as_ref()),
326                    ("HEAD", path_item.head.as_ref()),
327                    ("OPTIONS", path_item.options.as_ref()),
328                ];
329
330                for (method, operation) in methods {
331                    if operation.is_some() {
332                        let key = (method.to_string(), route_path.clone());
333                        routes.entry(key).or_default().push(path.clone());
334                    }
335                }
336            }
337        }
338    }
339
340    // Find route conflicts (same route in multiple files)
341    for ((method, route_path), files) in routes {
342        if files.len() > 1 {
343            conflicts.push(Conflict::RouteConflict {
344                method,
345                path: route_path,
346                files,
347            });
348        }
349    }
350
351    // Detect component conflicts
352    for component_type in &[
353        "schemas",
354        "parameters",
355        "responses",
356        "requestBodies",
357        "headers",
358        "examples",
359        "links",
360        "callbacks",
361    ] {
362        let mut components: HashMap<String, Vec<PathBuf>> = HashMap::new();
363
364        for (path, spec) in specs {
365            if let Some(components_obj) = spec
366                .raw_document
367                .as_ref()
368                .and_then(|doc| doc.get("components"))
369                .and_then(|c| c.get(component_type))
370            {
371                if let Some(components_map) = components_obj.as_object() {
372                    for key in components_map.keys() {
373                        components.entry(key.clone()).or_default().push(path.clone());
374                    }
375                }
376            }
377        }
378
379        // Check for conflicts (same key in multiple files with potentially different definitions)
380        for (key, files) in components {
381            if files.len() > 1 {
382                // Check if definitions are identical
383                let mut definitions = Vec::new();
384                for (file_path, spec) in specs {
385                    if files.contains(file_path) {
386                        if let Some(def) = spec
387                            .raw_document
388                            .as_ref()
389                            .and_then(|doc| doc.get("components"))
390                            .and_then(|c| c.get(component_type))
391                            .and_then(|ct| ct.get(&key))
392                        {
393                            definitions.push((file_path.clone(), def.clone()));
394                        }
395                    }
396                }
397
398                // Check if all definitions are byte-for-byte identical
399                let first_def = &definitions[0].1;
400                let all_identical = definitions.iter().all(|(_, def)| {
401                    serde_json::to_string(def).ok() == serde_json::to_string(first_def).ok()
402                });
403
404                if !all_identical {
405                    conflicts.push(Conflict::ComponentConflict {
406                        component_type: component_type.to_string(),
407                        key,
408                        files,
409                    });
410                }
411            }
412        }
413    }
414
415    conflicts
416}
417
418/// Merge multiple OpenAPI specs according to the conflict strategy
419///
420/// This function merges paths and components from all specs.
421/// Conflicts are handled according to the provided strategy.
422pub fn merge_specs(
423    specs: Vec<(PathBuf, OpenApiSpec)>,
424    conflict_strategy: ConflictStrategy,
425) -> std::result::Result<OpenApiSpec, MergeConflictError> {
426    if specs.is_empty() {
427        return Err(MergeConflictError::ComponentConflict {
428            component_type: "general".to_string(),
429            key: "no_specs".to_string(),
430            files: Vec::new(),
431        });
432    }
433
434    if specs.len() == 1 {
435        // No merging needed — safe because we just checked len() == 1
436        return specs.into_iter().next().map(|(_, spec)| spec).ok_or_else(|| {
437            MergeConflictError::ComponentConflict {
438                component_type: "general".to_string(),
439                key: "no_specs".to_string(),
440                files: Vec::new(),
441            }
442        });
443    }
444
445    // Detect conflicts first
446    let conflicts = detect_conflicts(&specs);
447
448    // Handle conflicts based on strategy
449    match conflict_strategy {
450        ConflictStrategy::Error => {
451            if !conflicts.is_empty() {
452                // Return all conflicts as an error for comprehensive feedback
453                return Err(MergeConflictError::MultipleConflicts {
454                    conflicts: conflicts.clone(),
455                });
456            }
457        }
458        ConflictStrategy::First | ConflictStrategy::Last => {
459            // Log warnings for conflicts
460            for conflict in &conflicts {
461                match conflict {
462                    Conflict::RouteConflict {
463                        method,
464                        path,
465                        files,
466                    } => {
467                        warn!(
468                            "Route conflict: {} {} defined in multiple files: {:?}. Using {} definition.",
469                            method, path, files,
470                            if conflict_strategy == ConflictStrategy::First { "first" } else { "last" }
471                        );
472                    }
473                    Conflict::ComponentConflict {
474                        component_type,
475                        key,
476                        files,
477                    } => {
478                        warn!(
479                            "Component conflict: components.{} defined in multiple files: {}. Using {} definition (strategy: {}).",
480                            component_type, key, files.iter().map(|f| f.display().to_string()).collect::<Vec<_>>().join(", "),
481                            if conflict_strategy == ConflictStrategy::First { "first" } else { "last" }
482                        );
483                    }
484                }
485            }
486        }
487    }
488
489    // Collect file paths before processing (needed for error messages)
490    let all_file_paths: Vec<PathBuf> = specs.iter().map(|(p, _)| p.clone()).collect();
491
492    // Start with the first spec as the base
493    let base_spec = specs.first().map(|(_, spec)| spec.clone()).ok_or_else(|| {
494        MergeConflictError::ComponentConflict {
495            component_type: "general".to_string(),
496            key: "no_specs".to_string(),
497            files: Vec::new(),
498        }
499    })?;
500    let mut base_doc = base_spec
501        .raw_document
502        .as_ref()
503        .cloned()
504        .unwrap_or_else(|| serde_json::json!({}));
505
506    // Skip the first spec (used as base) and merge the rest
507    let specs_to_merge: Vec<&(PathBuf, OpenApiSpec)> = specs.iter().skip(1).collect();
508
509    // Merge each subsequent spec
510    for (_file_path, spec) in specs_to_merge {
511        let spec_doc = spec.raw_document.as_ref().cloned().unwrap_or_else(|| serde_json::json!({}));
512
513        // Merge paths
514        if let Some(paths) = spec_doc.get("paths").and_then(|p| p.as_object()) {
515            if base_doc.get("paths").is_none() {
516                base_doc["paths"] = serde_json::json!({});
517            }
518            let base_paths = base_doc["paths"].as_object_mut().ok_or_else(|| {
519                MergeConflictError::ComponentConflict {
520                    component_type: "paths".to_string(),
521                    key: "invalid_type".to_string(),
522                    files: all_file_paths.clone(),
523                }
524            })?;
525            for (path, path_item) in paths {
526                if base_paths.contains_key(path) {
527                    // Conflict - handle based on strategy
528                    if conflict_strategy == ConflictStrategy::Last {
529                        base_paths.insert(path.clone(), path_item.clone());
530                    }
531                    // For First and Error, we already handled it above
532                } else {
533                    base_paths.insert(path.clone(), path_item.clone());
534                }
535            }
536        }
537
538        // Merge components
539        if let Some(components) = spec_doc.get("components").and_then(|c| c.as_object()) {
540            if base_doc.get("components").is_none() {
541                base_doc["components"] = serde_json::json!({});
542            }
543            let base_components = base_doc["components"].as_object_mut().ok_or_else(|| {
544                MergeConflictError::ComponentConflict {
545                    component_type: "components".to_string(),
546                    key: "invalid_type".to_string(),
547                    files: all_file_paths.clone(),
548                }
549            })?;
550            for (component_type, component_obj) in components {
551                if let Some(component_map) = component_obj.as_object() {
552                    let base_component_map = base_components
553                        .entry(component_type.clone())
554                        .or_insert_with(|| serde_json::json!({}))
555                        .as_object_mut()
556                        .ok_or_else(|| MergeConflictError::ComponentConflict {
557                            component_type: component_type.clone(),
558                            key: "invalid_type".to_string(),
559                            files: all_file_paths.clone(),
560                        })?;
561
562                    for (key, value) in component_map {
563                        if let Some(existing) = base_component_map.get(key) {
564                            // Check if identical
565                            if serde_json::to_string(existing).ok()
566                                != serde_json::to_string(value).ok()
567                            {
568                                // Different - handle based on strategy
569                                if conflict_strategy == ConflictStrategy::Last {
570                                    base_component_map.insert(key.clone(), value.clone());
571                                }
572                                // For First and Error, we already handled it above
573                            }
574                            // If identical, no action needed
575                        } else {
576                            base_component_map.insert(key.clone(), value.clone());
577                        }
578                    }
579                }
580            }
581        }
582    }
583
584    // Re-parse the merged document
585    let merged_spec: openapiv3::OpenAPI =
586        serde_json::from_value(base_doc.clone()).map_err(|e| {
587            MergeConflictError::ComponentConflict {
588                component_type: "parsing".to_string(),
589                key: format!("merge_error: {}", e),
590                files: all_file_paths,
591            }
592        })?;
593
594    Ok(OpenApiSpec {
595        spec: merged_spec,
596        file_path: None, // Merged spec has no single file path
597        raw_document: Some(base_doc),
598    })
599}