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 = 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 merged_value = merger::merge_openapi(snippets)?;
121
122        // Strategy 1: Full Spec (Strict Validation)
123        if !self.outputs.is_empty() {
124            if let serde_yaml::Value::Mapping(map) = &merged_value {
125                let openapi_key = serde_yaml::Value::String("openapi".to_string());
126                let info_key = serde_yaml::Value::String("info".to_string());
127
128                if !map.contains_key(&openapi_key) || !map.contains_key(&info_key) {
129                    return Err(error::Error::NoRootFound);
130                }
131            } else {
132                return Err(error::Error::NoRootFound);
133            }
134
135            for output in &self.outputs {
136                self.write_file(output, &merged_value)?;
137                log::info!("Written full spec to {:?}", output);
138            }
139        }
140
141        // Strategy 2: Schemas Only (Relaxed)
142        if !self.schema_outputs.is_empty() {
143            let schemas = merged_value
144                .get("components")
145                .and_then(|c| c.get("schemas"))
146                .cloned()
147                .unwrap_or_else(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
148
149            if let serde_yaml::Value::Mapping(m) = &schemas {
150                if m.is_empty() {
151                    log::warn!("Generating empty schemas file.");
152                }
153            }
154
155            for output in &self.schema_outputs {
156                self.write_file(output, &schemas)?;
157                log::info!("Written schemas to {:?}", output);
158            }
159        }
160
161        // Strategy 3: Paths Only (Relaxed)
162        if !self.path_outputs.is_empty() {
163            let paths = merged_value
164                .get("paths")
165                .cloned()
166                .unwrap_or_else(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
167
168            if let serde_yaml::Value::Mapping(m) = &paths {
169                if m.is_empty() {
170                    log::warn!("Generating empty paths file.");
171                }
172            }
173
174            for output in &self.path_outputs {
175                self.write_file(output, &paths)?;
176                log::info!("Written paths to {:?}", output);
177            }
178        }
179
180        // Strategy 4: Fragments (Headless Spec)
181        // Removes top-level keys: openapi, info, servers, externalDocs
182        // Keeps: paths, components, tags, security, etc.
183        if !self.fragment_outputs.is_empty() {
184            let mut fragment = merged_value.clone();
185            if let serde_yaml::Value::Mapping(ref mut map) = fragment {
186                map.remove(serde_yaml::Value::String("openapi".to_string()));
187                map.remove(serde_yaml::Value::String("info".to_string()));
188                map.remove(serde_yaml::Value::String("servers".to_string()));
189            }
190
191            for output in &self.fragment_outputs {
192                self.write_file(output, &fragment)?;
193                log::info!("Written fragment to {:?}", output);
194            }
195        }
196
197        Ok(())
198    }
199
200    fn write_file<T: serde::Serialize>(&self, path: &PathBuf, content: &T) -> Result<()> {
201        // Ensure parent directory exists
202        if let Some(parent) = path.parent() {
203            std::fs::create_dir_all(parent)?;
204        }
205
206        let file = std::fs::File::create(path)?;
207        let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("yaml");
208
209        match extension {
210            "json" => {
211                serde_json::to_writer_pretty(file, content)?;
212            }
213            "yaml" | "yml" => {
214                serde_yaml::to_writer(file, content)?;
215            }
216            _ => {
217                serde_yaml::to_writer(file, content)?;
218            }
219        }
220        Ok(())
221    }
222}