#!/usr/bin/env node import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); async function loadSpec(cliPath) { if (cliPath) return JSON.parse(readFileSync(cliPath, 'utf-8')); const res = await fetch('http://localhost:3000/api/openapi.json'); if (!res.ok) throw new Error('HTTP ' + res.status); return await res.json(); } function cleanOpId(opId) { if (!opId || typeof opId !== 'string') return 'unnamed'; return opId .replace(/^[A-Za-z0-9]+Controller_?/, '') .replace(/^Resolution_?/, '') .replace(/[^a-zA-Z0-9_]/g, '_') .replace(/^_+|_+$/g, '') || 'unnamed'; } function tsType(def) { if (!def || typeof def !== 'object') return 'unknown'; if (typeof def.$ref === 'string') return sanitizeTsName(String(def.$ref.split('/').pop())); if (def.type === 'string') return 'string'; if (def.type === 'integer' || def.type === 'number') return 'number'; if (def.type === 'boolean') return 'boolean'; if (def.type === 'array') { const items = def.items && typeof def.items === 'object' ? def.items : { type: 'unknown' }; return tsType(items) + '[]'; } if (Array.isArray(def.oneOf)) return def.oneOf.map(tsType).join(' | '); if (Array.isArray(def.anyOf)) return def.anyOf.map(tsType).join(' | '); if (def.additionalProperties && typeof def.additionalProperties === 'object') return 'Record'; if (def.type === 'object' || def.properties) return 'Record'; return 'unknown'; } function sanitizeTsName(name) { const s = String(name == null ? 'unnamed' : name).replace(/[^a-zA-Z0-9_]/g, '_').replace(/^_+|_+$/g, ''); return s || 'Unnamed'; } function pathToTemplate(path) { return String(path).replace(/\{([^}]+)\}/g, (_, p) => '${' + sanitizeTsName(p).charAt(0).toLowerCase() + sanitizeTsName(p).slice(1) + '}'); } function pathParamNames(path) { const out = []; const re = /\{([^}]+)\}/g; let m; while ((m = re.exec(String(path)))) out.push(m[1]); return out; } function generateTypeScript(spec) { const L = []; L.push('// Auto-generated by scripts/generate-bindings.mjs — do not edit.'); L.push(''); const schemas = (spec.components && spec.components.schemas) || {}; for (const [name, schema] of Object.entries(schemas)) { if (!schema || typeof schema !== 'object') continue; L.push('export interface ' + sanitizeTsName(name) + ' {'); const props = schema.properties || {}; const required = new Set(schema.required || []); for (const [prop, def] of Object.entries(props)) { const opt = required.has(prop) ? '' : '?'; L.push(' ' + prop + opt + ': ' + tsType(def) + ';'); } L.push('}'); L.push(''); } L.push('export interface GapmindClientConfig { baseUrl: string; token?: string; }'); L.push(''); L.push('export class GapmindClient {'); L.push(' private baseUrl: string;'); L.push(' private token?: string;'); L.push(' constructor(config: GapmindClientConfig) {'); L.push(" this.baseUrl = config.baseUrl.replace(/\\/$/, '');"); L.push(' this.token = config.token;'); L.push(' }'); L.push(' private headers(): Record {'); L.push(" const h: Record = { 'Content-Type': 'application/json' };"); L.push(' if (this.token) h["Authorization"] = "Bearer " + this.token;'); L.push(' return h;'); L.push(' }'); L.push(' private async request(method: string, path: string, body?: unknown): Promise {'); L.push(' const res = await fetch(this.baseUrl + path, { method, headers: this.headers(), body: body ? JSON.stringify(body) : undefined });'); L.push(' if (!res.ok) throw new Error("API error " + res.status + ": " + (await res.text()));'); L.push(' return res.json() as Promise;'); L.push(' }'); L.push(''); for (const [path, methods] of Object.entries(spec.paths || {})) { if (!methods || typeof methods !== 'object') continue; for (const [method, op] of Object.entries(methods)) { if (!op || typeof op !== 'object' || !op.operationId) continue; if (!['get','post','put','patch','delete'].includes(method)) continue; const name = cleanOpId(op.operationId); const http = method.toUpperCase(); const qps = ((op.parameters || []).filter((p) => p && p.in === 'query')); const tpl = pathToTemplate(path); const pNames = pathParamNames(path); const params = []; for (const pn of pNames) params.push(sanitizeTsName(pn).charAt(0).toLowerCase() + sanitizeTsName(pn).slice(1) + ': string'); for (const qp of qps) { const qn = String(qp.name); params.push(qn + (qp.required ? ': string' : '?: string')); } const hasBody = !!(op.requestBody && op.requestBody.content); if (hasBody) params.push('body: unknown'); L.push(' async ' + name + '(' + params.join(', ') + '): Promise {'); if (qps.length) { L.push(' const qs = new URLSearchParams();'); for (const qp of qps) { const qn = String(qp.name); L.push(' if (' + qn + ' !== undefined) qs.set(' + JSON.stringify(qn) + ', String(' + qn + '));'); } const suffix = ' + (qs.toString() ? "?" + qs.toString() : "")'; L.push(' return this.request("' + http + '", `' + tpl + '`' + suffix + (hasBody ? ', body' : '') + ');'); } else { L.push(' return this.request("' + http + '", `' + tpl + '`' + (hasBody ? ', body' : '') + ');'); } L.push(' }'); L.push(''); } } L.push('}'); return L.join('\n'); } function sanitizeRustName(name) { const parts = String(name == null ? 'unnamed' : name).split(/[^a-zA-Z0-9]+/).filter(Boolean); const s = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('') || 'Unnamed'; return s.replace(/^[^A-Za-z_]/, '_'); } function sanitizeRustField(name) { const reserved = ['type','match','struct','enum','fn','pub','use','mod','crate','self','super','move','ref']; let s = String(name).replace(/[^a-zA-Z0-9_]/g, '_'); if (!s) s = 'field'; // camelCase -> snake_case s = s.replace(/([a-z0-9])([A-Z])/g, '$1_$2'); if (/^[0-9]/.test(s)) s = '_' + s; s = s.toLowerCase(); if (reserved.includes(s)) s += '_'; return s; } function rustType(def, optional) { let inner = 'serde_json::Value'; if (def && typeof def === 'object') { if (typeof def.$ref === 'string') inner = sanitizeRustName(String(def.$ref.split('/').pop())); else if (def.type === 'string') inner = 'String'; else if (def.type === 'integer') inner = 'i64'; else if (def.type === 'number') inner = 'f64'; else if (def.type === 'boolean') inner = 'bool'; else if (def.type === 'array') inner = 'Vec<' + rustType(def.items || {}, false) + '>'; else if (def.type === 'object' || def.properties) inner = 'serde_json::Value'; } return optional ? 'Option<' + inner + '>' : inner; } function generateRust(spec) { const L = []; L.push('// Auto-generated by scripts/generate-bindings.mjs — do not edit.'); L.push('use serde::{Deserialize, Serialize};'); L.push(''); const schemas = (spec.components && spec.components.schemas) || {}; for (const [name, schema] of Object.entries(schemas)) { if (!schema || typeof schema !== 'object') continue; const props = schema.properties || {}; if (schema.type !== 'object' && !Object.keys(props).length) continue; const sn = sanitizeRustName(name); const required = new Set(schema.required || []); L.push('#[derive(Debug, Clone, Serialize, Deserialize)]'); L.push('pub struct ' + sn + ' {'); for (const [prop, def] of Object.entries(props)) { const f = sanitizeRustField(prop); if (f !== prop) L.push(' #[serde(rename = "' + prop + '")]'); if (!required.has(prop)) L.push(' #[serde(default, skip_serializing_if = "Option::is_none")]'); L.push(' pub ' + f + ': ' + rustType(def, !required.has(prop)) + ','); } L.push('}'); L.push(''); } L.push('#[derive(Debug, Clone)]'); L.push('pub struct GapmindClient { base_url: String, token: Option, http: reqwest::Client }'); L.push(''); L.push('impl GapmindClient {'); L.push(' pub fn new(base_url: impl Into, token: Option) -> Self {'); L.push(' Self { base_url: base_url.into().trim_end_matches(\'/\').to_string(), token, http: reqwest::Client::new() }'); L.push(' }'); L.push(' fn auth(&self, b: reqwest::RequestBuilder) -> reqwest::RequestBuilder {'); L.push(' match &self.token { Some(t) => b.bearer_auth(t), None => b }'); L.push(' }'); L.push(' pub async fn get_json(&self, path: &str) -> Result {'); L.push(' let url = format!("{}{}", self.base_url, path);'); L.push(' let res = self.auth(self.http.get(&url)).send().await?.error_for_status()?;'); L.push(' res.json().await'); L.push(' }'); L.push(' pub async fn post_json(&self, path: &str, body: &serde_json::Value) -> Result {'); L.push(' let url = format!("{}{}", self.base_url, path);'); L.push(' let res = self.auth(self.http.post(&url).json(body)).send().await?.error_for_status()?;'); L.push(' res.json().await'); L.push(' }'); for (const [path, methods] of Object.entries(spec.paths || {})) { if (!methods || typeof methods !== 'object') continue; for (const [method, op] of Object.entries(methods)) { if (!op || typeof op !== 'object' || !op.operationId) continue; if (!['get','post','put','patch','delete'].includes(method)) continue; const fnName = sanitizeRustField(cleanOpId(op.operationId)); const pNames = pathParamNames(path); const qps = ((op.parameters || []).filter((p) => p && p.in === 'query')).map((p) => String(p.name)); const args = [...pNames.map((p) => sanitizeRustField(p) + ': &str'), ...qps.map((q) => sanitizeRustField(q) + ': Option<&str>')]; if (op.requestBody) args.push('body: &serde_json::Value'); L.push(' pub async fn ' + fnName + '(&self' + (args.length ? ', ' + args.join(', ') : '') + ') -> Result {'); let rsPath = String(path); for (const p of pNames) rsPath = rsPath.split('{' + p + '}').join('{}'); // build format string with positional args const fmtArgs = pNames.map((p) => sanitizeRustField(p)).join(', '); L.push(' let mut url = format!("{}{}", self.base_url, format!("' + rsPath.replace(/"/g, '') + '"' + (fmtArgs ? ', ' + fmtArgs : '') + '));'); if (qps.length) { L.push(' let mut q: Vec<(&str, String)> = Vec::new();'); for (const q of qps) { const f = sanitizeRustField(q); L.push(' if let Some(v) = ' + f + ' { q.push(("' + q + '", v.to_string())); }'); } L.push(' if (!q.is_empty()) { let s: Vec = q.iter().map(|(k,v)| format!("{}={}", k, v)).collect(); url.push_str(&format!("?{}", s.join("&"))); }'); } if (method === 'get') L.push(' self.get_json(&url[self.base_url.len()..]).await'); else if (method === 'post') L.push(' self.post_json(&url[self.base_url.len()..], body).await'); else L.push(' { let res = self.auth(self.http.request(reqwest::Method::' + method.toUpperCase() + ', &url)).send().await?.error_for_status()?; res.json().await }'); L.push(' }'); } } L.push('}'); return L.join('\n'); } const spec = await loadSpec(process.argv[2]); console.log('Loaded: ' + spec.info?.title + ' v' + spec.info?.version + ' paths=' + Object.keys(spec.paths || {}).length); mkdirSync(join(ROOT, 'clients', 'typescript'), { recursive: true }); writeFileSync(join(ROOT, 'clients', 'typescript', 'gapmind.ts'), generateTypeScript(spec)); mkdirSync(join(ROOT, 'clients', 'rust', 'src'), { recursive: true }); writeFileSync(join(ROOT, 'clients', 'rust', 'src', 'lib.rs'), generateRust(spec)); writeFileSync(join(ROOT, 'clients', 'rust', 'Cargo.toml'), '[package]\nname = "gapmind-client"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\nserde = { version = "1", features = ["derive"] }\nserde_json = "1"\nreqwest = { version = "0.12", features = ["json"] }\n'); console.log('Done.');