use crate::config::CliConfig;
use std::process::Command;
fn load_local_manifest() -> eyre::Result<cufflink_types::ServiceManifest> {
let output = Command::new("cargo")
.args(["run", "--", "--emit-manifest"])
.output()?;
if !output.status.success() {
eyre::bail!("Failed to build service. Run from a cufflink service directory.");
}
let stdout = String::from_utf8(output.stdout)?;
let manifest: cufflink_types::ServiceManifest = serde_json::from_str(stdout.trim())?;
Ok(manifest)
}
fn generate_local_spec(tenant_slug: Option<&str>) -> eyre::Result<serde_json::Value> {
let manifest = load_local_manifest()?;
let slug = tenant_slug.unwrap_or("local");
Ok(cufflink_types::openapi::generate_openapi(&manifest, slug))
}
async fn fetch_remote_spec(env: Option<&str>) -> eyre::Result<serde_json::Value> {
let config = CliConfig::load_with_env(env)?;
let manifest = load_local_manifest()?;
let client = config.http_client();
let resp = config
.auth_request(
&client,
reqwest::Method::GET,
&format!("{}/api/services", config.api_url),
)
.send()
.await?;
let body: serde_json::Value = resp.json().await?;
let services = body["services"]
.as_array()
.ok_or_else(|| eyre::eyre!("No services found"))?;
let service = services
.iter()
.find(|s| s["name"].as_str() == Some(&manifest.name))
.ok_or_else(|| {
eyre::eyre!(
"Service '{}' not found. Deploy it first, or use --local.",
manifest.name
)
})?;
let service_id = service["id"]
.as_str()
.ok_or_else(|| eyre::eyre!("Service has no ID"))?;
let spec_resp = config
.auth_request(
&client,
reqwest::Method::GET,
&format!(
"{}/api/services/{}/openapi.json",
config.api_url, service_id
),
)
.send()
.await?;
if !spec_resp.status().is_success() {
let status = spec_resp.status();
let body = spec_resp.text().await.unwrap_or_default();
eyre::bail!("Failed to fetch OpenAPI spec ({}): {}", status, body);
}
Ok(spec_resp.json().await?)
}
pub async fn run(local: bool, tenant_slug: Option<&str>, env: Option<&str>) -> eyre::Result<()> {
let spec = if local {
generate_local_spec(tenant_slug)?
} else {
fetch_remote_spec(env).await?
};
println!("{}", serde_json::to_string_pretty(&spec)?);
Ok(())
}
pub async fn save(
output_path: &str,
local: bool,
tenant_slug: Option<&str>,
env: Option<&str>,
) -> eyre::Result<()> {
let spec = if local {
generate_local_spec(tenant_slug)?
} else {
fetch_remote_spec(env).await?
};
std::fs::write(output_path, serde_json::to_string_pretty(&spec)?)?;
println!("OpenAPI spec written to {}", output_path);
Ok(())
}
pub async fn generate_client(
output_dir: &str,
local: bool,
tenant_slug: Option<&str>,
env: Option<&str>,
) -> eyre::Result<()> {
let manifest = load_local_manifest()?;
let service_name = &manifest.name;
let spec = if local {
let slug = tenant_slug.unwrap_or("local");
cufflink_types::openapi::generate_openapi(&manifest, slug)
} else {
fetch_remote_spec(env).await?
};
let spec_path = format!("{}/openapi.json", output_dir);
std::fs::create_dir_all(output_dir)?;
std::fs::write(&spec_path, serde_json::to_string_pretty(&spec)?)?;
let ts_client = generate_typescript_client(&spec, service_name);
let client_path = format!("{}/client.ts", output_dir);
std::fs::write(&client_path, ts_client)?;
println!("Generated TypeScript client:");
println!(" {}", spec_path);
println!(" {}", client_path);
Ok(())
}
fn generate_typescript_client(spec: &serde_json::Value, service_name: &str) -> String {
let mut out = String::new();
out.push_str(&format!(
"// Auto-generated TypeScript client for {}\n",
service_name
));
out.push_str("// Generated by cufflink CLI\n\n");
if let Some(schemas) = spec["components"]["schemas"].as_object() {
for (name, schema) in schemas {
if name == "Pagination" {
continue;
}
out.push_str(&format!("export interface {} {{\n", name));
if let Some(props) = schema["properties"].as_object() {
for (prop_name, prop_def) in props {
let ts_type = openapi_type_to_ts(prop_def);
let nullable = prop_def["nullable"].as_bool().unwrap_or(false);
if nullable {
out.push_str(&format!(" {}: {} | null;\n", prop_name, ts_type));
} else {
out.push_str(&format!(" {}: {};\n", prop_name, ts_type));
}
}
}
out.push_str("}\n\n");
}
out.push_str("export interface Pagination {\n");
out.push_str(" current_page: number;\n");
out.push_str(" per_page: number;\n");
out.push_str(" total_records: number;\n");
out.push_str(" total_pages: number;\n");
out.push_str("}\n\n");
}
out.push_str("export class CufflinkClient {\n");
out.push_str(" private baseUrl: string;\n");
out.push_str(" private headers: Record<string, string>;\n\n");
out.push_str(" constructor(baseUrl: string, headers: Record<string, string> = {}) {\n");
out.push_str(" this.baseUrl = baseUrl.replace(/\\/$/, '');\n");
out.push_str(" this.headers = { 'Content-Type': 'application/json', ...headers };\n");
out.push_str(" }\n\n");
out.push_str(
" private async request<T>(method: string, path: string, body?: unknown): Promise<T> {\n",
);
out.push_str(" const resp = await fetch(`${this.baseUrl}${path}`, {\n");
out.push_str(" method,\n");
out.push_str(" headers: this.headers,\n");
out.push_str(" body: body ? JSON.stringify(body) : undefined,\n");
out.push_str(" });\n");
out.push_str(
" if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);\n",
);
out.push_str(" return resp.json();\n");
out.push_str(" }\n\n");
if let Some(paths) = spec["paths"].as_object() {
for (path, operations) in paths {
if let Some(ops) = operations.as_object() {
for (method, op) in ops {
let operation_id = op["operationId"].as_str().unwrap_or("unknown");
let fn_name = to_camel_case(operation_id);
let is_custom = op["tags"]
.as_array()
.map(|tags| tags.iter().any(|t| t.as_str() == Some("custom")))
.unwrap_or(false);
if is_custom {
let has_body = op.get("requestBody").is_some();
if has_body {
out.push_str(&format!(
" async {}(data?: Record<string, unknown>): Promise<Record<string, unknown>> {{\n",
fn_name
));
out.push_str(&format!(
" return this.request('{}', '{}', data);\n",
method.to_uppercase(),
path
));
} else {
out.push_str(&format!(
" async {}(params?: Record<string, string>): Promise<Record<string, unknown>> {{\n",
fn_name
));
out.push_str(
" const query = params ? '?' + new URLSearchParams(params).toString() : '';\n"
);
out.push_str(&format!(
" return this.request('GET', `{}${{query}}`);\n",
path
));
}
out.push_str(" }\n\n");
} else {
match method.as_str() {
"get" if !path.contains(":id") => {
let schema_ref = op["responses"]["200"]["content"]
["application/json"]["schema"]["properties"]["results"]
["items"]["$ref"]
.as_str()
.and_then(|r| r.split('/').next_back());
let return_type = schema_ref.unwrap_or("unknown");
out.push_str(&format!(
" async {}(params?: Record<string, string>): Promise<{{ results: {}[]; pagination: Pagination }}> {{\n",
fn_name, return_type
));
out.push_str(
" const query = params ? '?' + new URLSearchParams(params).toString() : '';\n"
);
out.push_str(&format!(
" return this.request('GET', `{}${{query}}`);\n",
path
));
out.push_str(" }\n\n");
}
"post" => {
let create_ref = op["requestBody"]["content"]["application/json"]
["schema"]["$ref"]
.as_str()
.and_then(|r| r.split('/').next_back());
let body_type = create_ref.unwrap_or("unknown");
let response_ref = op["responses"]["200"]["content"]
["application/json"]["schema"]["$ref"]
.as_str()
.and_then(|r| r.split('/').next_back());
let return_type = response_ref.unwrap_or("unknown");
out.push_str(&format!(
" async {}(data: {}): Promise<{}> {{\n",
fn_name, body_type, return_type
));
out.push_str(&format!(
" return this.request('POST', '{}', data);\n",
path
));
out.push_str(" }\n\n");
}
"get" if path.contains(":id") => {
let response_ref = op["responses"]["200"]["content"]
["application/json"]["schema"]["$ref"]
.as_str()
.and_then(|r| r.split('/').next_back());
let return_type = response_ref.unwrap_or("unknown");
let base_path = path.replace("/:id", "");
out.push_str(&format!(
" async {}(id: string): Promise<{}> {{\n",
fn_name, return_type
));
out.push_str(&format!(
" return this.request('GET', `{}/${{id}}`);\n",
base_path
));
out.push_str(" }\n\n");
}
"put" => {
let body_ref = op["requestBody"]["content"]["application/json"]
["schema"]["$ref"]
.as_str()
.and_then(|r| r.split('/').next_back());
let body_type = body_ref.unwrap_or("unknown");
let response_ref = op["responses"]["200"]["content"]
["application/json"]["schema"]["$ref"]
.as_str()
.and_then(|r| r.split('/').next_back());
let return_type = response_ref.unwrap_or("unknown");
let base_path = path.replace("/:id", "");
out.push_str(&format!(
" async {}(id: string, data: {}): Promise<{}> {{\n",
fn_name, body_type, return_type
));
out.push_str(&format!(
" return this.request('PUT', `{}/${{id}}`, data);\n",
base_path
));
out.push_str(" }\n\n");
}
"delete" => {
let base_path = path.replace("/:id", "");
out.push_str(&format!(
" async {}(id: string): Promise<void> {{\n",
fn_name
));
out.push_str(&format!(
" await this.request('DELETE', `{}/${{id}}`);\n",
base_path
));
out.push_str(" }\n\n");
}
_ => {}
}
} }
}
}
}
out.push_str("}\n");
out
}
fn openapi_type_to_ts(prop: &serde_json::Value) -> &str {
match prop["type"].as_str() {
Some("string") => match prop["format"].as_str() {
Some("uuid") => "string",
Some("date-time") => "string",
Some("date") => "string",
_ => "string",
},
Some("integer") => "number",
Some("number") => "number",
Some("boolean") => "boolean",
Some("object") => "Record<string, unknown>",
Some("array") => "unknown[]",
_ => "unknown",
}
}
fn to_camel_case(s: &str) -> String {
let mut result = String::new();
let mut capitalize_next = false;
for (i, c) in s.chars().enumerate() {
if c == '_' {
capitalize_next = true;
} else if capitalize_next {
result.push(c.to_ascii_uppercase());
capitalize_next = false;
} else if i == 0 {
result.push(c.to_ascii_lowercase());
} else {
result.push(c);
}
}
result
}