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