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