1use crate::spec_source::{is_remote_spec, parse_oas_version, parse_spec, validate_remote_spec_url};
2use crate::{CodeGenerator, GeneratorConfig, SchemaAnalyzer, streaming::StreamingConfig};
3use clap::{Arg, Command};
4use std::fs;
5use std::io::Read;
6use std::process;
7use std::time::Duration;
8
9pub const MAX_REMOTE_SPEC_BYTES: u64 = 64 * 1024 * 1024;
11pub const REMOTE_SPEC_TIMEOUT: Duration = Duration::from_secs(30);
13
14pub struct CliConfig {
16 pub api_name: &'static str,
18 pub default_module_name: &'static str,
20 pub streaming_config: Option<StreamingConfig>,
22 pub enable_specta: bool,
24}
25
26#[deprecated(note = "use the openapi-to-rust binary's generate command")]
28pub async fn run_generation_cli(cli_config: CliConfig) {
29 let matches = Command::new("api-gen")
30 .version("0.1.0")
31 .about("Generate API types and streaming client")
32 .arg(
33 Arg::new("input")
34 .help("Input OpenAPI spec (file path or URL)")
35 .required(true)
36 .index(1),
37 )
38 .arg(
39 Arg::new("output-dir")
40 .long("output-dir")
41 .value_name("DIR")
42 .help("Output directory for generated files (default: src/generated)")
43 .default_value("src/generated"),
44 )
45 .arg(
46 Arg::new("module-name")
47 .short('m')
48 .long("module-name")
49 .value_name("NAME")
50 .help("Generated module name")
51 .default_value(cli_config.default_module_name),
52 )
53 .arg(
54 Arg::new("verbose")
55 .short('v')
56 .long("verbose")
57 .help("Enable verbose output")
58 .action(clap::ArgAction::SetTrue),
59 )
60 .arg(
61 Arg::new("dry-run")
62 .long("dry-run")
63 .help("Print generated code to stdout instead of writing to file")
64 .action(clap::ArgAction::SetTrue),
65 )
66 .get_matches();
67
68 let Some(input) = matches.get_one::<String>("input") else {
69 eprintln!("Error: missing required argument 'input'");
70 process::exit(1);
71 };
72 let Some(output_dir) = matches.get_one::<String>("output-dir") else {
73 eprintln!("Error: missing required argument 'output-dir'");
74 process::exit(1);
75 };
76 let Some(module_name) = matches.get_one::<String>("module-name") else {
77 eprintln!("Error: missing required argument 'module-name'");
78 process::exit(1);
79 };
80 let verbose = matches.get_flag("verbose");
81 let dry_run = matches.get_flag("dry-run");
82
83 if verbose {
84 println!("🚀 {} API Generator", cli_config.api_name);
85 println!("Input: {input}");
86 if !dry_run {
87 println!("Output: {output_dir}");
88 }
89 println!("Module: {module_name}");
90 if cli_config.streaming_config.is_some() {
91 println!("🌊 Streaming: enabled");
92 }
93 println!();
94 }
95
96 let spec_content = match load_spec(input) {
98 Ok(content) => content,
99 Err(e) => {
100 eprintln!("❌ Error loading spec: {e}");
101 process::exit(1);
102 }
103 };
104
105 if verbose {
106 println!("📄 Loaded OpenAPI spec ({} bytes)", spec_content.len());
107 }
108
109 let spec_value: serde_json::Value = match parse_spec(&spec_content, input) {
111 Ok(value) => value,
112 Err(e) => {
113 eprintln!("❌ Error parsing spec: {e}");
114 process::exit(1);
115 }
116 };
117
118 let oas_version = spec_value
123 .get("openapi")
124 .and_then(|v| v.as_str())
125 .unwrap_or("");
126 if let Some((major, minor)) = parse_oas_version(oas_version) {
127 match (major, minor) {
128 (3, 0) | (3, 1) => {}
129 (3, 2) => {
130 eprintln!(
131 "⚠️ OpenAPI {oas_version}: 3.2 is experimentally supported. \
132 Some 3.2-only features (additionalOperations, query method, \
133 itemSchema, $self, defaultMapping) are not yet wired through codegen. \
134 See issue #14 for status."
135 );
136 }
137 _ => {
138 eprintln!(
139 "❌ Unsupported OpenAPI version: {oas_version}. \
140 This generator targets 3.0.x, 3.1.x, and (experimentally) 3.2.x."
141 );
142 process::exit(1);
143 }
144 }
145 } else {
146 eprintln!(
147 "❌ Missing or unrecognised `openapi` field. Expected something like \"3.1.0\", got: {oas_version:?}"
148 );
149 process::exit(1);
150 }
151
152 if verbose {
153 if let Some(info) = spec_value.get("info") {
154 if let Some(title) = info.get("title").and_then(|t| t.as_str()) {
155 println!("📋 Title: {title}");
156 }
157 if let Some(version) = info.get("version").and_then(|v| v.as_str()) {
158 println!("🏷️ Version: {version}");
159 }
160 }
161 println!();
162 }
163
164 if verbose {
166 println!("🔍 Analyzing schemas...");
167 }
168
169 let mut analyzer = match SchemaAnalyzer::new(spec_value) {
170 Ok(analyzer) => analyzer,
171 Err(e) => {
172 eprintln!("❌ Error creating analyzer: {e}");
173 process::exit(1);
174 }
175 };
176
177 let mut analysis = match analyzer.analyze() {
178 Ok(analysis) => analysis,
179 Err(e) => {
180 eprintln!("❌ Error analyzing schemas: {e}");
181 process::exit(1);
182 }
183 };
184
185 if verbose {
186 println!("📈 Found {} schemas", analysis.schemas.len());
187 println!("📈 Found {} operations", analysis.operations.len());
188 if let Some(ref config) = cli_config.streaming_config {
189 println!("🌊 Found {} streaming endpoints", config.endpoints.len());
190 }
191 println!();
192 }
193
194 if verbose {
196 let stream_status = if cli_config.streaming_config.is_some() {
197 "with streaming support"
198 } else {
199 ""
200 };
201 println!(
202 "⚙️ Generating {} API code {}...",
203 cli_config.api_name, stream_status
204 );
205 }
206
207 let config = GeneratorConfig {
208 module_name: module_name.clone(),
209 output_dir: output_dir.into(),
210 streaming_config: cli_config.streaming_config,
211 enable_specta: cli_config.enable_specta,
212 ..Default::default()
213 };
214
215 let generator = CodeGenerator::new(config);
216 let generation_result = match generator.generate_all(&mut analysis) {
217 Ok(result) => result,
218 Err(e) => {
219 eprintln!("❌ Error generating code: {e}");
220 process::exit(1);
221 }
222 };
223
224 if dry_run {
225 println!("=== Generated Files ===");
226 for file in &generation_result.files {
227 println!("\n--- {} ---", file.path.display());
228 println!("{}", file.content);
229 }
230 println!("\n--- {} ---", generation_result.mod_file.path.display());
231 println!("{}", generation_result.mod_file.content);
232 } else {
233 if let Err(e) = generator.write_files(&generation_result) {
235 eprintln!("❌ Error writing files: {e}");
236 process::exit(1);
237 }
238
239 if verbose {
240 println!(
241 "✅ Generated {} files written to: {}",
242 generation_result.files.len() + 1,
243 generator.config().output_dir.display()
244 );
245 for file in &generation_result.files {
246 println!(" - {}", file.path.display());
247 }
248 println!(" - {}", generation_result.mod_file.path.display());
249 } else {
250 println!(
251 "✅ Generated {} files written to: {}",
252 generation_result.files.len() + 1,
253 generator.config().output_dir.display()
254 );
255 }
256 }
257}
258
259pub fn load_spec(input: &str) -> Result<String, Box<dyn std::error::Error>> {
265 if !is_remote_spec(input) {
266 if input.contains("://") {
267 return match validate_remote_spec_url(input) {
268 Ok(_) => Err("unsupported remote OpenAPI URL".into()),
269 Err(error) => Err(error.into()),
270 };
271 }
272 return Ok(fs::read_to_string(input)?);
273 }
274
275 let url = validate_remote_spec_url(input)?;
276 let client = reqwest::blocking::Client::builder()
277 .connect_timeout(Duration::from_secs(10))
278 .timeout(REMOTE_SPEC_TIMEOUT)
279 .redirect(reqwest::redirect::Policy::none())
282 .user_agent(concat!("openapi-to-rust/", env!("CARGO_PKG_VERSION")))
283 .build()?;
284 let response = client.get(url).send().map_err(|error| {
285 format!(
286 "failed to fetch remote OpenAPI document: {}",
287 error.without_url()
288 )
289 })?;
290 if !response.status().is_success() {
291 return Err(format!(
292 "failed to fetch OpenAPI document: HTTP {}",
293 response.status()
294 )
295 .into());
296 }
297 if response
298 .content_length()
299 .is_some_and(|length| length > MAX_REMOTE_SPEC_BYTES)
300 {
301 return Err(format!(
302 "remote OpenAPI document exceeds the {} MiB response-size limit",
303 MAX_REMOTE_SPEC_BYTES / 1024 / 1024
304 )
305 .into());
306 }
307
308 let mut bytes = Vec::new();
309 response
310 .take(MAX_REMOTE_SPEC_BYTES + 1)
311 .read_to_end(&mut bytes)?;
312 if bytes.len() as u64 > MAX_REMOTE_SPEC_BYTES {
313 return Err(format!(
314 "remote OpenAPI document exceeds the {} MiB response-size limit",
315 MAX_REMOTE_SPEC_BYTES / 1024 / 1024
316 )
317 .into());
318 }
319 Ok(String::from_utf8(bytes)?)
320}