Skip to main content

openapi_to_rust/
cli.rs

1use crate::{CodeGenerator, GeneratorConfig, SchemaAnalyzer, streaming::StreamingConfig};
2use clap::{Arg, Command};
3use std::fs;
4use std::process;
5
6/// Configuration for the CLI helper
7pub struct CliConfig {
8    /// Name of the API for display purposes
9    pub api_name: &'static str,
10    /// Default module name
11    pub default_module_name: &'static str,
12    /// Streaming configuration for this API
13    pub streaming_config: Option<StreamingConfig>,
14    /// Enable Specta type derives for frontend integration
15    pub enable_specta: bool,
16}
17
18/// Run the complete generation CLI with the provided configuration
19pub async fn run_generation_cli(cli_config: CliConfig) {
20    let matches = Command::new("api-gen")
21        .version("0.1.0")
22        .about("Generate API types and streaming client")
23        .arg(
24            Arg::new("input")
25                .help("Input OpenAPI spec (file path or URL)")
26                .required(true)
27                .index(1),
28        )
29        .arg(
30            Arg::new("output-dir")
31                .long("output-dir")
32                .value_name("DIR")
33                .help("Output directory for generated files (default: src/generated)")
34                .default_value("src/generated"),
35        )
36        .arg(
37            Arg::new("module-name")
38                .short('m')
39                .long("module-name")
40                .value_name("NAME")
41                .help("Generated module name")
42                .default_value(cli_config.default_module_name),
43        )
44        .arg(
45            Arg::new("verbose")
46                .short('v')
47                .long("verbose")
48                .help("Enable verbose output")
49                .action(clap::ArgAction::SetTrue),
50        )
51        .arg(
52            Arg::new("dry-run")
53                .long("dry-run")
54                .help("Print generated code to stdout instead of writing to file")
55                .action(clap::ArgAction::SetTrue),
56        )
57        .get_matches();
58
59    let Some(input) = matches.get_one::<String>("input") else {
60        eprintln!("Error: missing required argument 'input'");
61        process::exit(1);
62    };
63    let Some(output_dir) = matches.get_one::<String>("output-dir") else {
64        eprintln!("Error: missing required argument 'output-dir'");
65        process::exit(1);
66    };
67    let Some(module_name) = matches.get_one::<String>("module-name") else {
68        eprintln!("Error: missing required argument 'module-name'");
69        process::exit(1);
70    };
71    let verbose = matches.get_flag("verbose");
72    let dry_run = matches.get_flag("dry-run");
73
74    if verbose {
75        println!("🚀 {} API Generator", cli_config.api_name);
76        println!("Input: {input}");
77        if !dry_run {
78            println!("Output: {output_dir}");
79        }
80        println!("Module: {module_name}");
81        if cli_config.streaming_config.is_some() {
82            println!("🌊 Streaming: enabled");
83        }
84        println!();
85    }
86
87    // Load OpenAPI spec
88    let spec_content = match load_spec(input, verbose).await {
89        Ok(content) => content,
90        Err(e) => {
91            eprintln!("❌ Error loading spec: {e}");
92            process::exit(1);
93        }
94    };
95
96    if verbose {
97        println!("📄 Loaded OpenAPI spec ({} bytes)", spec_content.len());
98    }
99
100    // Parse the spec
101    let spec_value: serde_json::Value = match parse_spec(&spec_content, input) {
102        Ok(value) => value,
103        Err(e) => {
104            eprintln!("❌ Error parsing spec: {e}");
105            process::exit(1);
106        }
107    };
108
109    // Version gate: surface unsupported OAS major.minor early. We support
110    // 3.0.x and 3.1.x first-class; 3.2.x parses (extensions on the spec model
111    // capture the new fields), but several 3.2-only behaviors are still TODO,
112    // so warn loudly. See https://github.com/gpu-cli/openapi-to-rust/issues/14.
113    let oas_version = spec_value
114        .get("openapi")
115        .and_then(|v| v.as_str())
116        .unwrap_or("");
117    if let Some((major, minor)) = parse_oas_version(oas_version) {
118        match (major, minor) {
119            (3, 0) | (3, 1) => {}
120            (3, 2) => {
121                eprintln!(
122                    "⚠️  OpenAPI {oas_version}: 3.2 is experimentally supported. \
123                     Some 3.2-only features (additionalOperations, query method, \
124                     itemSchema, $self, defaultMapping) are not yet wired through codegen. \
125                     See issue #14 for status."
126                );
127            }
128            _ => {
129                eprintln!(
130                    "❌ Unsupported OpenAPI version: {oas_version}. \
131                     This generator targets 3.0.x, 3.1.x, and (experimentally) 3.2.x."
132                );
133                process::exit(1);
134            }
135        }
136    } else {
137        eprintln!(
138            "❌ Missing or unrecognised `openapi` field. Expected something like \"3.1.0\", got: {oas_version:?}"
139        );
140        process::exit(1);
141    }
142
143    if verbose {
144        if let Some(info) = spec_value.get("info") {
145            if let Some(title) = info.get("title").and_then(|t| t.as_str()) {
146                println!("📋 Title: {title}");
147            }
148            if let Some(version) = info.get("version").and_then(|v| v.as_str()) {
149                println!("🏷️  Version: {version}");
150            }
151        }
152        println!();
153    }
154
155    // Analyze schemas
156    if verbose {
157        println!("🔍 Analyzing schemas...");
158    }
159
160    let mut analyzer = match SchemaAnalyzer::new(spec_value) {
161        Ok(analyzer) => analyzer,
162        Err(e) => {
163            eprintln!("❌ Error creating analyzer: {e}");
164            process::exit(1);
165        }
166    };
167
168    let mut analysis = match analyzer.analyze() {
169        Ok(analysis) => analysis,
170        Err(e) => {
171            eprintln!("❌ Error analyzing schemas: {e}");
172            process::exit(1);
173        }
174    };
175
176    if verbose {
177        println!("📈 Found {} schemas", analysis.schemas.len());
178        println!("📈 Found {} operations", analysis.operations.len());
179        if let Some(ref config) = cli_config.streaming_config {
180            println!("🌊 Found {} streaming endpoints", config.endpoints.len());
181        }
182        println!();
183    }
184
185    // Generate code
186    if verbose {
187        let stream_status = if cli_config.streaming_config.is_some() {
188            "with streaming support"
189        } else {
190            ""
191        };
192        println!(
193            "⚙️  Generating {} API code {}...",
194            cli_config.api_name, stream_status
195        );
196    }
197
198    let config = GeneratorConfig {
199        module_name: module_name.clone(),
200        output_dir: output_dir.into(),
201        streaming_config: cli_config.streaming_config,
202        enable_specta: cli_config.enable_specta,
203        ..Default::default()
204    };
205
206    let generator = CodeGenerator::new(config);
207    let generation_result = match generator.generate_all(&mut analysis) {
208        Ok(result) => result,
209        Err(e) => {
210            eprintln!("❌ Error generating code: {e}");
211            process::exit(1);
212        }
213    };
214
215    if dry_run {
216        println!("=== Generated Files ===");
217        for file in &generation_result.files {
218            println!("\n--- {} ---", file.path.display());
219            println!("{}", file.content);
220        }
221        println!("\n--- {} ---", generation_result.mod_file.path.display());
222        println!("{}", generation_result.mod_file.content);
223    } else {
224        // Write all files to disk
225        if let Err(e) = generator.write_files(&generation_result) {
226            eprintln!("❌ Error writing files: {e}");
227            process::exit(1);
228        }
229
230        if verbose {
231            println!(
232                "✅ Generated {} files written to: {}",
233                generation_result.files.len() + 1,
234                generator.config().output_dir.display()
235            );
236            for file in &generation_result.files {
237                println!("   - {}", file.path.display());
238            }
239            println!("   - {}", generation_result.mod_file.path.display());
240        } else {
241            println!(
242                "✅ Generated {} files written to: {}",
243                generation_result.files.len() + 1,
244                generator.config().output_dir.display()
245            );
246        }
247    }
248}
249
250async fn load_spec(input: &str, verbose: bool) -> Result<String, Box<dyn std::error::Error>> {
251    if input.starts_with("http://") || input.starts_with("https://") {
252        // Load from URL
253        if verbose {
254            println!("🌐 Fetching from URL...");
255        }
256
257        let response = reqwest::get(input).await?;
258        if !response.status().is_success() {
259            return Err(format!("HTTP error: {}", response.status()).into());
260        }
261
262        let content = response.text().await?;
263        Ok(content)
264    } else {
265        // Load from file
266        if verbose {
267            println!("📁 Reading from file...");
268        }
269
270        let content = fs::read_to_string(input)?;
271        Ok(content)
272    }
273}
274
275/// Parse the `openapi` version string into (major, minor). Tolerates patch and
276/// build-metadata suffixes. Returns None for unrecognised input.
277pub fn parse_oas_version(s: &str) -> Option<(u32, u32)> {
278    let mut parts = s.split('.');
279    let major = parts.next()?.parse().ok()?;
280    let minor_raw = parts.next()?;
281    let minor_digits: String = minor_raw
282        .chars()
283        .take_while(|c| c.is_ascii_digit())
284        .collect();
285    let minor = minor_digits.parse().ok()?;
286    Some((major, minor))
287}
288
289fn parse_spec(content: &str, input: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
290    // Determine format from extension or content
291    let is_yaml = input.ends_with(".yaml")
292        || input.ends_with(".yml")
293        || content.trim_start().starts_with("openapi:")
294        || content.trim_start().starts_with("swagger:");
295
296    if is_yaml {
297        let value = yaml_to_json_value(content)?;
298        Ok(value)
299    } else {
300        let value = json_from_str_lossy(content)?;
301        Ok(value)
302    }
303}
304
305/// Parse YAML to serde_json::Value, converting large numbers to f64 to avoid overflow.
306/// serde_yaml 0.9 cannot represent integers exceeding i64/u64 range (e.g. numbers > 2^64),
307/// so we preprocess the YAML to convert such numbers to float notation, then go through
308/// serde_yaml::Value and convert to serde_json::Value manually.
309pub fn yaml_to_json_value(content: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
310    let preprocessed = sanitize_large_yaml_integers(content);
311    let yaml_value: serde_yaml::Value = serde_yaml::from_str(&preprocessed)?;
312    Ok(yaml_value_to_json(yaml_value))
313}
314
315/// Parse JSON with lossy number handling: numbers that overflow i64/u64 are stored as f64.
316pub fn json_from_str_lossy(content: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
317    // Try normal parsing first (fast path)
318    match serde_json::from_str::<serde_json::Value>(content) {
319        Ok(v) => Ok(v),
320        Err(e) => {
321            let err_msg = e.to_string();
322            if err_msg.contains("number out of range") {
323                // Fall back: parse via YAML which handles large numbers
324                let yaml_value: serde_yaml::Value = serde_yaml::from_str(content)?;
325                Ok(yaml_value_to_json(yaml_value))
326            } else {
327                Err(e.into())
328            }
329        }
330    }
331}
332
333fn yaml_value_to_json(yaml: serde_yaml::Value) -> serde_json::Value {
334    match yaml {
335        serde_yaml::Value::Null => serde_json::Value::Null,
336        serde_yaml::Value::Bool(b) => serde_json::Value::Bool(b),
337        serde_yaml::Value::Number(n) => {
338            if let Some(i) = n.as_i64() {
339                serde_json::Value::Number(i.into())
340            } else if let Some(u) = n.as_u64() {
341                serde_json::Value::Number(u.into())
342            } else if let Some(f) = n.as_f64() {
343                serde_json::json!(f)
344            } else {
345                // Fallback: represent as 0.0
346                serde_json::json!(0.0)
347            }
348        }
349        serde_yaml::Value::String(s) => serde_json::Value::String(s),
350        serde_yaml::Value::Sequence(seq) => {
351            serde_json::Value::Array(seq.into_iter().map(yaml_value_to_json).collect())
352        }
353        serde_yaml::Value::Mapping(map) => {
354            let obj = map
355                .into_iter()
356                .filter_map(|(k, v)| {
357                    let key = match k {
358                        serde_yaml::Value::String(s) => s,
359                        serde_yaml::Value::Number(n) => n.to_string(),
360                        serde_yaml::Value::Bool(b) => b.to_string(),
361                        _ => return None,
362                    };
363                    Some((key, yaml_value_to_json(v)))
364                })
365                .collect();
366            serde_json::Value::Object(obj)
367        }
368        serde_yaml::Value::Tagged(tagged) => yaml_value_to_json(tagged.value),
369    }
370}
371
372/// Preprocess YAML content to convert integers that exceed i64/u64 range to float notation.
373/// serde_yaml 0.9 cannot parse integers larger than u64::MAX or smaller than i64::MIN,
374/// so we find bare integer values on YAML lines and append `.0` if they overflow.
375fn sanitize_large_yaml_integers(content: &str) -> String {
376    let mut result = String::with_capacity(content.len());
377    for line in content.lines() {
378        if let Some(sanitized) = try_sanitize_integer_line(line) {
379            result.push_str(&sanitized);
380        } else {
381            result.push_str(line);
382        }
383        result.push('\n');
384    }
385    result
386}
387
388/// If a YAML line has a `key: <integer>` pattern where the integer overflows i64/u64,
389/// convert it to float by appending `.0`. Returns None if no change needed.
390fn try_sanitize_integer_line(line: &str) -> Option<String> {
391    // Match pattern: optional whitespace, key, colon, space(s), then a number value
392    // We look for the value portion after the last `: ` or `- ` on the line
393    let trimmed = line.trim();
394
395    // Skip comments and empty lines
396    if trimmed.is_empty() || trimmed.starts_with('#') {
397        return None;
398    }
399
400    // Find the value part — after `: ` for mapping entries
401    let colon_pos = line.find(": ")?;
402    let value_start = colon_pos + 2;
403    let value_str = line[value_start..].trim();
404
405    // Check if the value looks like a bare integer (optional leading minus, then digits)
406    if value_str.is_empty() {
407        return None;
408    }
409
410    let (is_negative, digit_part) = if let Some(rest) = value_str.strip_prefix('-') {
411        (true, rest)
412    } else {
413        (false, value_str)
414    };
415
416    // Must be all digits
417    if !digit_part.chars().all(|c| c.is_ascii_digit()) || digit_part.is_empty() {
418        return None;
419    }
420
421    // Check if it overflows i64/u64
422    let overflows = if is_negative {
423        // Check if |value| > i64::MAX + 1 = 9223372036854775808
424        digit_part.len() > 19 || (digit_part.len() == 19 && digit_part > "9223372036854775808")
425    } else {
426        // Check if value > u64::MAX = 18446744073709551615
427        digit_part.len() > 20 || (digit_part.len() == 20 && digit_part > "18446744073709551615")
428    };
429
430    if overflows {
431        // Replace the integer with float notation
432        let mut sanitized = line[..value_start].to_string();
433        sanitized.push_str(value_str);
434        sanitized.push_str(".0");
435        Some(sanitized)
436    } else {
437        None
438    }
439}