250 lines
12 KiB
JavaScript
250 lines
12 KiB
JavaScript
#!/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<string, ' + tsType(def.additionalProperties) + '>';
|
|
if (def.type === 'object' || def.properties) return 'Record<string, unknown>';
|
|
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<string, string> {');
|
|
L.push(" const h: Record<string, string> = { '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<T>(method: string, path: string, body?: unknown): Promise<T> {');
|
|
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<T>;');
|
|
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<unknown> {');
|
|
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<String>, http: reqwest::Client }');
|
|
L.push('');
|
|
L.push('impl GapmindClient {');
|
|
L.push(' pub fn new(base_url: impl Into<String>, token: Option<String>) -> 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<serde_json::Value, reqwest::Error> {');
|
|
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<serde_json::Value, reqwest::Error> {');
|
|
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<serde_json::Value, reqwest::Error> {');
|
|
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<String> = 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.');
|