Files
n8n-nodes-wibu/gen.ts
bipproduction 4fcb80df3d tambahan v2
2025-11-07 10:41:05 +08:00

388 lines
16 KiB
TypeScript

// tools/generate-n8n-from-openapi.ts
// Final stable generator: OpenAPI -> n8n nodes (TypeScript)
// Usage: node ./tools/generate-n8n-from-openapi.ts
/* eslint-disable @typescript-eslint/no-explicit-any */
import fs from "fs";
import path from "path";
const NAMESPACE = "wibu";
function pascalCase(str: string) {
return str
.replace(/[^a-zA-Z0-9]+(.)/g, (_, chr) => chr.toUpperCase())
.replace(/^\w/, (c) => c.toUpperCase());
}
function ensureDir(p: string) {
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
}
function readJSON(p: string) {
return JSON.parse(fs.readFileSync(p, "utf8"));
}
function escapeForTemplate(s: string) {
return s.replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
}
function parsePathParams(url: string) {
const out: string[] = [];
const re = /{([^}]+)}/g;
let m: RegExpExecArray | null;
while ((m = re.exec(url))) out.push(m[1]);
return out;
}
function detectRequestBodyType(requestBody: any) {
if (!requestBody || !requestBody.content) return null;
if (requestBody.content["multipart/form-data"]) return "formdata";
if (requestBody.content["application/x-www-form-urlencoded"]) return "formurl";
if (requestBody.content["application/json"]) return "json";
const keys = Object.keys(requestBody.content || {});
return keys.length ? keys[0] : null;
}
function buildParamListFromOps(ops: any[]) {
const map: Record<string, any> = {};
for (const op of ops) {
const params = op.parameters || [];
for (const p of params) {
const key = `${p.in}_${p.name}`;
if (!map[key]) map[key] = p;
}
const inferred = parsePathParams(op.path);
for (const ip of inferred) {
const key = `path_${ip}`;
if (!map[key]) map[key] = { name: ip, in: "path", required: true };
}
}
return map;
}
function generateNode(tag: string, operations: any[], outDir: string) {
const baseFolder = path.join(outDir, NAMESPACE);
ensureDir(baseFolder);
const folder = path.join(baseFolder, tag);
ensureDir(folder);
const className = `${pascalCase(tag)}Api`;
const merged = buildParamListFromOps(operations);
const mergedProps = Object.keys(merged)
.map((k) => {
const p = merged[k];
const display = `${pascalCase(p.in ?? "param")} - ${p.name}${p.required ? " *" : ""}`;
const type = p.schema && p.schema.type === "integer" ? "number" : "string";
return `{
displayName: "${display.replace(/"/g, '\\"')}",
name: "${k}",
type: "${type}",
default: "${(p.example ?? "") as string}"
}`;
})
.join(",");
const opsOptions = operations
.map((o) => {
const name = (o.summary ?? o.operationId ?? o.path).toString().replace(/"/g, '\\"');
return `{
name: "${name}",
value: "${o.operationId}"
}`;
})
.join(",");
// Build switch cases
const switchCases = operations
.map((op) => {
const opId = op.operationId;
const requestBodyTypeDetected = detectRequestBodyType(op.requestBody) ?? "null";
const pathParams = parsePathParams(op.path);
const replacePathCode = pathParams
.map(
(pp) =>
`endpoint = endpoint.replace(new RegExp('\\\\{${pp}\\\\}','g'), encodeURIComponent(String(pathParamsValues["${pp}"] ?? "")));`
)
.join("\n ");
const paginationBlock = op["x-pagination"]
? `// pagination collector
const allItems: any[] = [];
if (Array.isArray(parsed)) allItems.push(...parsed);
else if (parsed && parsed.items && Array.isArray(parsed.items)) allItems.push(...parsed.items);
else allItems.push(parsed);
let nextUrl: string | null = (parsed && (parsed.next || (parsed.links && parsed.links.next))) || null;
const visited = new Set<string>();
while (nextUrl) {
if (visited.has(nextUrl)) break;
visited.add(nextUrl);
const r2 = await fetch(nextUrl).catch(() => null);
if (!r2) break;
let p2: any = null;
try { p2 = await r2.json(); } catch { p2 = await r2.text().catch(() => null); }
if (Array.isArray(p2)) allItems.push(...p2);
else if (p2 && p2.items) allItems.push(...p2.items);
else allItems.push(p2);
nextUrl = (p2 && (p2.next || (p2.links && p2.links.next))) || null;
}
returnData.push(...allItems);
`
: `returnData.push(parsed);`;
return `case "${opId}": {
// build endpoint template and replace path params
let endpoint = \`${escapeForTemplate(op.path)}\`;
const pathParamsValues: Record<string, any> = {};
${pathParams.map((pp) => `pathParamsValues["${pp}"] = paramsCollection["path_${pp}"] ?? undefined;`).join("\n ")}
${replacePathCode}
// build query string
const urlSearch = new URLSearchParams();
for (const k of Object.keys(queryCollection || {})) {
const v = queryCollection[k];
if (v !== undefined && v !== null && v !== "") urlSearch.append(k, String(v));
}
// apiKey placement
if (creds && creds.authType === "apiKey" && creds.apiKeyValue) {
const placement = creds.apiKeyPlacement ?? "header";
if (placement === "header") {
headerCollection[creds.apiKeyName ?? "Authorization"] = creds.apiKeyValue;
} else if (placement === "query") {
urlSearch.append(creds.apiKeyName ?? "api_key", creds.apiKeyValue);
} else if (placement === "path") {
endpoint = endpoint.replace(new RegExp('\\\\{'+(creds.apiKeyName ?? 'api_key')+'\\\\}','g'), encodeURIComponent(creds.apiKeyValue));
}
}
let url = (creds.baseUrl || "").replace(/\\/$/, "") + endpoint;
const qs = urlSearch.toString();
if (qs) url += (url.includes('?') ? '&' : '?') + qs;
// build headers
const headers: Record<string, string> = {};
for (const hk of Object.keys(headerCollection || {})) {
const hv = headerCollection[hk];
if (hv !== undefined && hv !== null) headers[hk] = String(hv);
}
if (creds && creds.headersJson) {
try { Object.assign(headers, creds.headersJson); } catch (e) {}
}
if (creds && creds.authType === 'oauth2' && creds.oauth2 && creds.oauth2.accessToken) {
headers['Authorization'] = \`Bearer \${creds.oauth2.accessToken}\`;
}
// build body according to requestBodyType
let bodyToSend: any = undefined;
const requestBodyType: string = "${requestBodyTypeDetected}";
if (requestBodyType === "formdata") {
const FD: any = (globalThis as any).FormData ?? null;
if (!FD) throw new Error("FormData not available in runtime. Use Node18+ or add polyfill.");
const fd = new FD();
for (const k of Object.keys(bodyCollection || {})) {
const v = bodyCollection[k];
if (v === undefined || v === null) continue;
fd.append(k, v);
}
bodyToSend = fd;
} else if (requestBodyType === "formurl") {
const urlp = new URLSearchParams();
for (const k of Object.keys(bodyCollection || {})) {
const v = bodyCollection[k];
if (v === undefined || v === null) continue;
urlp.append(k, String(v));
}
bodyToSend = urlp.toString();
headers['Content-Type'] = 'application/x-www-form-urlencoded';
} else if (requestBodyType === "json") {
try { bodyToSend = JSON.stringify(bodyCollection.bodyJson ?? bodyCollection ?? {}); headers['Content-Type'] = 'application/json'; } catch (e) { bodyToSend = JSON.stringify(bodyCollection ?? {}); headers['Content-Type'] = 'application/json'; }
} else if (bodyCollection && typeof bodyCollection.rawString === 'string' && bodyCollection.rawString.length) {
bodyToSend = bodyCollection.rawString;
headers['Content-Type'] = headers['Content-Type'] ?? 'text/plain';
}
const method = "${op.method.toUpperCase()}";
const init: any = { method, headers };
if (bodyToSend !== undefined) init.body = bodyToSend;
const GLOBAL_TIMEOUT = Number(creds && creds.timeout ? creds.timeout : 60000);
const GLOBAL_RETRY = Number(creds && creds.retry ? creds.retry : 1);
const GLOBAL_FOLLOW = creds && creds.followRedirect !== false;
const fetchWithTimeout = async (input: string, initOpt: any) => {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), GLOBAL_TIMEOUT);
try {
const r = await fetch(input, { ...initOpt, redirect: GLOBAL_FOLLOW ? 'follow' : 'manual', signal: controller.signal });
clearTimeout(id);
return r;
} catch (err) {
clearTimeout(id);
throw err;
}
};
let resp: Response | null = null;
for (let attempt = 0; attempt < GLOBAL_RETRY; attempt++) {
try { resp = await fetchWithTimeout(url, init); break; } catch (e) { if (attempt + 1 >= GLOBAL_RETRY) throw e; await new Promise(r=>setTimeout(r, 300 * (attempt+1))); }
}
if (!resp) throw new Error('No response from fetch.');
const contentType = resp.headers.get('content-type') || '';
let parsed: any = null;
try {
if (contentType.includes('application/json')) parsed = await resp.json();
else if (contentType.startsWith('text/') || contentType === '') parsed = await resp.text();
else { const ab = await resp.arrayBuffer(); parsed = { binary: Buffer.from(new Uint8Array(ab)).toString('base64'), mime: contentType }; }
} catch (e) { parsed = await resp.text().catch(()=>null); }
if (!resp.ok) {
const m = typeof parsed === 'string' ? parsed : JSON.stringify(parsed);
const err: any = new Error(\`HTTP \${resp.status}: \${m}\`);
err.statusCode = resp.status; err.response = parsed; throw err;
}
${paginationBlock}
break;
}`;
})
.join("\n\n");
const nodeContent = `import { INodeType, INodeTypeDescription, IExecuteFunctions } from "n8n-workflow";
export class ${className} implements INodeType {
description: INodeTypeDescription = {
displayName: "${NAMESPACE} - ${pascalCase(tag)}",
name: "${pascalCase(tag)}",
icon: "file:../../../icon.svg",
group: ["transform"],
version: 1,
description: "Auto-generated node (stable generator)",
defaults: { name: "${NAMESPACE} - ${pascalCase(tag)}" },
inputs: ["main"],
outputs: ["main"],
credentials: [{ name: "wibuApi", required: true }],
properties: [
{
displayName: "Operation",
name: "operation",
type: "options",
options: [
${opsOptions}
],
default: "${operations[0].operationId}"
},
{
displayName: "Parameters (path / query / header)",
name: "params",
type: "collection",
placeholder: "Add Parameter",
default: {},
options: [
${mergedProps}
]
},
{ displayName: "Query (quick)", name: "query", type: "collection", placeholder: "Add Query", default: {}, options: [] },
{ displayName: "Headers (quick)", name: "headers", type: "collection", placeholder: "Add Header", default: {}, options: [] },
{ displayName: "Body", name: "body", type: "collection", placeholder: "Add Body", default: {}, options: [ { displayName: "JSON Body", name: "bodyJson", type: "json", default: {} }, { displayName: "Raw String", name: "rawString", type: "string", default: "" } ] },
{ displayName: "Body Type", name: "bodyType", type: "options", options: [ { name: "None", value: "none" }, { name: "JSON", value: "json" }, { name: "Form URL Encoded", value: "formurl" }, { name: "FormData (multipart)", value: "formdata" } ], default: "none" },
{ displayName: "Advanced", name: "advanced", type: "collection", default: {}, placeholder: "Advanced", options: [ { displayName: "Timeout (ms)", name: "timeout", type: "number", default: 60000 }, { displayName: "Retries", name: "retry", type: "number", default: 1 }, { displayName: "Follow Redirect", name: "followRedirect", type: "boolean", default: true } ] }
]
};
async execute(this: IExecuteFunctions) {
const returnData: any[] = [];
const items = this.getInputData();
const creds = await this.getCredentials("wibuApi") as any;
for (let i = 0; i < items.length; i++) {
const operation = this.getNodeParameter("operation", i) as string;
const paramsCollection = (this.getNodeParameter("params", i, {}) as any) || {};
const queryCollection = (this.getNodeParameter("query", i, {}) as any) || {};
const headerCollection = (this.getNodeParameter("headers", i, {}) as any) || {};
const bodyCollection = (this.getNodeParameter("body", i, {}) as any) || {};
switch (operation) {
${switchCases}
default:
throw new Error("Operation not implemented: " + operation);
}
}
return [returnData];
}
}
`;
const filePath = path.join(folder, `${className}.node.ts`);
fs.writeFileSync(filePath, nodeContent, "utf8");
return className;
}
function writeCredentials(outDir: string) {
const credFolder = path.join(outDir, "credentials");
ensureDir(credFolder);
const credFile = `import { ICredentialType, INodeProperties } from "n8n-workflow";
export class WibuApi implements ICredentialType {
name = "wibuApi";
displayName = "Wibu API";
properties: INodeProperties[] = [
{ displayName: "Base URL", name: "baseUrl", type: "string", default: "", placeholder: "https://api.example.com" },
{ displayName: "Authentication Type", name: "authType", type: "options", options: [ { name: "None", value: "none" }, { name: "API Key", value: "apiKey" }, { name: "OAuth2 (scaffold)", value: "oauth2" } ], default: "apiKey" },
{ displayName: "API Key Name", name: "apiKeyName", type: "string", default: "Authorization", displayOptions: { show: { authType: ["apiKey"] } } },
{ displayName: "API Key Value", name: "apiKeyValue", type: "string", default: "", typeOptions: { password: true }, displayOptions: { show: { authType: ["apiKey"] } } },
{ displayName: "API Key Placement", name: "apiKeyPlacement", type: "options", options: [ { name: "Header", value: "header" }, { name: "Query", value: "query" }, { name: "Path", value: "path" } ], default: "header", displayOptions: { show: { authType: ["apiKey"] } } },
{ displayName: "OAuth2 - Access Token (manual)", name: "oauth2", type: "collection", default: {}, placeholder: "OAuth2 tokens", displayOptions: { show: { authType: ["oauth2"] } }, options: [ { displayName: "Access Token", name: "accessToken", type: "string", default: "", typeOptions: { password: true } }, { displayName: "Refresh Token", name: "refreshToken", type: "string", default: "", typeOptions: { password: true } }, { displayName: "Expires At (ms)", name: "expiresAt", type: "number", default: 0 } ] },
{ displayName: "Additional Headers (JSON)", name: "headersJson", type: "json", default: {} },
{ displayName: "Timeout (ms)", name: "timeout", type: "number", default: 60000 },
{ displayName: "Retries", name: "retry", type: "number", default: 1 },
{ displayName: "Follow Redirects", name: "followRedirect", type: "boolean", default: true }
];
}
`;
fs.writeFileSync(path.join(credFolder, "WibuApi.credentials.ts"), credFile, "utf8");
}
export function main() {
const projectRoot = process.cwd();
const openapiPath = path.join(projectRoot, "openapi.json");
const outDir = path.join(projectRoot, "src");
if (!fs.existsSync(openapiPath)) {
console.error("❌ openapi.json not found");
process.exit(1);
}
const spec = readJSON(openapiPath);
const tagOps: Record<string, any[]> = {};
for (const [pathKey, pathItem] of Object.entries(spec.paths)) {
for (const [method, op] of Object.entries<any>(pathItem as any)) {
const tags = op.tags ?? ["default"];
for (const t of tags) {
if (!tagOps[t]) tagOps[t] = [];
tagOps[t].push({ ...op, path: pathKey, method });
}
}
}
const generated: string[] = [];
for (const [tag, ops] of Object.entries(tagOps)) {
const node = generateNode(tag, ops, outDir);
generated.push(node);
console.log(`✅ Generated node: ${node}`);
}
writeCredentials(outDir);
console.log(`\n✅ All done. Generated ${generated.length} nodes + credentials.`);
}
if (require.main === module) {
main();
}