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
8pub const MAX_REMOTE_SPEC_BYTES: u64 = 64 * 1024 * 1024;
10pub const REMOTE_SPEC_TIMEOUT: Duration = Duration::from_secs(30);
12
13pub struct CliConfig {
15 pub api_name: &'static str,
17 pub default_module_name: &'static str,
19 pub streaming_config: Option<StreamingConfig>,
21 pub enable_specta: bool,
23}
24
25#[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 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 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 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 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 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 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
258pub fn is_remote_spec(input: &str) -> bool {
260 reqwest::Url::parse(input).is_ok_and(|url| matches!(url.scheme(), "https" | "http"))
261}
262
263pub 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
283pub 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
316pub 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 .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
389pub 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 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
422pub 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
432pub fn json_from_str_lossy(content: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
434 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 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 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
489fn 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
505fn try_sanitize_integer_line(line: &str) -> Option<String> {
508 let trimmed = line.trim();
511
512 if trimmed.is_empty() || trimmed.starts_with('#') {
514 return None;
515 }
516
517 let colon_pos = line.find(": ")?;
519 let value_start = colon_pos + 2;
520 let value_str = line[value_start..].trim();
521
522 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 if !digit_part.chars().all(|c| c.is_ascii_digit()) || digit_part.is_empty() {
535 return None;
536 }
537
538 let overflows = if is_negative {
540 digit_part.len() > 19 || (digit_part.len() == 19 && digit_part > "9223372036854775808")
542 } else {
543 digit_part.len() > 20 || (digit_part.len() == 20 && digit_part > "18446744073709551615")
545 };
546
547 if overflows {
548 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}