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_insert_with(Vec::new).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_insert_with(Vec::new).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_insert_with(Vec::new).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_insert_with(Vec::new).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
436        return Ok(specs.into_iter().next().unwrap().1);
437    }
438
439    // Detect conflicts first
440    let conflicts = detect_conflicts(&specs);
441
442    // Handle conflicts based on strategy
443    match conflict_strategy {
444        ConflictStrategy::Error => {
445            if !conflicts.is_empty() {
446                // Return all conflicts as an error for comprehensive feedback
447                return Err(MergeConflictError::MultipleConflicts {
448                    conflicts: conflicts.clone(),
449                });
450            }
451        }
452        ConflictStrategy::First | ConflictStrategy::Last => {
453            // Log warnings for conflicts
454            for conflict in &conflicts {
455                match conflict {
456                    Conflict::RouteConflict {
457                        method,
458                        path,
459                        files,
460                    } => {
461                        warn!(
462                            "Route conflict: {} {} defined in multiple files: {:?}. Using {} definition.",
463                            method, path, files,
464                            if conflict_strategy == ConflictStrategy::First { "first" } else { "last" }
465                        );
466                    }
467                    Conflict::ComponentConflict {
468                        component_type,
469                        key,
470                        files,
471                    } => {
472                        warn!(
473                            "Component conflict: components.{} defined in multiple files: {}. Using {} definition (strategy: {}).",
474                            component_type, key, files.iter().map(|f| f.display().to_string()).collect::<Vec<_>>().join(", "),
475                            if conflict_strategy == ConflictStrategy::First { "first" } else { "last" }
476                        );
477                    }
478                }
479            }
480        }
481    }
482
483    // Collect file paths before processing (needed for error messages)
484    let all_file_paths: Vec<PathBuf> = specs.iter().map(|(p, _)| p.clone()).collect();
485
486    // Start with the first spec as the base
487    let base_spec = specs[0].1.clone();
488    #[allow(unused_mut)]
489    let mut base_doc = base_spec
490        .raw_document
491        .as_ref()
492        .cloned()
493        .unwrap_or_else(|| serde_json::json!({}));
494
495    // Determine iteration order based on strategy
496    let specs_to_merge: Vec<&(PathBuf, OpenApiSpec)> =
497        if conflict_strategy == ConflictStrategy::Last {
498            specs.iter().skip(1).collect()
499        } else {
500            specs.iter().skip(1).collect()
501        };
502
503    // Merge each subsequent spec
504    for (_file_path, spec) in specs_to_merge {
505        let spec_doc = spec.raw_document.as_ref().cloned().unwrap_or_else(|| serde_json::json!({}));
506
507        // Merge paths
508        if let Some(paths) = spec_doc.get("paths").and_then(|p| p.as_object()) {
509            if base_doc.get("paths").is_none() {
510                base_doc["paths"] = serde_json::json!({});
511            }
512            let base_paths = base_doc["paths"].as_object_mut().expect("paths must be an object");
513            for (path, path_item) in paths {
514                if base_paths.contains_key(path) {
515                    // Conflict - handle based on strategy
516                    if conflict_strategy == ConflictStrategy::Last {
517                        base_paths.insert(path.clone(), path_item.clone());
518                    }
519                    // For First and Error, we already handled it above
520                } else {
521                    base_paths.insert(path.clone(), path_item.clone());
522                }
523            }
524        }
525
526        // Merge components
527        if let Some(components) = spec_doc.get("components").and_then(|c| c.as_object()) {
528            if base_doc.get("components").is_none() {
529                base_doc["components"] = serde_json::json!({});
530            }
531            let base_components =
532                base_doc["components"].as_object_mut().expect("components must be an object");
533            for (component_type, component_obj) in components {
534                if let Some(component_map) = component_obj.as_object() {
535                    let base_component_map = base_components
536                        .entry(component_type.clone())
537                        .or_insert_with(|| serde_json::json!({}))
538                        .as_object_mut()
539                        .expect("component type must be an object");
540
541                    for (key, value) in component_map {
542                        if base_component_map.contains_key(key) {
543                            // Check if identical
544                            let existing = base_component_map
545                                .get(key)
546                                .expect("key must exist after contains_key check");
547                            if serde_json::to_string(existing).ok()
548                                != serde_json::to_string(value).ok()
549                            {
550                                // Different - handle based on strategy
551                                if conflict_strategy == ConflictStrategy::Last {
552                                    base_component_map.insert(key.clone(), value.clone());
553                                }
554                                // For First and Error, we already handled it above
555                            }
556                            // If identical, no action needed
557                        } else {
558                            base_component_map.insert(key.clone(), value.clone());
559                        }
560                    }
561                }
562            }
563        }
564    }
565
566    // Re-parse the merged document
567    let merged_spec: openapiv3::OpenAPI =
568        serde_json::from_value(base_doc.clone()).map_err(|e| {
569            MergeConflictError::ComponentConflict {
570                component_type: "parsing".to_string(),
571                key: format!("merge_error: {}", e),
572                files: all_file_paths,
573            }
574        })?;
575
576    Ok(OpenApiSpec {
577        spec: merged_spec,
578        file_path: None, // Merged spec has no single file path
579        raw_document: Some(base_doc),
580    })
581}