From 47074ccb7c8a85c8f412e223895c904922795684 Mon Sep 17 00:00:00 2001 From: bipproduction Date: Thu, 20 Nov 2025 16:12:23 +0800 Subject: [PATCH] update version --- src/nodes/OpenApiNode.node.ts | 162 +++++++++++++++++++++++++++------- src/package.json | 2 +- 2 files changed, 131 insertions(+), 33 deletions(-) diff --git a/src/nodes/OpenApiNode.node.ts b/src/nodes/OpenApiNode.node.ts index d3fa21a..aca11e2 100644 --- a/src/nodes/OpenApiNode.node.ts +++ b/src/nodes/OpenApiNode.node.ts @@ -15,6 +15,10 @@ interface OpenAPISpec { const openApiCache: Map = new Map(); +// --------------------------------------------------------------------- +// Cache Loader +// --------------------------------------------------------------------- + async function loadOpenAPISpecCached(url: string): Promise { if (openApiCache.has(url)) return openApiCache.get(url)!; const res = await axios.get(url, { timeout: 20000 }); @@ -23,9 +27,14 @@ async function loadOpenAPISpecCached(url: string): Promise { return spec; } +// --------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------- + function getDefaultValue(type: string | undefined, schema?: any): any { if (schema?.example !== undefined) return schema.example; if (schema?.default !== undefined) return schema.default; + switch (type) { case "string": return ""; case "number": @@ -39,6 +48,7 @@ function getDefaultValue(type: string | undefined, schema?: any): any { function resolveRef(obj: any, spec: OpenAPISpec): any { if (!obj || typeof obj !== "object") return obj; + if (obj.$ref && typeof obj.$ref === "string" && obj.$ref.startsWith("#/")) { const refPath = obj.$ref.replace(/^#\//, "").split("/"); let cur: any = spec; @@ -48,6 +58,10 @@ function resolveRef(obj: any, spec: OpenAPISpec): any { return obj; } +// --------------------------------------------------------------------- +// Node Definition +// --------------------------------------------------------------------- + export class OpenApiNode implements INodeType { description: INodeTypeDescription = { displayName: "OpenApiNode - Dynamic OpenAPI", @@ -60,9 +74,11 @@ export class OpenApiNode implements INodeType { defaults: { name: "OpenApiNode" }, inputs: ["main"], outputs: ["main"], - credentials: [ - { name: "openApiNodeApi", required: false }, - ], + credentials: [{ name: "openApiNodeApi", required: false }], + + // ----------------------------------------------------------------- + // All Editable Properties + // ----------------------------------------------------------------- properties: [ { displayName: "OpenAPI JSON URL", @@ -71,17 +87,32 @@ export class OpenApiNode implements INodeType { default: "", required: true, }, + + // New: Tag Filter + { + displayName: "Tag Filter (Optional)", + name: "tagFilter", + type: "options", + default: "", + description: "Filter operations by tag", + typeOptions: { + loadOptionsMethod: "loadTags", + loadOptionsDependsOn: ["openapiUrl"], + }, + }, + { displayName: "Operation", name: "operation", type: "options", - typeOptions: { - loadOptionsMethod: "loadOperations", - loadOptionsDependsOn: ["openapiUrl"], - }, default: "", required: true, + typeOptions: { + loadOptionsMethod: "loadOperations", + loadOptionsDependsOn: ["openapiUrl", "tagFilter"], + }, }, + { displayName: "Parameters (Form)", name: "parametersForm", @@ -98,23 +129,23 @@ export class OpenApiNode implements INodeType { displayName: "Name", name: "name", type: "options", + default: "", typeOptions: { loadOptionsMethod: "loadParameterNames", loadOptionsDependsOn: ["openapiUrl", "operation"], }, - default: "", }, { displayName: "In", name: "in", type: "options", - options: [ - { name: "query", value: "query" }, - { name: "path", value: "path" }, - { name: "header", value: "header" }, - { name: "body", value: "body" }, - ], default: "query", + options: [ + { name: "Query", value: "query" }, + { name: "Path", value: "path" }, + { name: "Header", value: "header" }, + { name: "Body", value: "body" }, + ], }, { displayName: "Value", @@ -129,51 +160,109 @@ export class OpenApiNode implements INodeType { ], }; + // --------------------------------------------------------------------- + // Methods + // --------------------------------------------------------------------- + methods = { loadOptions: { - async loadOperations(this: ILoadOptionsFunctions): Promise { + // ------------------------------------------------------------- + // Load Tags + // ------------------------------------------------------------- + async loadTags(this: ILoadOptionsFunctions): Promise { const url = (this.getNodeParameter("openapiUrl", "") as string).trim(); if (!url) return [{ name: "Provide openapiUrl first", value: "" }]; + + try { + const spec = await loadOpenAPISpecCached(url); + const tagSet = new Set(); + + for (const path of Object.keys(spec.paths || {})) { + const pathObj = spec.paths[path]; + for (const method of Object.keys(pathObj || {})) { + const op = pathObj[method]; + (op.tags || []).forEach((t: string) => tagSet.add(t)); + } + } + + return [...tagSet].map(tag => ({ + name: tag, + value: tag, + })); + } catch (err: any) { + return [{ name: `Error: ${err.message}`, value: "" }]; + } + }, + + // ------------------------------------------------------------- + // Load Operations (with tag filter) + // ------------------------------------------------------------- + async loadOperations(this: ILoadOptionsFunctions): Promise { + const url = (this.getNodeParameter("openapiUrl", "") as string).trim(); + const selectedTag = (this.getNodeParameter("tagFilter", "") as string).trim(); + + if (!url) return [{ name: "Provide openapiUrl first", value: "" }]; + try { const spec = await loadOpenAPISpecCached(url); const ops: INodePropertyOptions[] = []; for (const path of Object.keys(spec.paths || {})) { const pathObj = spec.paths[path]; + for (const method of Object.keys(pathObj || {})) { const op = pathObj[method]; + + // Apply tag filter + if (selectedTag && !(op.tags || []).includes(selectedTag)) continue; + const opId = op.operationId || `${method}_${path.replace(/[{}]/g, "").replace(/\//g, "_")}`; - const title = (op.summary || opId).toString(); + + const title = op.summary || opId; + ops.push({ - name: `${title} — ${method.toUpperCase()} ${path}`, + name: `${method.toUpperCase()} ${path}`, value: opId, + description: title, }); } } - return ops; + + return ops.length + ? ops + : [{ name: "No operations found for selected tag", value: "" }]; + } catch (err: any) { return [{ name: `Error: ${err.message}`, value: "" }]; } }, + // ------------------------------------------------------------- + // Load Parameter Names + // ------------------------------------------------------------- async loadParameterNames(this: ILoadOptionsFunctions): Promise { const url = (this.getNodeParameter("openapiUrl", "") as string).trim(); const opId = (this.getNodeParameter("operation", "") as string).trim(); + if (!url || !opId) return [{ name: "Select operation first", value: "" }]; try { const spec = await loadOpenAPISpecCached(url); const out: INodePropertyOptions[] = []; + const seen = new Set(); outer: for (const path of Object.keys(spec.paths || {})) { const pathObj = spec.paths[path]; + for (const method of Object.keys(pathObj || {})) { const op = pathObj[method]; + const id = op.operationId || `${method}_${path.replace(/[{}]/g, "").replace(/\//g, "_")}`; + if (id !== opId) continue; const params = [ @@ -181,23 +270,21 @@ export class OpenApiNode implements INodeType { ...(op.parameters || []), ]; - const seen = new Set(); - for (const p of params) { if (!seen.has(p.name)) { seen.add(p.name); out.push({ name: `${p.name} (in=${p.in})`, - value: p.name, + value: p.name }); } } - const bodySchema = - op.requestBody?.content?.["application/json"]?.schema; + const bodySchema = op.requestBody?.content?.["application/json"]?.schema; if (bodySchema) { const resolved = resolveRef(bodySchema, spec); const props = resolved.properties || {}; + for (const propName of Object.keys(props)) { if (!seen.has(propName)) { seen.add(propName); @@ -221,6 +308,10 @@ export class OpenApiNode implements INodeType { }, }; + // --------------------------------------------------------------------- + // Execute API Call + // --------------------------------------------------------------------- + async execute(this: IExecuteFunctions) { const items = this.getInputData(); const returnData: any[] = []; @@ -241,11 +332,14 @@ export class OpenApiNode implements INodeType { outer: for (const path of Object.keys(spec.paths || {})) { const pathObj = spec.paths[path]; + for (const method of Object.keys(pathObj || {})) { const op = pathObj[method]; + const id = op.operationId || `${method}_${path.replace(/[{}]/g, "").replace(/\//g, "_")}`; + if (id === operationId) { foundOp = op; foundPath = path; @@ -258,15 +352,16 @@ export class OpenApiNode implements INodeType { if (!foundOp) throw new Error("Operation not found"); const paramsForm = this.getNodeParameter("parametersForm", i, {}) as any; - let queryParams: Record = {}; - let bodyParams: Record = {}; + + const queryParams: Record = {}; + const bodyParams: Record = {}; const pathParams = [ ...(spec.paths[foundPath].parameters || []), ...(foundOp.parameters || []), ]; - if (paramsForm && Array.isArray(paramsForm.parameter)) { + if (paramsForm?.parameter) { for (const p of paramsForm.parameter) { if (p.in === "query") queryParams[p.name] = p.value; else if (p.in === "body") bodyParams[p.name] = p.value; @@ -274,6 +369,7 @@ export class OpenApiNode implements INodeType { } } + // Build final URL let baseUrl = creds?.baseUrl || spec.servers?.[0]?.url || @@ -286,7 +382,7 @@ export class OpenApiNode implements INodeType { if (p.in === "path") { const item = paramsForm.parameter.find((x: any) => x.name === p.name); const val = item?.value; - if (!val) throw new Error(`Missing path param ${p.name}`); + if (!val) throw new Error(`Missing path param: ${p.name}`); url = url.replace(`{${p.name}}`, encodeURIComponent(val)); } } @@ -306,16 +402,18 @@ export class OpenApiNode implements INodeType { timeout: 30000, }; - // === FIX: GET & HEAD tidak boleh punya body === + // GET/HEAD cannot have body if (!(foundMethod === "get" || foundMethod === "head")) { - axiosConfig.data = Object.keys(bodyParams).length ? bodyParams : undefined; + axiosConfig.data = Object.keys(bodyParams).length + ? bodyParams + : undefined; } const resp = await axios(axiosConfig); returnData.push({ json: resp.data }); } catch (err: any) { - if (this.continueOnFail && this.continueOnFail()) { + if (this.continueOnFail?.()) { returnData.push({ json: { error: true, @@ -323,9 +421,9 @@ export class OpenApiNode implements INodeType { response: err.response?.data, }, }); - continue; + } else { + throw err; } - throw err; } } diff --git a/src/package.json b/src/package.json index eda2dcc..a2d4eda 100644 --- a/src/package.json +++ b/src/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-openapi-node", - "version": "1.0.3", + "version": "1.0.4", "keywords": [ "n8n", "n8n-nodes"