Skip to main content

openapi_to_rust/
cli.rs

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
9/// Maximum accepted size for a remotely fetched OpenAPI document (64 MiB).
10pub const MAX_REMOTE_SPEC_BYTES: u64 = 64 * 1024 * 1024;
11/// End-to-end timeout for fetching a remote OpenAPI document.
12pub const REMOTE_SPEC_TIMEOUT: Duration = Duration::from_secs(30);
13
14/// Configuration for the CLI helper
15pub struct CliConfig {
16    /// Name of the API for display purposes
17    pub api_name: &'static str,
18    /// Default module name
19    pub default_module_name: &'static str,
20    /// Streaming configuration for this API
21    pub streaming_config: Option<StreamingConfig>,
22    /// Enable Specta type derives for frontend integration
23    pub enable_specta: bool,
24}
25
26/// Run the legacy embedded generation CLI with the provided configuration.
27#[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    // Load OpenAPI spec
97    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    // Parse the spec
110    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    // Version gate: surface unsupported OAS major.minor early. We support
119    // 3.0.x and 3.1.x first-class; 3.2.x parses (extensions on the spec model
120    // capture the new fields), but several 3.2-only behaviors are still TODO,
121    // so warn loudly. See https://github.com/gpu-cli/openapi-to-rust/issues/14.
122    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    // Analyze schemas
165    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    // Generate code
195    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        // Write all files to disk
234        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
259/// Load a local or remote OpenAPI document with bounded remote I/O.
260///
261/// HTTPS is accepted everywhere. Plain HTTP is accepted only for loopback
262/// hosts so local development and deterministic integration tests do not need
263/// a certificate while non-local traffic remains encrypted.
264pub 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        // Redirects are intentionally disabled: an allowed HTTPS URL could
280        // otherwise redirect to disallowed plaintext transport.
281        .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}