use clap::{Parser, Subcommand};
use openapi_to_rust::cli::{json_from_str_lossy, yaml_to_json_value};
use openapi_to_rust::server::{
OperationIndex, Selector,
edit::Editor as ServerEditor,
list::{ListFilter, ListOutput, render as render_list},
resolve as resolve_selectors,
};
use openapi_to_rust::{CodeGenerator, ConfigFile, SchemaAnalyzer};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "openapi-to-rust")]
#[command(about = "Generate Rust types and clients from OpenAPI specs")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Generate {
#[arg(short, long, default_value = "openapi-to-rust.toml")]
config: PathBuf,
#[arg(long)]
types_conservative: bool,
},
Validate {
#[arg(short, long, default_value = "openapi-to-rust.toml")]
config: PathBuf,
},
Server {
#[command(subcommand)]
action: ServerCommands,
},
}
#[derive(Subcommand)]
enum ServerCommands {
List {
#[arg(long)]
spec: Option<PathBuf>,
#[arg(long, default_value = "openapi-to-rust.toml")]
config: PathBuf,
#[arg(long)]
tag: Option<String>,
#[arg(long)]
method: Option<String>,
#[arg(long)]
grep: Option<String>,
#[arg(long)]
json: bool,
},
Add {
selector: Option<String>,
#[arg(long)]
spec: Option<PathBuf>,
#[arg(long, default_value = "openapi-to-rust.toml")]
config: PathBuf,
#[arg(long)]
all_tag: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
regenerate: bool,
},
Remove {
selector: String,
#[arg(long, default_value = "openapi-to-rust.toml")]
config: PathBuf,
#[arg(long)]
dry_run: bool,
},
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
if let Err(e) = run(cli).await {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
match cli.command {
Commands::Validate { config } => {
println!("📖 Validating configuration from: {}", config.display());
match ConfigFile::load(&config) {
Ok(_config_file) => {
println!("✅ Configuration is valid!");
Ok(())
}
Err(e) => {
eprintln!("❌ Configuration validation failed:");
eprintln!("{}", e);
std::process::exit(1);
}
}
}
Commands::Generate {
config,
types_conservative,
} => {
println!("📖 Reading configuration from: {}", config.display());
let config_file = match ConfigFile::load(&config) {
Ok(cf) => cf,
Err(e) => {
eprintln!("❌ Failed to load configuration:");
eprintln!("{}", e);
std::process::exit(1);
}
};
let mut generator_config = config_file.into_generator_config();
if types_conservative {
generator_config.types = openapi_to_rust::TypeMappingConfig::conservative();
}
println!(
"📄 Reading OpenAPI spec: {}",
generator_config.spec_path.display()
);
let spec_content = std::fs::read_to_string(&generator_config.spec_path)?;
let spec_value: serde_json::Value = if generator_config.spec_path.extension()
== Some(std::ffi::OsStr::new("yaml"))
|| generator_config.spec_path.extension() == Some(std::ffi::OsStr::new("yml"))
{
yaml_to_json_value(&spec_content)?
} else {
json_from_str_lossy(&spec_content)?
};
let oas_version = spec_value
.get("openapi")
.and_then(|v| v.as_str())
.unwrap_or("");
match openapi_to_rust::cli::parse_oas_version(oas_version) {
Some((3, 0)) | Some((3, 1)) => {}
Some((3, 2)) => {
eprintln!("⚠️ OpenAPI {oas_version}: 3.2 is experimentally supported.");
}
Some((major, minor)) => {
eprintln!(
"❌ Unsupported OpenAPI version: {major}.{minor} ({oas_version:?}). \
This generator targets 3.0.x, 3.1.x, and (experimentally) 3.2.x. \
Swagger 2.0 and OAS 1.x are not supported."
);
std::process::exit(1);
}
None => {
let hint = if spec_value.get("swagger").is_some() {
" (looks like Swagger 2.0 — out of scope)"
} else {
""
};
eprintln!(
"❌ Missing or unrecognised `openapi` field{hint}. Expected something like \"3.1.0\", got: {oas_version:?}"
);
std::process::exit(1);
}
}
println!("🔍 Analyzing schemas...");
let type_mapper = openapi_to_rust::TypeMapper::new(generator_config.types.clone());
let mut analyzer = if generator_config.schema_extensions.is_empty() {
SchemaAnalyzer::with_type_mapper(spec_value, type_mapper)?
} else {
println!(
"📎 Merging {} schema extension(s)",
generator_config.schema_extensions.len()
);
SchemaAnalyzer::new_with_extensions_and_type_mapper(
spec_value,
&generator_config.schema_extensions,
type_mapper,
)?
};
let mut analysis = analyzer.analyze()?;
println!("📊 Found {} schemas", analysis.schemas.len());
println!("📊 Found {} operations", analysis.operations.len());
if let Some(server_section) = generator_config.server.as_ref()
&& server_section.prune_models
&& !server_section.operations.is_empty()
{
let pruned_count = prune_analysis_models(&mut analysis, server_section)?;
println!(
"✂️ Pruned {pruned_count} schema(s) outside the server scope ({} remain)",
analysis.schemas.len()
);
if generator_config.enable_async_client || generator_config.enable_sse_client {
eprintln!(
"⚠️ prune_models = true: the HTTP client will only see types \
reachable from picked server operations. Set prune_models = false \
or extend [server].operations to keep additional types."
);
}
}
println!("⚙️ Generating code...");
let generator = CodeGenerator::new(generator_config);
let result = generator.generate_all(&mut analysis)?;
generator.write_files(&result)?;
println!(
"✅ Generated {} files to {}",
result.files.len(),
generator.config().output_dir.display()
);
if let Some(server_section) = generator.config().server.as_ref() {
if !server_section.operations.is_empty() {
use openapi_to_rust::server::codegen::ServerCodegen;
println!("⚙️ Generating server scaffolding (axum)...");
let server_files =
ServerCodegen::new(generator.config(), &analysis, server_section)
.generate()?;
let out = generator.config().output_dir.clone();
let server_dir = out.join("server");
std::fs::create_dir_all(&server_dir)?;
for f in &server_files {
let path = out.join(&f.path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, &f.content)?;
}
let mod_path = out.join("mod.rs");
if mod_path.exists() {
let body = std::fs::read_to_string(&mod_path)?;
if !body.contains("pub mod server") {
let mut updated = body;
if !updated.ends_with('\n') {
updated.push('\n');
}
updated.push_str("\npub mod server;\npub use server::*;\n");
std::fs::write(&mod_path, updated)?;
}
}
println!(
"✅ Wrote {} server files to {}/server/",
server_files.len(),
out.display()
);
print_server_hint(&analysis, server_section);
}
}
if !result.required_deps.is_empty() {
eprintln!();
eprintln!(
"📦 Generated code uses {} optional crate(s). Add to your Cargo.toml:",
result.required_deps.len()
);
eprintln!();
eprintln!("[dependencies]");
for dep in &result.required_deps {
eprintln!("{}", dep.to_toml_line());
}
eprintln!();
eprintln!(
"(Same content written to {}/REQUIRED_DEPS.toml)",
generator.config().output_dir.display()
);
}
Ok(())
}
Commands::Server { action } => match action {
ServerCommands::List {
spec,
config,
tag,
method,
grep,
json,
} => run_server_list(spec, config, tag, method, grep, json),
ServerCommands::Add {
selector,
spec,
config,
all_tag,
dry_run,
regenerate,
} => run_server_add(selector, spec, config, all_tag, dry_run, regenerate),
ServerCommands::Remove {
selector,
config,
dry_run,
} => run_server_remove(selector, config, dry_run),
},
}
}
fn resolve_spec_path(
spec: Option<PathBuf>,
config: &std::path::Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
match spec {
Some(p) => Ok(p),
None => {
let cf = ConfigFile::load(config).map_err(|e| {
format!(
"no --spec provided and failed to load {}: {}",
config.display(),
e
)
})?;
Ok(cf.into_generator_config().spec_path)
}
}
}
fn load_analysis(
spec_path: &std::path::Path,
) -> Result<openapi_to_rust::SchemaAnalysis, Box<dyn std::error::Error>> {
let spec_content = std::fs::read_to_string(spec_path)?;
let spec_value: serde_json::Value = if spec_path.extension()
== Some(std::ffi::OsStr::new("yaml"))
|| spec_path.extension() == Some(std::ffi::OsStr::new("yml"))
{
yaml_to_json_value(&spec_content)?
} else {
json_from_str_lossy(&spec_content)?
};
let mut analyzer = SchemaAnalyzer::new(spec_value)?;
Ok(analyzer.analyze()?)
}
fn run_server_list(
spec: Option<PathBuf>,
config: PathBuf,
tag: Option<String>,
method: Option<String>,
grep: Option<String>,
json: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let spec_path = resolve_spec_path(spec, &config)?;
let analysis = load_analysis(&spec_path)?;
let index = OperationIndex::from_analysis(&analysis);
let filter = ListFilter { tag, method, grep };
let output = if json {
ListOutput::Json
} else {
ListOutput::Table
};
let (body, _count) = render_list(&index, &filter, output);
print!("{body}");
Ok(())
}
fn run_server_add(
selector: Option<String>,
spec: Option<PathBuf>,
config: PathBuf,
all_tag: Option<String>,
dry_run: bool,
regenerate: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let spec_path = resolve_spec_path(spec, &config)?;
let analysis = load_analysis(&spec_path)?;
let index = OperationIndex::from_analysis(&analysis);
let to_add: Vec<String> = match (&selector, &all_tag) {
(Some(_), Some(_)) => {
return Err("provide either <selector> or --all-tag, not both".into());
}
(None, None) => {
return Err("missing argument: provide <selector> or --all-tag <name>".into());
}
(Some(s), None) => vec![s.clone()],
(None, Some(tag)) => {
let sel = Selector::Tag(tag.clone());
let res = resolve_selectors(&[sel], &index)?;
res.operations
.iter()
.map(|op| op.operation_id.clone())
.collect()
}
};
for s in &to_add {
let parsed = Selector::parse(s)?;
let _ = resolve_selectors(&[parsed], &index)?;
}
let mut editor = ServerEditor::open(&config)?;
let mut added: Vec<String> = Vec::new();
let mut already_present: Vec<String> = Vec::new();
for s in &to_add {
if editor.add(s)? {
added.push(s.clone());
} else {
already_present.push(s.clone());
}
}
if dry_run {
println!("--- dry-run: proposed config ---");
print!("{}", editor.rendered());
println!("--- end ---");
} else {
editor.save()?;
}
for s in &added {
print_add_summary(s, &analysis, &index)?;
}
if !already_present.is_empty() {
for s in &already_present {
println!("• `{s}` already in [server].operations — no change.");
}
}
if !dry_run && !added.is_empty() {
let next_step = if regenerate {
"Regenerating now..."
} else {
"Run `openapi-to-rust generate` to emit code."
};
println!(
"\n✓ Added {} entr{} to {}. {next_step}",
added.len(),
if added.len() == 1 { "y" } else { "ies" },
config.display(),
);
if regenerate {
let exe = std::env::current_exe()?;
let status = std::process::Command::new(exe)
.arg("generate")
.arg("--config")
.arg(&config)
.status()?;
if !status.success() {
return Err(format!("regenerate failed with status {status}").into());
}
}
}
Ok(())
}
fn print_add_summary(
selector_str: &str,
analysis: &openapi_to_rust::SchemaAnalysis,
index: &OperationIndex,
) -> Result<(), Box<dyn std::error::Error>> {
let parsed = Selector::parse(selector_str)?;
let res = resolve_selectors(&[parsed], index)?;
for op in &res.operations {
let info = analysis
.operations
.get(&op.operation_id)
.ok_or("operation found in index but missing from analysis")?;
let tag_part = if op.tags.is_empty() {
"<untagged>".to_string()
} else {
op.tags.join(",")
};
println!(
"\n+ `{}`\n {} {} (tag: {})",
selector_str, op.method, op.path, tag_part
);
if let Some(rb) = &info.request_body {
if let Some(name) = rb.schema_name() {
println!(" Request: {name}");
} else {
println!(" Request: (non-JSON body)");
}
} else {
println!(" Request: (none)");
}
if !info.response_schemas.is_empty() {
let mut parts: Vec<String> = info
.response_schemas
.iter()
.map(|(code, ty)| format!("{code}={ty}"))
.collect();
parts.sort();
println!(" Response: {}", parts.join(" "));
}
println!(
" Streaming: {}",
if op.supports_streaming { "yes" } else { "no" }
);
}
Ok(())
}
fn prune_analysis_models(
analysis: &mut openapi_to_rust::SchemaAnalysis,
server: &openapi_to_rust::config::ServerSection,
) -> Result<usize, Box<dyn std::error::Error>> {
use openapi_to_rust::server::OperationIndex;
use openapi_to_rust::server::codegen::reachable_schemas;
let index = OperationIndex::from_analysis(analysis);
let selectors: Vec<Selector> = server
.operations
.iter()
.map(|s| Selector::parse(s))
.collect::<Result<_, _>>()?;
let resolution = resolve_selectors(&selectors, &index)?;
let ops: Vec<&openapi_to_rust::analysis::OperationInfo> = resolution
.operations
.iter()
.filter_map(|s| analysis.operations.get(&s.operation_id))
.collect();
let keep = reachable_schemas(analysis, &ops);
let before = analysis.schemas.len();
analysis.schemas.retain(|k, _| keep.contains(k));
Ok(before - analysis.schemas.len())
}
fn print_server_hint(
analysis: &openapi_to_rust::SchemaAnalysis,
server: &openapi_to_rust::config::ServerSection,
) {
use heck::{ToPascalCase, ToSnakeCase};
let first_op_id = server.operations.first().and_then(|raw| {
Selector::parse(raw).ok().and_then(|sel| match sel {
Selector::OperationId(id) => Some(id),
Selector::MethodPath { method, path } => analysis
.operations
.values()
.find(|op| op.method == method && op.path == path)
.map(|op| op.operation_id.clone()),
Selector::Tag(t) => analysis
.operations
.values()
.find(|op| op.tags.iter().any(|tag| tag == &t))
.map(|op| op.operation_id.clone()),
})
});
let Some(first_op_id) = first_op_id else {
return;
};
let Some(op) = analysis.operations.get(&first_op_id) else {
return;
};
let method = op.operation_id.to_snake_case();
let response_ty = format!("{}Response", op.operation_id.to_pascal_case());
let body_param = match &op.request_body {
Some(rb) => match rb.schema_name() {
Some(name) => format!(", body: {name}"),
None => String::new(),
},
None => String::new(),
};
let tag = op.tags.first().cloned().unwrap_or_else(|| "Server".into());
let trait_name = format!("{}Api", tag.to_pascal_case());
let router_fn = format!("{}_router", trait_name.to_snake_case());
eprintln!();
eprintln!("📝 Next step — implement the trait:");
eprintln!();
eprintln!(" #[derive(Clone)]");
eprintln!(" pub struct AppState {{ /* state goes here */ }}");
eprintln!();
eprintln!(" #[axum::async_trait]");
eprintln!(" impl {trait_name} for AppState {{");
eprintln!(" async fn {method}(&self{body_param}) -> {response_ty} {{");
eprintln!(" todo!()");
eprintln!(" }}");
eprintln!(" }}");
eprintln!();
eprintln!(" // In main():");
eprintln!(" let app = {router_fn}(AppState {{ /* … */ }});");
eprintln!();
if op.supports_streaming {
eprintln!(" For streaming, return `{response_ty}::OkStream(sse_response(your_stream))`.");
}
}
fn run_server_remove(
selector: String,
config: PathBuf,
dry_run: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let mut editor = ServerEditor::open(&config)?;
let removed = editor.remove(&selector)?;
if !removed {
println!("• `{selector}` not present in [server].operations — no change.");
return Ok(());
}
if dry_run {
println!("--- dry-run: proposed config ---");
print!("{}", editor.rendered());
println!("--- end ---");
} else {
editor.save()?;
println!(
"✓ Removed `{selector}` from {}. Handler code in your crate may now be dead — review.",
config.display()
);
}
Ok(())
}