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::io::Read;
5use std::process;
6use std::time::Duration;
7
8/// Maximum accepted size for a remotely fetched OpenAPI document (64 MiB).
9pub const MAX_REMOTE_SPEC_BYTES: u64 = 64 * 1024 * 1024;
10/// End-to-end timeout for fetching a remote OpenAPI document.
11pub const REMOTE_SPEC_TIMEOUT: Duration = Duration::from_secs(30);
12
13/// Configuration for the CLI helper
14pub struct CliConfig {
15    /// Name of the API for display purposes
16    pub api_name: &'static str,
17    /// Default module name
18    pub default_module_name: &'static str,
19    /// Streaming configuration for this API
20    pub streaming_config: Option<StreamingConfig>,
21    /// Enable Specta type derives for frontend integration
22    pub enable_specta: bool,
23}
24
25/// Run the legacy embedded generation CLI with the provided configuration.
26#[deprecated(note = "use the openapi-to-rust binary's generate command")]
27pub async fn run_generation_cli(cli_config: CliConfig) {
28    let matches = Command::new("api-gen")
29        .version("0.1.0")
30        .about("Generate API types and streaming client")
31        .arg(
32            Arg::new("input")
33                .help("Input OpenAPI spec (file path or URL)")
34                .required(true)
35                .index(1),
36        )
37        .arg(
38            Arg::new("output-dir")
39                .long("output-dir")
40                .value_name("DIR")
41                .help("Output directory for generated files (default: src/generated)")
42                .default_value("src/generated"),
43        )
44        .arg(
45            Arg::new("module-name")
46                .short('m')
47                .long("module-name")
48                .value_name("NAME")
49                .help("Generated module name")
50                .default_value(cli_config.default_module_name),
51        )
52        .arg(
53            Arg::new("verbose")
54                .short('v')
55                .long("verbose")
56                .help("Enable verbose output")
57                .action(clap::ArgAction::SetTrue),
58        )
59        .arg(
60            Arg::new("dry-run")
61                .long("dry-run")
62                .help("Print generated code to stdout instead of writing to file")
63                .action(clap::ArgAction::SetTrue),
64        )
65        .get_matches();
66
67    let Some(input) = matches.get_one::<String>("input") else {
68        eprintln!("Error: missing required argument 'input'");
69        process::exit(1);
70    };
71    let Some(output_dir) = matches.get_one::<String>("output-dir") else {
72        eprintln!("Error: missing required argument 'output-dir'");
73        process::exit(1);
74    };
75    let Some(module_name) = matches.get_one::<String>("module-name") else {
76        eprintln!("Error: missing required argument 'module-name'");
77        process::exit(1);
78    };
79    let verbose = matches.get_flag("verbose");
80    let dry_run = matches.get_flag("dry-run");
81
82    if verbose {
83        println!("🚀 {} API Generator", cli_config.api_name);
84        println!("Input: {input}");
85        if !dry_run {
86            println!("Output: {output_dir}");
87        }
88        println!("Module: {module_name}");
89        if cli_config.streaming_config.is_some() {
90            println!("🌊 Streaming: enabled");
91        }
92        println!();
93    }
94
95    // Load OpenAPI spec
96    let spec_content = match load_spec(input) {
97        Ok(content) => content,
98        Err(e) => {
99            eprintln!("❌ Error loading spec: {e}");
100            process::exit(1);
101        }
102    };
103
104    if verbose {
105        println!("📄 Loaded OpenAPI spec ({} bytes)", spec_content.len());
106    }
107
108    // Parse the spec
109    let spec_value: serde_json::Value = match parse_spec(&spec_content, input) {
110        Ok(value) => value,
111        Err(e) => {
112            eprintln!("❌ Error parsing spec: {e}");
113            process::exit(1);
114        }
115    };
116
117    // Version gate: surface unsupported OAS major.minor early. We support
118    // 3.0.x and 3.1.x first-class; 3.2.x parses (extensions on the spec model
119    // capture the new fields), but several 3.2-only behaviors are still TODO,
120    // so warn loudly. See https://github.com/gpu-cli/openapi-to-rust/issues/14.
121    let oas_version = spec_value
122        .get("openapi")
123        .and_then(|v| v.as_str())
124        .unwrap_or("");
125    if let Some((major, minor)) = parse_oas_version(oas_version) {
126        match (major, minor) {
127            (3, 0) | (3, 1) => {}
128            (3, 2) => {
129                eprintln!(
130                    "⚠️  OpenAPI {oas_version}: 3.2 is experimentally supported. \
131                     Some 3.2-only features (additionalOperations, query method, \
132                     itemSchema, $self, defaultMapping) are not yet wired through codegen. \
133                     See issue #14 for status."
134                );
135            }
136            _ => {
137                eprintln!(
138                    "❌ Unsupported OpenAPI version: {oas_version}. \
139                     This generator targets 3.0.x, 3.1.x, and (experimentally) 3.2.x."
140                );
141                process::exit(1);
142            }
143        }
144    } else {
145        eprintln!(
146            "❌ Missing or unrecognised `openapi` field. Expected something like \"3.1.0\", got: {oas_version:?}"
147        );
148        process::exit(1);
149    }
150
151    if verbose {
152        if let Some(info) = spec_value.get("info") {
153            if let Some(title) = info.get("title").and_then(|t| t.as_str()) {
154                println!("📋 Title: {title}");
155            }
156            if let Some(version) = info.get("version").and_then(|v| v.as_str()) {
157                println!("🏷️  Version: {version}");
158            }
159        }
160        println!();
161    }
162
163    // Analyze schemas
164    if verbose {
165        println!("🔍 Analyzing schemas...");
166    }
167
168    let mut analyzer = match SchemaAnalyzer::new(spec_value) {
169        Ok(analyzer) => analyzer,
170        Err(e) => {
171            eprintln!("❌ Error creating analyzer: {e}");
172            process::exit(1);
173        }
174    };
175
176    let mut analysis = match analyzer.analyze() {
177        Ok(analysis) => analysis,
178        Err(e) => {
179            eprintln!("❌ Error analyzing schemas: {e}");
180            process::exit(1);
181        }
182    };
183
184    if verbose {
185        println!("📈 Found {} schemas", analysis.schemas.len());
186        println!("📈 Found {} operations", analysis.operations.len());
187        if let Some(ref config) = cli_config.streaming_config {
188            println!("🌊 Found {} streaming endpoints", config.endpoints.len());
189        }
190        println!();
191    }
192
193    // Generate code
194    if verbose {
195        let stream_status = if cli_config.streaming_config.is_some() {
196            "with streaming support"
197        } else {
198            ""
199        };
200        println!(
201            "⚙️  Generating {} API code {}...",
202            cli_config.api_name, stream_status
203        );
204    }
205
206    let config = GeneratorConfig {
207        module_name: module_name.clone(),
208        output_dir: output_dir.into(),
209        streaming_config: cli_config.streaming_config,
210        enable_specta: cli_config.enable_specta,
211        ..Default::default()
212    };
213
214    let generator = CodeGenerator::new(config);
215    let generation_result = match generator.generate_all(&mut analysis) {
216        Ok(result) => result,
217        Err(e) => {
218            eprintln!("❌ Error generating code: {e}");
219            process::exit(1);
220        }
221    };
222
223    if dry_run {
224        println!("=== Generated Files ===");
225        for file in &generation_result.files {
226            println!("\n--- {} ---", file.path.display());
227            println!("{}", file.content);
228        }
229        println!("\n--- {} ---", generation_result.mod_file.path.display());
230        println!("{}", generation_result.mod_file.content);
231    } else {
232        // Write all files to disk
233        if let Err(e) = generator.write_files(&generation_result) {
234            eprintln!("❌ Error writing files: {e}");
235            process::exit(1);
236        }
237
238        if verbose {
239            println!(
240                "✅ Generated {} files written to: {}",
241                generation_result.files.len() + 1,
242                generator.config().output_dir.display()
243            );
244            for file in &generation_result.files {
245                println!("   - {}", file.path.display());
246            }
247            println!("   - {}", generation_result.mod_file.path.display());
248        } else {
249            println!(
250                "✅ Generated {} files written to: {}",
251                generation_result.files.len() + 1,
252                generator.config().output_dir.display()
253            );
254        }
255    }
256}
257
258/// Whether an input string names a supported remote OpenAPI source.
259pub fn is_remote_spec(input: &str) -> bool {
260    reqwest::Url::parse(input).is_ok_and(|url| matches!(url.scheme(), "https" | "http"))
261}
262
263/// Parse and enforce the remote-source transport policy.
264pub fn validate_remote_spec_url(input: &str) -> Result<reqwest::Url, String> {
265    let url = reqwest::Url::parse(input)
266        .map_err(|error| format!("invalid remote OpenAPI URL: {error}"))?;
267    if !url.username().is_empty() || url.password().is_some() {
268        return Err("remote OpenAPI URLs must not contain embedded credentials".to_string());
269    }
270    match url.scheme() {
271        "https" => Ok(url),
272        "http" if is_loopback_host(url.host_str()) => Ok(url),
273        "http" => Err(
274            "remote OpenAPI URLs must use HTTPS (plain HTTP is allowed only for localhost/loopback)"
275                .to_string(),
276        ),
277        scheme => Err(format!(
278            "unsupported OpenAPI URL scheme `{scheme}`; use HTTPS or a local file path"
279        )),
280    }
281}
282
283/// Remove URL credentials, query strings, and fragments before recording a
284/// source label in generated code. Local paths are retained as supplied.
285pub fn sanitize_source_provenance(input: &str) -> String {
286    let sanitize_controls = |value: &str| {
287        value
288            .chars()
289            .map(|character| {
290                if character.is_control() {
291                    '�'
292                } else {
293                    character
294                }
295            })
296            .collect::<String>()
297    };
298    let Ok(mut url) = reqwest::Url::parse(input) else {
299        return sanitize_controls(input);
300    };
301    if !matches!(url.scheme(), "https" | "http") {
302        return sanitize_controls(input);
303    }
304    let query_was_redacted = url.query().is_some();
305    let _ = url.set_username("");
306    let _ = url.set_password(None);
307    url.set_query(None);
308    url.set_fragment(None);
309    let mut label = url.to_string();
310    if query_was_redacted {
311        label.push_str(" (query redacted)");
312    }
313    sanitize_controls(&label)
314}
315
316/// Load a local or remote OpenAPI document with bounded remote I/O.
317///
318/// HTTPS is accepted everywhere. Plain HTTP is accepted only for loopback
319/// hosts so local development and deterministic integration tests do not need
320/// a certificate while non-local traffic remains encrypted.
321pub fn load_spec(input: &str) -> Result<String, Box<dyn std::error::Error>> {
322    if !is_remote_spec(input) {
323        if input.contains("://") {
324            return match validate_remote_spec_url(input) {
325                Ok(_) => Err("unsupported remote OpenAPI URL".into()),
326                Err(error) => Err(error.into()),
327            };
328        }
329        return Ok(fs::read_to_string(input)?);
330    }
331
332    let url = validate_remote_spec_url(input)?;
333    let client = reqwest::blocking::Client::builder()
334        .connect_timeout(Duration::from_secs(10))
335        .timeout(REMOTE_SPEC_TIMEOUT)
336        // Redirects are intentionally disabled: an allowed HTTPS URL could
337        // otherwise redirect to disallowed plaintext transport.
338        .redirect(reqwest::redirect::Policy::none())
339        .user_agent(concat!("openapi-to-rust/", env!("CARGO_PKG_VERSION")))
340        .build()?;
341    let response = client.get(url).send().map_err(|error| {
342        format!(
343            "failed to fetch remote OpenAPI document: {}",
344            error.without_url()
345        )
346    })?;
347    if !response.status().is_success() {
348        return Err(format!(
349            "failed to fetch OpenAPI document: HTTP {}",
350            response.status()
351        )
352        .into());
353    }
354    if response
355        .content_length()
356        .is_some_and(|length| length > MAX_REMOTE_SPEC_BYTES)
357    {
358        return Err(format!(
359            "remote OpenAPI document exceeds the {} MiB response-size limit",
360            MAX_REMOTE_SPEC_BYTES / 1024 / 1024
361        )
362        .into());
363    }
364
365    let mut bytes = Vec::new();
366    response
367        .take(MAX_REMOTE_SPEC_BYTES + 1)
368        .read_to_end(&mut bytes)?;
369    if bytes.len() as u64 > MAX_REMOTE_SPEC_BYTES {
370        return Err(format!(
371            "remote OpenAPI document exceeds the {} MiB response-size limit",
372            MAX_REMOTE_SPEC_BYTES / 1024 / 1024
373        )
374        .into());
375    }
376    Ok(String::from_utf8(bytes)?)
377}
378
379fn is_loopback_host(host: Option<&str>) -> bool {
380    match host {
381        Some("localhost") => true,
382        Some(host) => host
383            .parse::<std::net::IpAddr>()
384            .is_ok_and(|address| address.is_loopback()),
385        None => false,
386    }
387}
388
389/// Parse the `openapi` version string into (major, minor). Tolerates patch and
390/// build-metadata suffixes. Returns None for unrecognised input.
391pub fn parse_oas_version(s: &str) -> Option<(u32, u32)> {
392    let mut parts = s.split('.');
393    let major = parts.next()?.parse().ok()?;
394    let minor_raw = parts.next()?;
395    let minor_digits: String = minor_raw
396        .chars()
397        .take_while(|c| c.is_ascii_digit())
398        .collect();
399    let minor = minor_digits.parse().ok()?;
400    Some((major, minor))
401}
402
403pub fn parse_spec(
404    content: &str,
405    input: &str,
406) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
407    // Determine format from extension or content
408    let is_yaml = input.ends_with(".yaml")
409        || input.ends_with(".yml")
410        || content.trim_start().starts_with("openapi:")
411        || content.trim_start().starts_with("swagger:");
412
413    if is_yaml {
414        let value = yaml_to_json_value(content)?;
415        Ok(value)
416    } else {
417        let value = json_from_str_lossy(content)?;
418        Ok(value)
419    }
420}
421
422/// Parse YAML to serde_json::Value, converting large numbers to f64 to avoid overflow.
423/// serde_yaml 0.9 cannot represent integers exceeding i64/u64 range (e.g. numbers > 2^64),
424/// so we preprocess the YAML to convert such numbers to float notation, then go through
425/// serde_yaml::Value and convert to serde_json::Value manually.
426pub fn yaml_to_json_value(content: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
427    let preprocessed = sanitize_large_yaml_integers(content);
428    let yaml_value: serde_yaml::Value = serde_yaml::from_str(&preprocessed)?;
429    Ok(yaml_value_to_json(yaml_value))
430}
431
432/// Parse JSON with lossy number handling: numbers that overflow i64/u64 are stored as f64.
433pub fn json_from_str_lossy(content: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
434    // Try normal parsing first (fast path)
435    match serde_json::from_str::<serde_json::Value>(content) {
436        Ok(v) => Ok(v),
437        Err(e) => {
438            let err_msg = e.to_string();
439            if err_msg.contains("number out of range") {
440                // Fall back: parse via YAML which handles large numbers
441                let yaml_value: serde_yaml::Value = serde_yaml::from_str(content)?;
442                Ok(yaml_value_to_json(yaml_value))
443            } else {
444                Err(e.into())
445            }
446        }
447    }
448}
449
450fn yaml_value_to_json(yaml: serde_yaml::Value) -> serde_json::Value {
451    match yaml {
452        serde_yaml::Value::Null => serde_json::Value::Null,
453        serde_yaml::Value::Bool(b) => serde_json::Value::Bool(b),
454        serde_yaml::Value::Number(n) => {
455            if let Some(i) = n.as_i64() {
456                serde_json::Value::Number(i.into())
457            } else if let Some(u) = n.as_u64() {
458                serde_json::Value::Number(u.into())
459            } else if let Some(f) = n.as_f64() {
460                serde_json::json!(f)
461            } else {
462                // Fallback: represent as 0.0
463                serde_json::json!(0.0)
464            }
465        }
466        serde_yaml::Value::String(s) => serde_json::Value::String(s),
467        serde_yaml::Value::Sequence(seq) => {
468            serde_json::Value::Array(seq.into_iter().map(yaml_value_to_json).collect())
469        }
470        serde_yaml::Value::Mapping(map) => {
471            let obj = map
472                .into_iter()
473                .filter_map(|(k, v)| {
474                    let key = match k {
475                        serde_yaml::Value::String(s) => s,
476                        serde_yaml::Value::Number(n) => n.to_string(),
477                        serde_yaml::Value::Bool(b) => b.to_string(),
478                        _ => return None,
479                    };
480                    Some((key, yaml_value_to_json(v)))
481                })
482                .collect();
483            serde_json::Value::Object(obj)
484        }
485        serde_yaml::Value::Tagged(tagged) => yaml_value_to_json(tagged.value),
486    }
487}
488
489/// Preprocess YAML content to convert integers that exceed i64/u64 range to float notation.
490/// serde_yaml 0.9 cannot parse integers larger than u64::MAX or smaller than i64::MIN,
491/// so we find bare integer values on YAML lines and append `.0` if they overflow.
492fn sanitize_large_yaml_integers(content: &str) -> String {
493    let mut result = String::with_capacity(content.len());
494    for line in content.lines() {
495        if let Some(sanitized) = try_sanitize_integer_line(line) {
496            result.push_str(&sanitized);
497        } else {
498            result.push_str(line);
499        }
500        result.push('\n');
501    }
502    result
503}
504
505/// If a YAML line has a `key: <integer>` pattern where the integer overflows i64/u64,
506/// convert it to float by appending `.0`. Returns None if no change needed.
507fn try_sanitize_integer_line(line: &str) -> Option<String> {
508    // Match pattern: optional whitespace, key, colon, space(s), then a number value
509    // We look for the value portion after the last `: ` or `- ` on the line
510    let trimmed = line.trim();
511
512    // Skip comments and empty lines
513    if trimmed.is_empty() || trimmed.starts_with('#') {
514        return None;
515    }
516
517    // Find the value part — after `: ` for mapping entries
518    let colon_pos = line.find(": ")?;
519    let value_start = colon_pos + 2;
520    let value_str = line[value_start..].trim();
521
522    // Check if the value looks like a bare integer (optional leading minus, then digits)
523    if value_str.is_empty() {
524        return None;
525    }
526
527    let (is_negative, digit_part) = if let Some(rest) = value_str.strip_prefix('-') {
528        (true, rest)
529    } else {
530        (false, value_str)
531    };
532
533    // Must be all digits
534    if !digit_part.chars().all(|c| c.is_ascii_digit()) || digit_part.is_empty() {
535        return None;
536    }
537
538    // Check if it overflows i64/u64
539    let overflows = if is_negative {
540        // Check if |value| > i64::MAX + 1 = 9223372036854775808
541        digit_part.len() > 19 || (digit_part.len() == 19 && digit_part > "9223372036854775808")
542    } else {
543        // Check if value > u64::MAX = 18446744073709551615
544        digit_part.len() > 20 || (digit_part.len() == 20 && digit_part > "18446744073709551615")
545    };
546
547    if overflows {
548        // Replace the integer with float notation
549        let mut sanitized = line[..value_start].to_string();
550        sanitized.push_str(value_str);
551        sanitized.push_str(".0");
552        Some(sanitized)
553    } else {
554        None
555    }
556}