use crate::{CodeGenerator, GeneratorConfig, SchemaAnalyzer, streaming::StreamingConfig};
use clap::{Arg, Command};
use std::fs;
use std::io::Read;
use std::process;
use std::time::Duration;
pub const MAX_REMOTE_SPEC_BYTES: u64 = 64 * 1024 * 1024;
pub const REMOTE_SPEC_TIMEOUT: Duration = Duration::from_secs(30);
pub struct CliConfig {
pub api_name: &'static str,
pub default_module_name: &'static str,
pub streaming_config: Option<StreamingConfig>,
pub enable_specta: bool,
}
#[deprecated(note = "use the openapi-to-rust binary's generate command")]
pub async fn run_generation_cli(cli_config: CliConfig) {
let matches = Command::new("api-gen")
.version("0.1.0")
.about("Generate API types and streaming client")
.arg(
Arg::new("input")
.help("Input OpenAPI spec (file path or URL)")
.required(true)
.index(1),
)
.arg(
Arg::new("output-dir")
.long("output-dir")
.value_name("DIR")
.help("Output directory for generated files (default: src/generated)")
.default_value("src/generated"),
)
.arg(
Arg::new("module-name")
.short('m')
.long("module-name")
.value_name("NAME")
.help("Generated module name")
.default_value(cli_config.default_module_name),
)
.arg(
Arg::new("verbose")
.short('v')
.long("verbose")
.help("Enable verbose output")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("dry-run")
.long("dry-run")
.help("Print generated code to stdout instead of writing to file")
.action(clap::ArgAction::SetTrue),
)
.get_matches();
let Some(input) = matches.get_one::<String>("input") else {
eprintln!("Error: missing required argument 'input'");
process::exit(1);
};
let Some(output_dir) = matches.get_one::<String>("output-dir") else {
eprintln!("Error: missing required argument 'output-dir'");
process::exit(1);
};
let Some(module_name) = matches.get_one::<String>("module-name") else {
eprintln!("Error: missing required argument 'module-name'");
process::exit(1);
};
let verbose = matches.get_flag("verbose");
let dry_run = matches.get_flag("dry-run");
if verbose {
println!("🚀 {} API Generator", cli_config.api_name);
println!("Input: {input}");
if !dry_run {
println!("Output: {output_dir}");
}
println!("Module: {module_name}");
if cli_config.streaming_config.is_some() {
println!("🌊 Streaming: enabled");
}
println!();
}
let spec_content = match load_spec(input) {
Ok(content) => content,
Err(e) => {
eprintln!("❌ Error loading spec: {e}");
process::exit(1);
}
};
if verbose {
println!("📄 Loaded OpenAPI spec ({} bytes)", spec_content.len());
}
let spec_value: serde_json::Value = match parse_spec(&spec_content, input) {
Ok(value) => value,
Err(e) => {
eprintln!("❌ Error parsing spec: {e}");
process::exit(1);
}
};
let oas_version = spec_value
.get("openapi")
.and_then(|v| v.as_str())
.unwrap_or("");
if let Some((major, minor)) = parse_oas_version(oas_version) {
match (major, minor) {
(3, 0) | (3, 1) => {}
(3, 2) => {
eprintln!(
"⚠️ OpenAPI {oas_version}: 3.2 is experimentally supported. \
Some 3.2-only features (additionalOperations, query method, \
itemSchema, $self, defaultMapping) are not yet wired through codegen. \
See issue #14 for status."
);
}
_ => {
eprintln!(
"❌ Unsupported OpenAPI version: {oas_version}. \
This generator targets 3.0.x, 3.1.x, and (experimentally) 3.2.x."
);
process::exit(1);
}
}
} else {
eprintln!(
"❌ Missing or unrecognised `openapi` field. Expected something like \"3.1.0\", got: {oas_version:?}"
);
process::exit(1);
}
if verbose {
if let Some(info) = spec_value.get("info") {
if let Some(title) = info.get("title").and_then(|t| t.as_str()) {
println!("📋 Title: {title}");
}
if let Some(version) = info.get("version").and_then(|v| v.as_str()) {
println!("🏷️ Version: {version}");
}
}
println!();
}
if verbose {
println!("🔍 Analyzing schemas...");
}
let mut analyzer = match SchemaAnalyzer::new(spec_value) {
Ok(analyzer) => analyzer,
Err(e) => {
eprintln!("❌ Error creating analyzer: {e}");
process::exit(1);
}
};
let mut analysis = match analyzer.analyze() {
Ok(analysis) => analysis,
Err(e) => {
eprintln!("❌ Error analyzing schemas: {e}");
process::exit(1);
}
};
if verbose {
println!("📈 Found {} schemas", analysis.schemas.len());
println!("📈 Found {} operations", analysis.operations.len());
if let Some(ref config) = cli_config.streaming_config {
println!("🌊 Found {} streaming endpoints", config.endpoints.len());
}
println!();
}
if verbose {
let stream_status = if cli_config.streaming_config.is_some() {
"with streaming support"
} else {
""
};
println!(
"⚙️ Generating {} API code {}...",
cli_config.api_name, stream_status
);
}
let config = GeneratorConfig {
module_name: module_name.clone(),
output_dir: output_dir.into(),
streaming_config: cli_config.streaming_config,
enable_specta: cli_config.enable_specta,
..Default::default()
};
let generator = CodeGenerator::new(config);
let generation_result = match generator.generate_all(&mut analysis) {
Ok(result) => result,
Err(e) => {
eprintln!("❌ Error generating code: {e}");
process::exit(1);
}
};
if dry_run {
println!("=== Generated Files ===");
for file in &generation_result.files {
println!("\n--- {} ---", file.path.display());
println!("{}", file.content);
}
println!("\n--- {} ---", generation_result.mod_file.path.display());
println!("{}", generation_result.mod_file.content);
} else {
if let Err(e) = generator.write_files(&generation_result) {
eprintln!("❌ Error writing files: {e}");
process::exit(1);
}
if verbose {
println!(
"✅ Generated {} files written to: {}",
generation_result.files.len() + 1,
generator.config().output_dir.display()
);
for file in &generation_result.files {
println!(" - {}", file.path.display());
}
println!(" - {}", generation_result.mod_file.path.display());
} else {
println!(
"✅ Generated {} files written to: {}",
generation_result.files.len() + 1,
generator.config().output_dir.display()
);
}
}
}
pub fn is_remote_spec(input: &str) -> bool {
reqwest::Url::parse(input).is_ok_and(|url| matches!(url.scheme(), "https" | "http"))
}
pub fn validate_remote_spec_url(input: &str) -> Result<reqwest::Url, String> {
let url = reqwest::Url::parse(input)
.map_err(|error| format!("invalid remote OpenAPI URL: {error}"))?;
if !url.username().is_empty() || url.password().is_some() {
return Err("remote OpenAPI URLs must not contain embedded credentials".to_string());
}
match url.scheme() {
"https" => Ok(url),
"http" if is_loopback_host(url.host_str()) => Ok(url),
"http" => Err(
"remote OpenAPI URLs must use HTTPS (plain HTTP is allowed only for localhost/loopback)"
.to_string(),
),
scheme => Err(format!(
"unsupported OpenAPI URL scheme `{scheme}`; use HTTPS or a local file path"
)),
}
}
pub fn sanitize_source_provenance(input: &str) -> String {
let sanitize_controls = |value: &str| {
value
.chars()
.map(|character| {
if character.is_control() {
'�'
} else {
character
}
})
.collect::<String>()
};
let Ok(mut url) = reqwest::Url::parse(input) else {
return sanitize_controls(input);
};
if !matches!(url.scheme(), "https" | "http") {
return sanitize_controls(input);
}
let query_was_redacted = url.query().is_some();
let _ = url.set_username("");
let _ = url.set_password(None);
url.set_query(None);
url.set_fragment(None);
let mut label = url.to_string();
if query_was_redacted {
label.push_str(" (query redacted)");
}
sanitize_controls(&label)
}
pub fn load_spec(input: &str) -> Result<String, Box<dyn std::error::Error>> {
if !is_remote_spec(input) {
if input.contains("://") {
return match validate_remote_spec_url(input) {
Ok(_) => Err("unsupported remote OpenAPI URL".into()),
Err(error) => Err(error.into()),
};
}
return Ok(fs::read_to_string(input)?);
}
let url = validate_remote_spec_url(input)?;
let client = reqwest::blocking::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(REMOTE_SPEC_TIMEOUT)
.redirect(reqwest::redirect::Policy::none())
.user_agent(concat!("openapi-to-rust/", env!("CARGO_PKG_VERSION")))
.build()?;
let response = client.get(url).send().map_err(|error| {
format!(
"failed to fetch remote OpenAPI document: {}",
error.without_url()
)
})?;
if !response.status().is_success() {
return Err(format!(
"failed to fetch OpenAPI document: HTTP {}",
response.status()
)
.into());
}
if response
.content_length()
.is_some_and(|length| length > MAX_REMOTE_SPEC_BYTES)
{
return Err(format!(
"remote OpenAPI document exceeds the {} MiB response-size limit",
MAX_REMOTE_SPEC_BYTES / 1024 / 1024
)
.into());
}
let mut bytes = Vec::new();
response
.take(MAX_REMOTE_SPEC_BYTES + 1)
.read_to_end(&mut bytes)?;
if bytes.len() as u64 > MAX_REMOTE_SPEC_BYTES {
return Err(format!(
"remote OpenAPI document exceeds the {} MiB response-size limit",
MAX_REMOTE_SPEC_BYTES / 1024 / 1024
)
.into());
}
Ok(String::from_utf8(bytes)?)
}
fn is_loopback_host(host: Option<&str>) -> bool {
match host {
Some("localhost") => true,
Some(host) => host
.parse::<std::net::IpAddr>()
.is_ok_and(|address| address.is_loopback()),
None => false,
}
}
pub fn parse_oas_version(s: &str) -> Option<(u32, u32)> {
let mut parts = s.split('.');
let major = parts.next()?.parse().ok()?;
let minor_raw = parts.next()?;
let minor_digits: String = minor_raw
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
let minor = minor_digits.parse().ok()?;
Some((major, minor))
}
pub fn parse_spec(
content: &str,
input: &str,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let is_yaml = input.ends_with(".yaml")
|| input.ends_with(".yml")
|| content.trim_start().starts_with("openapi:")
|| content.trim_start().starts_with("swagger:");
if is_yaml {
let value = yaml_to_json_value(content)?;
Ok(value)
} else {
let value = json_from_str_lossy(content)?;
Ok(value)
}
}
pub fn yaml_to_json_value(content: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let preprocessed = sanitize_large_yaml_integers(content);
let yaml_value: serde_yaml::Value = serde_yaml::from_str(&preprocessed)?;
Ok(yaml_value_to_json(yaml_value))
}
pub fn json_from_str_lossy(content: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
match serde_json::from_str::<serde_json::Value>(content) {
Ok(v) => Ok(v),
Err(e) => {
let err_msg = e.to_string();
if err_msg.contains("number out of range") {
let yaml_value: serde_yaml::Value = serde_yaml::from_str(content)?;
Ok(yaml_value_to_json(yaml_value))
} else {
Err(e.into())
}
}
}
}
fn yaml_value_to_json(yaml: serde_yaml::Value) -> serde_json::Value {
match yaml {
serde_yaml::Value::Null => serde_json::Value::Null,
serde_yaml::Value::Bool(b) => serde_json::Value::Bool(b),
serde_yaml::Value::Number(n) => {
if let Some(i) = n.as_i64() {
serde_json::Value::Number(i.into())
} else if let Some(u) = n.as_u64() {
serde_json::Value::Number(u.into())
} else if let Some(f) = n.as_f64() {
serde_json::json!(f)
} else {
serde_json::json!(0.0)
}
}
serde_yaml::Value::String(s) => serde_json::Value::String(s),
serde_yaml::Value::Sequence(seq) => {
serde_json::Value::Array(seq.into_iter().map(yaml_value_to_json).collect())
}
serde_yaml::Value::Mapping(map) => {
let obj = map
.into_iter()
.filter_map(|(k, v)| {
let key = match k {
serde_yaml::Value::String(s) => s,
serde_yaml::Value::Number(n) => n.to_string(),
serde_yaml::Value::Bool(b) => b.to_string(),
_ => return None,
};
Some((key, yaml_value_to_json(v)))
})
.collect();
serde_json::Value::Object(obj)
}
serde_yaml::Value::Tagged(tagged) => yaml_value_to_json(tagged.value),
}
}
fn sanitize_large_yaml_integers(content: &str) -> String {
let mut result = String::with_capacity(content.len());
for line in content.lines() {
if let Some(sanitized) = try_sanitize_integer_line(line) {
result.push_str(&sanitized);
} else {
result.push_str(line);
}
result.push('\n');
}
result
}
fn try_sanitize_integer_line(line: &str) -> Option<String> {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
return None;
}
let colon_pos = line.find(": ")?;
let value_start = colon_pos + 2;
let value_str = line[value_start..].trim();
if value_str.is_empty() {
return None;
}
let (is_negative, digit_part) = if let Some(rest) = value_str.strip_prefix('-') {
(true, rest)
} else {
(false, value_str)
};
if !digit_part.chars().all(|c| c.is_ascii_digit()) || digit_part.is_empty() {
return None;
}
let overflows = if is_negative {
digit_part.len() > 19 || (digit_part.len() == 19 && digit_part > "9223372036854775808")
} else {
digit_part.len() > 20 || (digit_part.len() == 20 && digit_part > "18446744073709551615")
};
if overflows {
let mut sanitized = line[..value_start].to_string();
sanitized.push_str(value_str);
sanitized.push_str(".0");
Some(sanitized)
} else {
None
}
}