oas_forge/
lib.rs

1#![doc = include_str!("../README.md")]
2#![allow(clippy::collapsible_if)]
3pub mod config;
4pub mod error;
5pub mod generics;
6pub mod index;
7pub mod merger;
8pub mod preprocessor;
9pub mod scanner;
10pub mod visitor;
11
12use config::Config;
13use error::Result;
14use std::path::PathBuf;
15
16/// Main entry point for generating OpenAPI definitions.
17/// Main entry point for generating OpenAPI definitions.
18#[derive(Default)]
19pub struct Generator {
20    inputs: Vec<PathBuf>,
21    includes: Vec<PathBuf>,
22    outputs: Vec<PathBuf>,
23    schema_outputs: Vec<PathBuf>,
24    path_outputs: Vec<PathBuf>,
25    fragment_outputs: Vec<PathBuf>,
26}
27
28impl Generator {
29    /// Creates a new Generator instance.
30    pub fn new() -> Self {
31        Self::default()
32    }
33
34    /// Configures the generator from a Config object.
35    pub fn with_config(mut self, config: Config) -> Self {
36        if let Some(inputs) = config.input {
37            self.inputs.extend(inputs);
38        }
39        if let Some(includes) = config.include {
40            self.includes.extend(includes);
41        }
42        if let Some(output) = config.output {
43            self.outputs.extend(output);
44        }
45        if let Some(output_schemas) = config.output_schemas {
46            self.schema_outputs.extend(output_schemas);
47        }
48        if let Some(output_paths) = config.output_paths {
49            self.path_outputs.extend(output_paths);
50        }
51        if let Some(output_fragments) = config.output_fragments {
52            self.fragment_outputs.extend(output_fragments);
53        }
54        self
55    }
56
57    /// Adds an input directory to scan.
58    pub fn input<P: Into<PathBuf>>(mut self, path: P) -> Self {
59        self.inputs.push(path.into());
60        self
61    }
62
63    /// Adds a specific file to include.
64    pub fn include<P: Into<PathBuf>>(mut self, path: P) -> Self {
65        self.includes.push(path.into());
66        self
67    }
68
69    /// Appends an output file path.
70    pub fn output<P: Into<PathBuf>>(mut self, path: P) -> Self {
71        self.outputs.push(path.into());
72        self
73    }
74
75    /// Appends an output file path for just the schemas.
76    pub fn output_schemas<P: Into<PathBuf>>(mut self, path: P) -> Self {
77        self.schema_outputs.push(path.into());
78        self
79    }
80
81    /// Appends an output file path for just the paths.
82    pub fn output_paths<P: Into<PathBuf>>(mut self, path: P) -> Self {
83        self.path_outputs.push(path.into());
84        self
85    }
86
87    /// Appends an output file path for full spec minus root details (fragments).
88    pub fn output_fragments<P: Into<PathBuf>>(mut self, path: P) -> Self {
89        self.fragment_outputs.push(path.into());
90        self
91    }
92
93    /// Executes the generation process.
94    pub fn generate(self) -> Result<()> {
95        if self.outputs.is_empty()
96            && self.schema_outputs.is_empty()
97            && self.path_outputs.is_empty()
98            && self.fragment_outputs.is_empty()
99        {
100            return Err(std::io::Error::new(
101                std::io::ErrorKind::InvalidInput,
102                "At least one output path (output, output_schemas, output_paths, or output_fragments) is required",
103            )
104            .into());
105        }
106
107        // 1. Scan and Extract
108        log::info!(
109            "Scanning directories: {:?} and includes: {:?}",
110            self.inputs,
111            self.includes
112        );
113        let snippets = scanner::scan_directories(&self.inputs, &self.includes)?;
114
115        // 2. Merge (Relaxed - may return empty map if no root)
116        log::info!("Merging {} snippets", snippets.len());
117        let merged_value = merger::merge_openapi(snippets)?;
118
119        // Strategy 1: Full Spec (Strict Validation)
120        if !self.outputs.is_empty() {
121            if let serde_yaml::Value::Mapping(map) = &merged_value {
122                let openapi_key = serde_yaml::Value::String("openapi".to_string());
123                let info_key = serde_yaml::Value::String("info".to_string());
124
125                if !map.contains_key(&openapi_key) || !map.contains_key(&info_key) {
126                    return Err(error::Error::NoRootFound);
127                }
128            } else {
129                return Err(error::Error::NoRootFound);
130            }
131
132            for output in &self.outputs {
133                self.write_file(output, &merged_value)?;
134                log::info!("Written full spec to {:?}", output);
135            }
136        }
137
138        // Strategy 2: Schemas Only (Relaxed)
139        if !self.schema_outputs.is_empty() {
140            let schemas = merged_value
141                .get("components")
142                .and_then(|c| c.get("schemas"))
143                .cloned()
144                .unwrap_or_else(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
145
146            if let serde_yaml::Value::Mapping(m) = &schemas {
147                if m.is_empty() {
148                    log::warn!("Generating empty schemas file.");
149                }
150            }
151
152            for output in &self.schema_outputs {
153                self.write_file(output, &schemas)?;
154                log::info!("Written schemas to {:?}", output);
155            }
156        }
157
158        // Strategy 3: Paths Only (Relaxed)
159        if !self.path_outputs.is_empty() {
160            let paths = merged_value
161                .get("paths")
162                .cloned()
163                .unwrap_or_else(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
164
165            if let serde_yaml::Value::Mapping(m) = &paths {
166                if m.is_empty() {
167                    log::warn!("Generating empty paths file.");
168                }
169            }
170
171            for output in &self.path_outputs {
172                self.write_file(output, &paths)?;
173                log::info!("Written paths to {:?}", output);
174            }
175        }
176
177        // Strategy 4: Fragments (Headless Spec)
178        // Removes top-level keys: openapi, info, servers, externalDocs
179        // Keeps: paths, components, tags, security, etc.
180        if !self.fragment_outputs.is_empty() {
181            let mut fragment = merged_value.clone();
182            if let serde_yaml::Value::Mapping(ref mut map) = fragment {
183                map.remove(&serde_yaml::Value::String("openapi".to_string()));
184                map.remove(&serde_yaml::Value::String("info".to_string()));
185                map.remove(&serde_yaml::Value::String("servers".to_string()));
186            }
187
188            for output in &self.fragment_outputs {
189                self.write_file(output, &fragment)?;
190                log::info!("Written fragment to {:?}", output);
191            }
192        }
193
194        Ok(())
195    }
196
197    fn write_file<T: serde::Serialize>(&self, path: &PathBuf, content: &T) -> Result<()> {
198        // Ensure parent directory exists
199        if let Some(parent) = path.parent() {
200            std::fs::create_dir_all(parent)?;
201        }
202
203        let file = std::fs::File::create(path)?;
204        let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("yaml");
205
206        match extension {
207            "json" => {
208                serde_json::to_writer_pretty(file, content)?;
209            }
210            "yaml" | "yml" => {
211                serde_yaml::to_writer(file, content)?;
212            }
213            _ => {
214                serde_yaml::to_writer(file, content)?;
215            }
216        }
217        Ok(())
218    }
219}