use anyhow::{Context, Result};
use clap::Args;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Args, Debug)]
pub struct ClientArgs {
#[arg(short, long)]
pub spec: String,
#[arg(short, long, default_value = "./generated")]
pub output: PathBuf,
#[arg(short, long, default_value = "rust")]
pub language: String,
#[arg(short, long)]
pub name: Option<String>,
}
pub async fn client(args: ClientArgs) -> Result<()> {
println!("🔧 Generating API client from OpenAPI spec...");
println!(" Spec: {}", args.spec);
println!(" Language: {}", args.language);
println!(" Output: {}", args.output.display());
fs::create_dir_all(&args.output).context("Failed to create output directory")?;
let spec_content = load_spec(&args.spec).await?;
let spec: serde_json::Value = if args.spec.ends_with(".yaml") || args.spec.ends_with(".yml") {
serde_yaml::from_str(&spec_content).context("Failed to parse YAML spec")?
} else {
serde_json::from_str(&spec_content).context("Failed to parse JSON spec")?
};
let title = spec["info"]["title"].as_str().unwrap_or("api");
let version = spec["info"]["version"].as_str().unwrap_or("0.1.0");
let client_name = args.name.unwrap_or_else(|| sanitize_name(title));
println!(" API: {} v{}", title, version);
println!(" Client name: {}", client_name);
match args.language.as_str() {
"rust" => generate_rust_client(&args.output, &client_name, &spec).await?,
"typescript" | "ts" => {
generate_typescript_client(&args.output, &client_name, &spec).await?
}
"python" | "py" => generate_python_client(&args.output, &client_name, &spec).await?,
lang => anyhow::bail!(
"Unsupported language: {}. Use rust, typescript, or python.",
lang
),
}
println!("✅ Client generated successfully!");
Ok(())
}
async fn load_spec(spec_path: &str) -> Result<String> {
if spec_path.starts_with("http://") || spec_path.starts_with("https://") {
#[cfg(feature = "remote-spec")]
{
let response = reqwest::get(spec_path)
.await
.context("Failed to fetch OpenAPI spec from URL")?;
response
.text()
.await
.context("Failed to read response body")
}
#[cfg(not(feature = "remote-spec"))]
{
anyhow::bail!(
"Remote spec loading requires the 'remote-spec' feature. Use a local file instead."
)
}
} else {
fs::read_to_string(spec_path).context("Failed to read OpenAPI spec file")
}
}
fn sanitize_name(name: &str) -> String {
name.to_lowercase()
.replace([' ', '-'], "_")
.chars()
.filter(|c| c.is_alphanumeric() || *c == '_')
.collect()
}
async fn generate_rust_client(output: &Path, name: &str, spec: &serde_json::Value) -> Result<()> {
let src_dir = output.join("src");
fs::create_dir_all(&src_dir)?;
let cargo_toml = format!(
r#"[package]
name = "{name}"
version = "0.1.0"
edition = "2021"
[dependencies]
reqwest = {{ version = "0.12", features = ["json"] }}
serde = {{ version = "1", features = ["derive"] }}
serde_json = "1"
thiserror = "2"
tokio = {{ version = "1", features = ["full"] }}
"#
);
fs::write(output.join("Cargo.toml"), cargo_toml)?;
let base_url = get_base_url(spec);
let endpoints = generate_rust_endpoints(spec);
let models = generate_rust_models(spec);
let lib_rs = format!(
r#"//! Generated API client for {name}
//!
//! Auto-generated by RustAPI CLI
use reqwest::{{Client, Response}};
use serde::{{Deserialize, Serialize}};
use thiserror::Error;
/// API client errors
#[derive(Error, Debug)]
pub enum ApiError {{
#[error("HTTP error: {{0}}")]
Http(#[from] reqwest::Error),
#[error("API error: {{status}} - {{message}}")]
Api {{ status: u16, message: String }},
}}
/// API client
pub struct ApiClient {{
client: Client,
base_url: String,
}}
impl Default for ApiClient {{
fn default() -> Self {{
Self::new("{base_url}")
}}
}}
impl ApiClient {{
/// Create a new API client with the given base URL
pub fn new(base_url: impl Into<String>) -> Self {{
Self {{
client: Client::new(),
base_url: base_url.into(),
}}
}}
/// Create with custom reqwest client
pub fn with_client(client: Client, base_url: impl Into<String>) -> Self {{
Self {{
client,
base_url: base_url.into(),
}}
}}
{endpoints}
}}
// Models
{models}
"#
);
fs::write(src_dir.join("lib.rs"), lib_rs)?;
println!(" Generated Rust client crate");
Ok(())
}
fn get_base_url(spec: &serde_json::Value) -> String {
spec["servers"]
.as_array()
.and_then(|s| s.first())
.and_then(|s| s["url"].as_str())
.unwrap_or("http://localhost:8080")
.to_string()
}
fn generate_rust_endpoints(spec: &serde_json::Value) -> String {
let mut endpoints = String::new();
if let Some(paths) = spec["paths"].as_object() {
for (path, methods) in paths {
if let Some(methods) = methods.as_object() {
for (method, operation) in methods {
let default_op_id = format!("{}_{}", method, path.replace('/', "_"));
let op_id = operation["operationId"].as_str().unwrap_or(&default_op_id);
let fn_name = to_snake_case(op_id);
let summary = operation["summary"].as_str().unwrap_or("");
let rust_path = path;
endpoints.push_str(&format!(
r#"
/// {summary}
pub async fn {fn_name}(&self) -> Result<Response, ApiError> {{
let url = format!("{{}}{rust_path}", self.base_url);
let response = self.client.{method}(&url).send().await?;
Ok(response)
}}
"#
));
}
}
}
}
endpoints
}
fn generate_rust_models(spec: &serde_json::Value) -> String {
let mut models = String::new();
if let Some(schemas) = spec["components"]["schemas"].as_object() {
for (name, schema) in schemas {
let struct_name = to_pascal_case(name);
models.push_str(&format!("\n/// {name} model\n"));
models.push_str("#[derive(Debug, Clone, Serialize, Deserialize)]\n");
models.push_str(&format!("pub struct {} {{\n", struct_name));
if let Some(props) = schema["properties"].as_object() {
for (prop_name, prop) in props {
let rust_type = json_type_to_rust(prop);
let field_name = to_snake_case(prop_name);
models.push_str(&format!(" pub {}: {},\n", field_name, rust_type));
}
}
models.push_str("}\n");
}
}
models
}
fn json_type_to_rust(prop: &serde_json::Value) -> String {
match prop["type"].as_str() {
Some("string") => "String".to_string(),
Some("integer") => "i64".to_string(),
Some("number") => "f64".to_string(),
Some("boolean") => "bool".to_string(),
Some("array") => {
let items_type = json_type_to_rust(&prop["items"]);
format!("Vec<{}>", items_type)
}
Some("object") => "serde_json::Value".to_string(),
_ => "serde_json::Value".to_string(),
}
}
async fn generate_typescript_client(
output: &Path,
name: &str,
spec: &serde_json::Value,
) -> Result<()> {
let base_url = get_base_url(spec);
let client_ts = format!(
r#"/**
* Generated API client for {name}
* Auto-generated by RustAPI CLI
*/
const BASE_URL = '{base_url}';
export interface ApiError {{
status: number;
message: string;
}}
export class ApiClient {{
private baseUrl: string;
constructor(baseUrl: string = BASE_URL) {{
this.baseUrl = baseUrl;
}}
private async request<T>(method: string, path: string, body?: any): Promise<T> {{
const response = await fetch(`${{this.baseUrl}}${{path}}`, {{
method,
headers: {{
'Content-Type': 'application/json',
}},
body: body ? JSON.stringify(body) : undefined,
}});
if (!response.ok) {{
throw {{ status: response.status, message: await response.text() }};
}}
return response.json();
}}
// Add generated methods here based on OpenAPI spec
}}
export default new ApiClient();
"#
);
fs::write(output.join("client.ts"), client_ts)?;
let package_json = format!(
r#"{{
"name": "{name}",
"version": "0.1.0",
"main": "client.ts",
"types": "client.ts"
}}
"#
);
fs::write(output.join("package.json"), package_json)?;
println!(" Generated TypeScript client");
Ok(())
}
async fn generate_python_client(output: &Path, name: &str, spec: &serde_json::Value) -> Result<()> {
let base_url = get_base_url(spec);
let client_py = format!(
r#"\"\"\"
Generated API client for {name}
Auto-generated by RustAPI CLI
\"\"\"
import requests
from typing import Any, Dict, Optional
from dataclasses import dataclass
BASE_URL = '{base_url}'
@dataclass
class ApiError(Exception):
status: int
message: str
class ApiClient:
def __init__(self, base_url: str = BASE_URL):
self.base_url = base_url
self.session = requests.Session()
def _request(self, method: str, path: str, **kwargs) -> Any:
url = f"{{self.base_url}}{{path}}"
response = self.session.request(method, url, **kwargs)
if not response.ok:
raise ApiError(response.status_code, response.text)
return response.json()
# Add generated methods here based on OpenAPI spec
# Default client instance
client = ApiClient()
"#
);
fs::write(output.join("client.py"), client_py)?;
let setup_py = format!(
r#"from setuptools import setup
setup(
name='{name}',
version='0.1.0',
py_modules=['client'],
install_requires=['requests>=2.28.0'],
)
"#
);
fs::write(output.join("setup.py"), setup_py)?;
println!(" Generated Python client");
Ok(())
}
fn to_snake_case(s: &str) -> String {
let mut result = String::new();
for (i, c) in s.chars().enumerate() {
if c.is_uppercase() && i > 0 {
result.push('_');
}
result.push(c.to_lowercase().next().unwrap_or(c));
}
result
}
fn to_pascal_case(s: &str) -> String {
s.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
})
.collect()
}