Skip to main content

oas_forge/
lib.rs

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