From 8e6f2c02bf55ca1ba9b66f2ce1e80168b5234088 Mon Sep 17 00:00:00 2001 From: Nicholas Penree Date: Wed, 11 May 2022 22:31:00 -0400 Subject: [PATCH 1/3] Create/Update/Raw Request --- nodes/NetSuite/NetSuite.node.ts | 411 +++++++++++++++++++++++--- nodes/NetSuite/NetSuite.node.types.ts | 17 ++ package.json | 2 +- 3 files changed, 381 insertions(+), 49 deletions(-) diff --git a/nodes/NetSuite/NetSuite.node.ts b/nodes/NetSuite/NetSuite.node.ts index 74bed83..169e0c5 100644 --- a/nodes/NetSuite/NetSuite.node.ts +++ b/nodes/NetSuite/NetSuite.node.ts @@ -1,23 +1,20 @@ import { IExecuteFunctions } from 'n8n-core'; import { - IDataObject, INodeExecutionData, INodeType, INodeTypeDescription, - IRunExecutionData, - NodeApiError, LoggerProxy as Logger, } from 'n8n-workflow'; + import { - INetSuiteCredentials, INetSuiteOperationOptions, + INetSuiteCredentials, INetSuiteOperationOptions, INetSuiteRequestOptions, NetSuiteRequestType, } from './NetSuite.node.types'; import { makeRequest } from '@fye/netsuite-rest-api'; -import { fsync } from 'fs'; -import { connected } from 'process'; const handleNetsuiteResponse = function (fns: IExecuteFunctions, response: any) { - // console.log(`Netsuite response:`, response.body); + // console.log(response); + console.log(`Netsuite response:`, response.statusCode, response.body); let body: any = {}; const { title: webTitle = undefined, @@ -43,7 +40,30 @@ const handleNetsuiteResponse = function (fns: IExecuteFunctions, response: any) } } else { body = response.body; + if ([ 'POST', 'PATCH', 'DELETE' ].includes(response.request.options.method)) { + body = {}; + if (response.headers['x-netsuite-propertyvalidation']) { + body.propertyValidation = response.headers['x-netsuite-propertyvalidation'].split(','); + } + if (response.headers['x-n-operationid']) { + body.operationId = response.headers['x-n-operationid']; + } + if (response.headers['x-netsuite-jobid']) { + body.jobId = response.headers['x-netsuite-jobid']; + } + if (response.headers['location']) { + body.links = [ + { + rel: 'self', + href: response.headers['location'], + }, + ]; + body.id = response.headers['location'].split('/').pop(); + } + body.success = response.statusCode === 204; + } } + // console.log(body); return { json: body }; }; @@ -83,35 +103,130 @@ export class NetSuite implements INodeType { type: 'options', options: [ { - name: 'Get Records', - value: 'getRecords', + name: 'List Records', + value: 'listRecords', }, { name: 'Get Record', value: 'getRecord', }, + { + name: 'Insert Record', + value: 'insertRecord', + }, { name: 'Update Record', value: 'updateRecord', }, { - name: 'Delete Record', - value: 'deleteRecord', + name: 'Remove Record', + value: 'removeRecord', + }, + // { + // name: 'Execute SuiteQL', + // value: 'runSuiteQL', + // }, + // { + // name: 'Get Workbook', + // value: 'getWorkbook', + // }, + { + name: 'Raw Request', + value: 'rawRequest', }, + ], + default: 'getRecord', + }, + { + displayName: 'Request Type', + name: 'requestType', + type: 'options', + options: [ { - name: 'Execute SuiteQL', - value: 'runSuiteQL', + name: 'Record', + value: 'record', }, { - name: 'Get Workbook', - value: 'getWorkbook', + name: 'SuiteQL', + value: 'suiteql', }, { - name: 'Raw Request', - value: 'rawRequest', + name: 'Workbook', + value: 'workbook', + }, + { + name: 'Dataset', + value: 'dataset', + }, + ], + displayOptions: { + show: { + operation: [ + 'rawRequest', + ], + }, + }, + required: true, + default: 'record', + }, + { + displayName: 'HTTP Method', + name: 'method', + type: 'options', + options: [ + { + name: 'DELETE', + value: 'DELETE', + }, + { + name: 'GET', + value: 'GET', + }, + { + name: 'HEAD', + value: 'HEAD', + }, + { + name: 'OPTIONS', + value: 'OPTIONS', + }, + { + name: 'PATCH', + value: 'PATCH', + }, + { + name: 'POST', + value: 'POST', + }, + { + name: 'PUT', + value: 'PUT', }, ], - default: 'getRecords', + default: 'GET', + description: 'The request method to use.', + displayOptions: { + show: { + operation: [ + 'rawRequest', + ], + }, + }, + required: true, + }, + { + displayName: 'Path', + name: 'path', + type: 'string', + required: true, + default: 'services/rest/record/v1/salesOrder', + displayOptions: { + show: { + operation: [ + 'rawRequest', + ], + }, + }, }, { displayName: 'Record Type', @@ -123,6 +238,7 @@ export class NetSuite implements INodeType { { name: 'Calendar Event', value: 'calendarEvent' }, { name: 'Cash Sale', value: 'cashSale' }, { name: 'Charge', value: 'charge' }, + { name: 'Classification (BETA)', value: 'classification' }, { name: 'Contact', value: 'contact' }, { name: 'Contact Category', value: 'contactCategory' }, { name: 'Contact Role', value: 'contactRole' }, @@ -160,8 +276,9 @@ export class NetSuite implements INodeType { operation: [ 'getRecord', 'updateRecord', - 'deleteRecord', - 'getRecords', + 'removeRecord', + 'listRecords', + 'insertRecord', ], }, }, @@ -177,6 +294,8 @@ export class NetSuite implements INodeType { show: { operation: [ 'getRecord', + 'updateRecord', + 'removeRecord', ], }, }, @@ -191,8 +310,21 @@ export class NetSuite implements INodeType { displayOptions: { show: { operation: [ - 'getRecords', + 'listRecords', 'runSuiteQL', + ], + }, + }, + }, + { + displayName: 'Body', + name: 'body', + type: 'string', + required: false, + default: '', + displayOptions: { + show: { + operation: [ 'rawRequest', ], }, @@ -213,6 +345,37 @@ export class NetSuite implements INodeType { }, description: 'Optionally return only the specified fields and sublists in the response.', }, + { + displayName: 'Replace Sublists', + name: 'replace', + type: 'string', + required: false, + default: '', + displayOptions: { + show: { + operation: [ + 'insertRecord', + 'updateRecord', + ], + }, + }, + description: 'The names of sublists on this record. All sublist lines will be replaced with lines specified in the request. The sublists not specified here will have lines added to the record. The names are delimited by comma.', + }, + { + displayName: 'Replace Selected Fields', + name: 'replaceSelectedFields', + type: 'boolean', + required: true, + default: false, + displayOptions: { + show: { + operation: [ + 'updateRecord', + ], + }, + }, + description: 'If true, all fields that should be deleted in the update request, including body fields, must be included in the replace query parameter.', + }, { displayName: 'Expand Sub-resources', name: 'expandSubResources', @@ -244,6 +407,61 @@ export class NetSuite implements INodeType { }, description: 'If true, returns enumeration values in a format that only shows the internal ID value.', }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'listRecords', + ], + }, + }, + default: true, + description: 'Whether all results should be returned or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'listRecords', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 1000, + }, + default: 100, + description: 'How many records to return', + }, + { + displayName: 'Offset', + name: 'offset', + type: 'number', + displayOptions: { + show: { + operation: [ + 'listRecords', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 0, + }, + default: 0, + description: 'How many records to return', + }, { displayName: 'API Version', name: 'version', @@ -258,9 +476,11 @@ export class NetSuite implements INodeType { show: { operation: [ 'getRecord', - 'getRecords', + 'listRecords', + 'insertRecord', 'updateRecord', - 'deleteRecord', + 'removeRecord', + 'createRecord', ], }, }, @@ -269,37 +489,51 @@ export class NetSuite implements INodeType { ], }; - static async getRecords(options: INetSuiteOperationOptions): Promise { + static async listRecords(options: INetSuiteOperationOptions): Promise { const { fns, credentials, itemIndex } = options; const apiVersion = fns.getNodeParameter('version', itemIndex) as string; const recordType = fns.getNodeParameter('recordType', itemIndex) as string; - const queryLimit = fns.getNodeParameter('queryLimit', itemIndex, 10) as string; + const returnAll = fns.getNodeParameter('returnAll', itemIndex) as boolean; + const query = fns.getNodeParameter('query', itemIndex) as string; + let limit = 100; + let offset = 0; let hasMore = true; - let nextUrl = `services/rest/record/${apiVersion}/${recordType}?limit=${queryLimit}&offset=0`; - let method = 'POST'; - let requestType = 'suiteql'; - const requestData = { - method: 'GET', - requestType: 'record', - nextUrl, - }; + let method = 'GET'; + let nextUrl; + let requestType = NetSuiteRequestType.Record; + const params = new URLSearchParams(); const returnData: INodeExecutionData[] = []; - - while (hasMore === true) { - const response = await makeRequest(getConfig(credentials), { - method, - requestType, - nextUrl, - }); + let prefix = query ? `?${query}` : ''; + if (returnAll !== true) { + prefix = query ? `${prefix}&` : '?'; + limit = fns.getNodeParameter('limit', itemIndex) as number; + offset = fns.getNodeParameter('offset', itemIndex) as number; + params.set('limit', String(limit)); + params.set('offset', String(offset)); + prefix += params.toString(); + } + const requestData: INetSuiteRequestOptions = { + method, + requestType, + path: `services/rest/record/${apiVersion}/${recordType}${prefix}`, + }; + // console.log('requestData', requestData); + while ((returnAll || returnData.length < limit) && hasMore === true) { + const response = await makeRequest(getConfig(credentials), requestData); const body: any = handleNetsuiteResponse(fns, response); - const { hasMore: doContinue, items, links } = body; + const { hasMore: doContinue, items, links } = body.json; if (doContinue) { nextUrl = links.find((link: any) => link.rel === 'next').href; + requestData.nextUrl = nextUrl; } if (Array.isArray(items)) { - returnData.push(...items.map(item => ({ json: item }))); + for (const json of items) { + if (returnAll || returnData.length < limit) { + returnData.push({ json }); + } + } } - hasMore = doContinue; + hasMore = doContinue && (returnAll || returnData.length < limit); } return returnData; } @@ -312,7 +546,6 @@ export class NetSuite implements INodeType { const apiVersion = fns.getNodeParameter('version', itemIndex) as string; const recordType = fns.getNodeParameter('recordType', itemIndex) as string; const internalId = fns.getNodeParameter('internalId', itemIndex) as string; - if (expandSubResources) { params.append('expandSubResources', 'true'); } @@ -322,13 +555,82 @@ export class NetSuite implements INodeType { const q = params.toString(); const requestData = { method: 'GET', - requestType: 'record', + requestType: NetSuiteRequestType.Record, path: `services/rest/record/${apiVersion}/${recordType}/${internalId}${q ? `?${q}` : ''}`, }; const response = await makeRequest(getConfig(credentials), requestData); return handleNetsuiteResponse(fns, response); } + static async removeRecord(options: INetSuiteOperationOptions): Promise { + const { fns, credentials, itemIndex } = options; + const apiVersion = fns.getNodeParameter('version', itemIndex) as string; + const recordType = fns.getNodeParameter('recordType', itemIndex) as string; + const internalId = fns.getNodeParameter('internalId', itemIndex) as string; + const requestData = { + method: 'DELETE', + requestType: NetSuiteRequestType.Record, + path: `services/rest/record/${apiVersion}/${recordType}/${internalId}`, + }; + const response = await makeRequest(getConfig(credentials), requestData); + return handleNetsuiteResponse(fns, response); + } + + static async insertRecord(options: INetSuiteOperationOptions): Promise { + const { fns, credentials, itemIndex, item } = options; + const apiVersion = fns.getNodeParameter('version', itemIndex) as string; + const recordType = fns.getNodeParameter('recordType', itemIndex) as string; + const query = item ? item.json : undefined; + const requestData: INetSuiteRequestOptions = { + method: 'POST', + requestType: NetSuiteRequestType.Record, + path: `services/rest/record/${apiVersion}/${recordType}`, + }; + if (query) requestData.query = query; + const response = await makeRequest(getConfig(credentials), requestData); + return handleNetsuiteResponse(fns, response); + } + + static async updateRecord(options: INetSuiteOperationOptions): Promise { + const { fns, credentials, itemIndex, item } = options; + const apiVersion = fns.getNodeParameter('version', itemIndex) as string; + const recordType = fns.getNodeParameter('recordType', itemIndex) as string; + const internalId = fns.getNodeParameter('internalId', itemIndex) as string; + const query = item ? item.json : undefined; + const requestData: INetSuiteRequestOptions = { + method: 'PATCH', + requestType: NetSuiteRequestType.Record, + path: `services/rest/record/${apiVersion}/${recordType}/${internalId}`, + }; + if (query) requestData.query = query; + const response = await makeRequest(getConfig(credentials), requestData); + return handleNetsuiteResponse(fns, response); + } + + static async rawRequest(options: INetSuiteOperationOptions): Promise { + const { fns, credentials, itemIndex, item } = options; + const path = fns.getNodeParameter('path', itemIndex) as string; + const method = fns.getNodeParameter('method', itemIndex) as string; + const body = fns.getNodeParameter('body', itemIndex) as string; + const requestType = fns.getNodeParameter('requestType', itemIndex) as NetSuiteRequestType; + const query = body || (item ? item.json : undefined); + const requestData: INetSuiteRequestOptions = { + method, + requestType, + path, + }; + if (query && !['GET', 'HEAD', 'OPTIONS'].includes(method)) requestData.query = query; + // console.log('requestData', requestData); + const response = await makeRequest(getConfig(credentials), requestData); + return { + json: { + statusCode: response.statusCode, + headers: response.headers, + body: response.body, + }, + }; + } + async execute(this: IExecuteFunctions): Promise { const credentials: INetSuiteCredentials = (await this.getCredentials('netsuite')) as INetSuiteCredentials; const operation = this.getNodeParameter('operation', 0) as string; @@ -339,12 +641,25 @@ export class NetSuite implements INodeType { console.log(`Processing ${operation} for ${itemIndex+1} of ${items.length}`); const item: INodeExecutionData = items[itemIndex]; if (operation === 'getRecord') { - const record = await NetSuite.getRecord({fns: this, credentials, itemIndex}); + const record = await NetSuite.getRecord({ item, fns: this, credentials, itemIndex}); record.json.orderNo = item.json.orderNo; returnData.push(record); - } else if (operation == 'getRecords') { - const records = await NetSuite.getRecords({fns: this, credentials, itemIndex}); + } else if (operation === 'listRecords') { + const records = await NetSuite.listRecords({ item, fns: this, credentials, itemIndex}); returnData.push(...records); + } else if (operation === 'removeRecord') { + const record = await NetSuite.removeRecord({ item, fns: this, credentials, itemIndex}); + returnData.push(record); + } else if (operation === 'insertRecord') { + const record = await NetSuite.insertRecord({ item, fns: this, credentials, itemIndex}); + console.log(record); + returnData.push(record); + } else if (operation === 'updateRecord') { + const record = await NetSuite.updateRecord({ item, fns: this, credentials, itemIndex}); + returnData.push(record); + } else if (operation === 'rawRequest') { + const record = await NetSuite.rawRequest({ item, fns: this, credentials, itemIndex}); + returnData.push(record); } } diff --git a/nodes/NetSuite/NetSuite.node.types.ts b/nodes/NetSuite/NetSuite.node.types.ts index b6236fb..a0d9ba0 100644 --- a/nodes/NetSuite/NetSuite.node.types.ts +++ b/nodes/NetSuite/NetSuite.node.types.ts @@ -1,4 +1,5 @@ import { IExecuteFunctions } from "n8n-core"; +import { INodeExecutionData } from "n8n-workflow"; export type INetSuiteCredentials = { hostname: string; @@ -10,7 +11,23 @@ export type INetSuiteCredentials = { }; export type INetSuiteOperationOptions = { + item?: INodeExecutionData; fns: IExecuteFunctions; credentials: INetSuiteCredentials; itemIndex: number; +} + +export enum NetSuiteRequestType { + Record = 'record', + SuiteQL = 'suiteql', + Workbook = 'workbook', +} +export type INetSuiteRequestOptions = { + nextUrl?: string; + method: string; + body?: any; + headers?: any; + query?: any; + path?: string; + requestType: NetSuiteRequestType; } \ No newline at end of file diff --git a/package.json b/package.json index 1d438d7..7c1e374 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-netsuite", - "version": "0.2.0", + "version": "0.4.0", "description": "n8n node for NetSuite using the REST API", "license": "MIT", "homepage": "https://github.com/drudge/n8n-nodes-netsuite", From f1d5c3f431f31e59e1a8b256a3da32600cd23d92 Mon Sep 17 00:00:00 2001 From: Nicholas Penree Date: Thu, 12 May 2022 09:13:33 -0400 Subject: [PATCH 2/3] Improve error reporting --- nodes/NetSuite/NetSuite.node.ts | 3 ++- package.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/nodes/NetSuite/NetSuite.node.ts b/nodes/NetSuite/NetSuite.node.ts index 169e0c5..24bdf02 100644 --- a/nodes/NetSuite/NetSuite.node.ts +++ b/nodes/NetSuite/NetSuite.node.ts @@ -4,6 +4,7 @@ import { INodeType, INodeTypeDescription, LoggerProxy as Logger, + NodeApiError, } from 'n8n-workflow'; import { @@ -30,7 +31,7 @@ const handleNetsuiteResponse = function (fns: IExecuteFunctions, response: any) } if (fns.continueOnFail() !== true) { const code = webCode || restletCode; - const error = new Error(code); + const error = new NodeApiError(fns.getNode(), response.body); error.message = message; throw error; } else { diff --git a/package.json b/package.json index 7c1e374..4fa2ca1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-netsuite", - "version": "0.4.0", + "version": "0.4.1", "description": "n8n node for NetSuite using the REST API", "license": "MIT", "homepage": "https://github.com/drudge/n8n-nodes-netsuite", From 41aea02ce4489b3e01fa5fff93957be0b5953e2f Mon Sep 17 00:00:00 2001 From: Nicholas Penree Date: Wed, 18 May 2022 19:11:06 -0400 Subject: [PATCH 3/3] Add support for concurrent REST API calls --- nodes/NetSuite/NetSuite.node.options.ts | 438 +++++++++++++++++++++ nodes/NetSuite/NetSuite.node.ts | 499 +++--------------------- package.json | 5 +- 3 files changed, 500 insertions(+), 442 deletions(-) create mode 100644 nodes/NetSuite/NetSuite.node.options.ts diff --git a/nodes/NetSuite/NetSuite.node.options.ts b/nodes/NetSuite/NetSuite.node.options.ts new file mode 100644 index 0000000..7906958 --- /dev/null +++ b/nodes/NetSuite/NetSuite.node.options.ts @@ -0,0 +1,438 @@ +import { + INodeTypeDescription, +} from 'n8n-workflow'; + +/** + * Options to be displayed + */ +export const nodeDescription: INodeTypeDescription = { + displayName: 'NetSuite', + name: 'netsuite', + group: ['netsuite', 'erp'], + version: 1, + description: 'NetSuite REST API', + defaults: { + name: 'NetSuite', + color: '#125580', + }, + icon: 'file:netSuite.svg', + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'netsuite', + required: true, + }, + ], + properties: [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + options: [ + { + name: 'List Records', + value: 'listRecords', + }, + { + name: 'Get Record', + value: 'getRecord', + }, + { + name: 'Insert Record', + value: 'insertRecord', + }, + { + name: 'Update Record', + value: 'updateRecord', + }, + { + name: 'Remove Record', + value: 'removeRecord', + }, + // { + // name: 'Execute SuiteQL', + // value: 'runSuiteQL', + // }, + // { + // name: 'Get Workbook', + // value: 'getWorkbook', + // }, + { + name: 'Raw Request', + value: 'rawRequest', + }, + ], + default: 'getRecord', + }, + { + displayName: 'Request Type', + name: 'requestType', + type: 'options', + options: [ + { + name: 'Record', + value: 'record', + }, + { + name: 'SuiteQL', + value: 'suiteql', + }, + { + name: 'Workbook', + value: 'workbook', + }, + { + name: 'Dataset', + value: 'dataset', + }, + ], + displayOptions: { + show: { + operation: [ + 'rawRequest', + ], + }, + }, + required: true, + default: 'record', + }, + { + displayName: 'HTTP Method', + name: 'method', + type: 'options', + options: [ + { + name: 'DELETE', + value: 'DELETE', + }, + { + name: 'GET', + value: 'GET', + }, + { + name: 'HEAD', + value: 'HEAD', + }, + { + name: 'OPTIONS', + value: 'OPTIONS', + }, + { + name: 'PATCH', + value: 'PATCH', + }, + { + name: 'POST', + value: 'POST', + }, + { + name: 'PUT', + value: 'PUT', + }, + ], + default: 'GET', + description: 'The request method to use.', + displayOptions: { + show: { + operation: [ + 'rawRequest', + ], + }, + }, + required: true, + }, + { + displayName: 'Path', + name: 'path', + type: 'string', + required: true, + default: 'services/rest/record/v1/salesOrder', + displayOptions: { + show: { + operation: [ + 'rawRequest', + ], + }, + }, + }, + { + displayName: 'Record Type', + name: 'recordType', + type: 'options', + options: [ + { name: 'Assembly Item', value: 'assemblyItem' }, + { name: 'Billing Account', value: 'billingAccount' }, + { name: 'Calendar Event', value: 'calendarEvent' }, + { name: 'Cash Sale', value: 'cashSale' }, + { name: 'Charge', value: 'charge' }, + { name: 'Classification (BETA)', value: 'classification' }, + { name: 'Contact', value: 'contact' }, + { name: 'Contact Category', value: 'contactCategory' }, + { name: 'Contact Role', value: 'contactRole' }, + { name: 'Credit Memo', value: 'creditMemo' }, + { name: 'Customer', value: 'customer' }, + { name: 'Customer Subsidiary Relationship', value: 'customerSubsidiaryRelationship' }, + { name: 'Email Template', value: 'emailTemplate' }, + { name: 'Employee', value: 'employee' }, + { name: 'Inventory Item', value: 'inventoryItem' }, + { name: 'Invoice', value: 'invoice' }, + { name: 'Item Fulfillment', value: 'itemFulfillment' }, + { name: 'Journal Entry', value: 'journalEntry' }, + { name: 'Message', value: 'message' }, + { name: 'Non-Inventory Sale Item', value: 'nonInventorySaleItem' }, + { name: 'Phone Call', value: 'phoneCall' }, + { name: 'Price Book', value: 'priceBook' }, + { name: 'Price Plan', value: 'pricePlan' }, + { name: 'Purchase Order', value: 'purchaseOrder' }, + { name: 'Sales Order', value: 'salesOrder' }, + { name: 'Subscription', value: 'subscription' }, + { name: 'Subscription Change Order', value: 'subscriptionChangeOrder' }, + { name: 'Subscription Line', value: 'subscriptionLine' }, + { name: 'Subscription Plan', value: 'subscriptionPlan' }, + { name: 'Subscription Term', value: 'subscriptionTerm' }, + { name: 'Subsidiary', value: 'subsidiary' }, + { name: 'Task', value: 'task' }, + { name: 'Time Bill', value: 'timeBill' }, + { name: 'Usage', value: 'usage' }, + { name: 'Vendor', value: 'vendor' }, + { name: 'Vendor Bill', value: 'vendorBill' }, + { name: 'Vendor Subsidiary Relationship', value: 'vendorSubsidiaryRelationship' }, + ], + displayOptions: { + show: { + operation: [ + 'getRecord', + 'updateRecord', + 'removeRecord', + 'listRecords', + 'insertRecord', + ], + }, + }, + default: 'salesOrder', + }, + { + displayName: 'ID', + name: 'internalId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + operation: [ + 'getRecord', + 'updateRecord', + 'removeRecord', + ], + }, + }, + description: 'The internal identifier of the record. Prefix with eid: to use the external identifier.', + }, + { + displayName: 'Query', + name: 'query', + type: 'string', + required: false, + default: '', + displayOptions: { + show: { + operation: [ + 'listRecords', + 'runSuiteQL', + ], + }, + }, + }, + { + displayName: 'Body', + name: 'body', + type: 'string', + required: false, + default: '', + displayOptions: { + show: { + operation: [ + 'rawRequest', + ], + }, + }, + }, + { + displayName: 'Restrict Returned Fields', + name: 'fields', + type: 'string', + required: false, + default: '', + displayOptions: { + show: { + operation: [ + 'getRecord', + ], + }, + }, + description: 'Optionally return only the specified fields and sublists in the response.', + }, + { + displayName: 'Replace Sublists', + name: 'replace', + type: 'string', + required: false, + default: '', + displayOptions: { + show: { + operation: [ + 'insertRecord', + 'updateRecord', + ], + }, + }, + description: 'The names of sublists on this record. All sublist lines will be replaced with lines specified in the request. The sublists not specified here will have lines added to the record. The names are delimited by comma.', + }, + { + displayName: 'Replace Selected Fields', + name: 'replaceSelectedFields', + type: 'boolean', + required: true, + default: false, + displayOptions: { + show: { + operation: [ + 'updateRecord', + ], + }, + }, + description: 'If true, all fields that should be deleted in the update request, including body fields, must be included in the replace query parameter.', + }, + { + displayName: 'Expand Sub-resources', + name: 'expandSubResources', + type: 'boolean', + required: true, + default: false, + displayOptions: { + show: { + operation: [ + 'getRecord', + ], + }, + }, + description: 'If true, automatically expands all sublists, sublist lines, and subrecords on this record.', + }, + { + displayName: 'Simple Enum Format', + name: 'simpleEnumFormat', + type: 'boolean', + required: true, + // eslint-disable-next-line n8n-nodes-base/node-param-default-wrong-for-simplify + default: false, + displayOptions: { + show: { + operation: [ + 'getRecord', + ], + }, + }, + description: 'If true, returns enumeration values in a format that only shows the internal ID value.', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'listRecords', + ], + }, + }, + default: true, + description: 'Whether all results should be returned or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'listRecords', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 1000, + }, + default: 100, + description: 'How many records to return', + }, + { + displayName: 'Offset', + name: 'offset', + type: 'number', + displayOptions: { + show: { + operation: [ + 'listRecords', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 0, + }, + default: 0, + description: 'How many records to return', + }, + { + displayName: 'API Version', + name: 'version', + type: 'options', + options: [ + { + name: 'v1', + value: 'v1', + }, + ], + displayOptions: { + show: { + operation: [ + 'getRecord', + 'listRecords', + 'insertRecord', + 'updateRecord', + 'removeRecord', + 'createRecord', + ], + }, + }, + default: 'v1', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + default: {}, + placeholder: 'Add options', + description: 'Add options', + options: [ + { + displayName: 'Concurrency', + name: 'concurrency', + type: 'number', + default: 1, + typeOptions: { + minValue: 1, + }, // eslint-disable-next-line n8n-nodes-base/node-param-description-wrong-for-limit + description: 'Use control the maximum number of REST requests sent to NetSuite at the same time. The default is 1.', + }, + ], + }, + ], +}; \ No newline at end of file diff --git a/nodes/NetSuite/NetSuite.node.ts b/nodes/NetSuite/NetSuite.node.ts index 24bdf02..f7d80b9 100644 --- a/nodes/NetSuite/NetSuite.node.ts +++ b/nodes/NetSuite/NetSuite.node.ts @@ -1,5 +1,7 @@ +import { debuglog } from 'util'; import { IExecuteFunctions } from 'n8n-core'; import { + IDataObject, INodeExecutionData, INodeType, INodeTypeDescription, @@ -11,11 +13,18 @@ import { INetSuiteCredentials, INetSuiteOperationOptions, INetSuiteRequestOptions, NetSuiteRequestType, } from './NetSuite.node.types'; +import { + nodeDescription, +} from './NetSuite.node.options'; + import { makeRequest } from '@fye/netsuite-rest-api'; +import * as pLimit from 'p-limit'; + +const debug = debuglog('n8n-nodes-netsuite'); const handleNetsuiteResponse = function (fns: IExecuteFunctions, response: any) { - // console.log(response); - console.log(`Netsuite response:`, response.statusCode, response.body); + // debug(response); + debug(`Netsuite response:`, response.statusCode, response.body); let body: any = {}; const { title: webTitle = undefined, @@ -64,7 +73,7 @@ const handleNetsuiteResponse = function (fns: IExecuteFunctions, response: any) body.success = response.statusCode === 204; } } - // console.log(body); + // debug(body); return { json: body }; }; @@ -78,417 +87,7 @@ const getConfig = (credentials: INetSuiteCredentials) => ({ }); export class NetSuite implements INodeType { - description: INodeTypeDescription = { - displayName: 'NetSuite', - name: 'netsuite', - group: ['netsuite', 'erp'], - version: 1, - description: 'NetSuite REST API', - defaults: { - name: 'NetSuite', - color: '#125580', - }, - icon: 'file:netSuite.svg', - inputs: ['main'], - outputs: ['main'], - credentials: [ - { - name: 'netsuite', - required: true, - }, - ], - properties: [ - { - displayName: 'Operation', - name: 'operation', - type: 'options', - options: [ - { - name: 'List Records', - value: 'listRecords', - }, - { - name: 'Get Record', - value: 'getRecord', - }, - { - name: 'Insert Record', - value: 'insertRecord', - }, - { - name: 'Update Record', - value: 'updateRecord', - }, - { - name: 'Remove Record', - value: 'removeRecord', - }, - // { - // name: 'Execute SuiteQL', - // value: 'runSuiteQL', - // }, - // { - // name: 'Get Workbook', - // value: 'getWorkbook', - // }, - { - name: 'Raw Request', - value: 'rawRequest', - }, - ], - default: 'getRecord', - }, - { - displayName: 'Request Type', - name: 'requestType', - type: 'options', - options: [ - { - name: 'Record', - value: 'record', - }, - { - name: 'SuiteQL', - value: 'suiteql', - }, - { - name: 'Workbook', - value: 'workbook', - }, - { - name: 'Dataset', - value: 'dataset', - }, - ], - displayOptions: { - show: { - operation: [ - 'rawRequest', - ], - }, - }, - required: true, - default: 'record', - }, - { - displayName: 'HTTP Method', - name: 'method', - type: 'options', - options: [ - { - name: 'DELETE', - value: 'DELETE', - }, - { - name: 'GET', - value: 'GET', - }, - { - name: 'HEAD', - value: 'HEAD', - }, - { - name: 'OPTIONS', - value: 'OPTIONS', - }, - { - name: 'PATCH', - value: 'PATCH', - }, - { - name: 'POST', - value: 'POST', - }, - { - name: 'PUT', - value: 'PUT', - }, - ], - default: 'GET', - description: 'The request method to use.', - displayOptions: { - show: { - operation: [ - 'rawRequest', - ], - }, - }, - required: true, - }, - { - displayName: 'Path', - name: 'path', - type: 'string', - required: true, - default: 'services/rest/record/v1/salesOrder', - displayOptions: { - show: { - operation: [ - 'rawRequest', - ], - }, - }, - }, - { - displayName: 'Record Type', - name: 'recordType', - type: 'options', - options: [ - { name: 'Assembly Item', value: 'assemblyItem' }, - { name: 'Billing Account', value: 'billingAccount' }, - { name: 'Calendar Event', value: 'calendarEvent' }, - { name: 'Cash Sale', value: 'cashSale' }, - { name: 'Charge', value: 'charge' }, - { name: 'Classification (BETA)', value: 'classification' }, - { name: 'Contact', value: 'contact' }, - { name: 'Contact Category', value: 'contactCategory' }, - { name: 'Contact Role', value: 'contactRole' }, - { name: 'Credit Memo', value: 'creditMemo' }, - { name: 'Customer', value: 'customer' }, - { name: 'Customer Subsidiary Relationship', value: 'customerSubsidiaryRelationship' }, - { name: 'Email Template', value: 'emailTemplate' }, - { name: 'Employee', value: 'employee' }, - { name: 'Inventory Item', value: 'inventoryItem' }, - { name: 'Invoice', value: 'invoice' }, - { name: 'Item Fulfillment', value: 'itemFulfillment' }, - { name: 'Journal Entry', value: 'journalEntry' }, - { name: 'Message', value: 'message' }, - { name: 'Non-Inventory Sale Item', value: 'nonInventorySaleItem' }, - { name: 'Phone Call', value: 'phoneCall' }, - { name: 'Price Book', value: 'priceBook' }, - { name: 'Price Plan', value: 'pricePlan' }, - { name: 'Purchase Order', value: 'purchaseOrder' }, - { name: 'Sales Order', value: 'salesOrder' }, - { name: 'Subscription', value: 'subscription' }, - { name: 'Subscription Change Order', value: 'subscriptionChangeOrder' }, - { name: 'Subscription Line', value: 'subscriptionLine' }, - { name: 'Subscription Plan', value: 'subscriptionPlan' }, - { name: 'Subscription Term', value: 'subscriptionTerm' }, - { name: 'Subsidiary', value: 'subsidiary' }, - { name: 'Task', value: 'task' }, - { name: 'Time Bill', value: 'timeBill' }, - { name: 'Usage', value: 'usage' }, - { name: 'Vendor', value: 'vendor' }, - { name: 'Vendor Bill', value: 'vendorBill' }, - { name: 'Vendor Subsidiary Relationship', value: 'vendorSubsidiaryRelationship' }, - ], - displayOptions: { - show: { - operation: [ - 'getRecord', - 'updateRecord', - 'removeRecord', - 'listRecords', - 'insertRecord', - ], - }, - }, - default: 'salesOrder', - }, - { - displayName: 'ID', - name: 'internalId', - type: 'string', - required: true, - default: '', - displayOptions: { - show: { - operation: [ - 'getRecord', - 'updateRecord', - 'removeRecord', - ], - }, - }, - description: 'The internal identifier of the record. Prefix with eid: to use the external identifier.', - }, - { - displayName: 'Query', - name: 'query', - type: 'string', - required: false, - default: '', - displayOptions: { - show: { - operation: [ - 'listRecords', - 'runSuiteQL', - ], - }, - }, - }, - { - displayName: 'Body', - name: 'body', - type: 'string', - required: false, - default: '', - displayOptions: { - show: { - operation: [ - 'rawRequest', - ], - }, - }, - }, - { - displayName: 'Restrict Returned Fields', - name: 'fields', - type: 'string', - required: false, - default: '', - displayOptions: { - show: { - operation: [ - 'getRecord', - ], - }, - }, - description: 'Optionally return only the specified fields and sublists in the response.', - }, - { - displayName: 'Replace Sublists', - name: 'replace', - type: 'string', - required: false, - default: '', - displayOptions: { - show: { - operation: [ - 'insertRecord', - 'updateRecord', - ], - }, - }, - description: 'The names of sublists on this record. All sublist lines will be replaced with lines specified in the request. The sublists not specified here will have lines added to the record. The names are delimited by comma.', - }, - { - displayName: 'Replace Selected Fields', - name: 'replaceSelectedFields', - type: 'boolean', - required: true, - default: false, - displayOptions: { - show: { - operation: [ - 'updateRecord', - ], - }, - }, - description: 'If true, all fields that should be deleted in the update request, including body fields, must be included in the replace query parameter.', - }, - { - displayName: 'Expand Sub-resources', - name: 'expandSubResources', - type: 'boolean', - required: true, - default: false, - displayOptions: { - show: { - operation: [ - 'getRecord', - ], - }, - }, - description: 'If true, automatically expands all sublists, sublist lines, and subrecords on this record.', - }, - { - displayName: 'Simple Enum Format', - name: 'simpleEnumFormat', - type: 'boolean', - required: true, - // eslint-disable-next-line n8n-nodes-base/node-param-default-wrong-for-simplify - default: false, - displayOptions: { - show: { - operation: [ - 'getRecord', - ], - }, - }, - description: 'If true, returns enumeration values in a format that only shows the internal ID value.', - }, - { - displayName: 'Return All', - name: 'returnAll', - type: 'boolean', - displayOptions: { - show: { - operation: [ - 'listRecords', - ], - }, - }, - default: true, - description: 'Whether all results should be returned or only up to a given limit', - }, - { - displayName: 'Limit', - name: 'limit', - type: 'number', - displayOptions: { - show: { - operation: [ - 'listRecords', - ], - returnAll: [ - false, - ], - }, - }, - typeOptions: { - minValue: 1, - maxValue: 1000, - }, - default: 100, - description: 'How many records to return', - }, - { - displayName: 'Offset', - name: 'offset', - type: 'number', - displayOptions: { - show: { - operation: [ - 'listRecords', - ], - returnAll: [ - false, - ], - }, - }, - typeOptions: { - minValue: 0, - }, - default: 0, - description: 'How many records to return', - }, - { - displayName: 'API Version', - name: 'version', - type: 'options', - options: [ - { - name: 'v1', - value: 'v1', - }, - ], - displayOptions: { - show: { - operation: [ - 'getRecord', - 'listRecords', - 'insertRecord', - 'updateRecord', - 'removeRecord', - 'createRecord', - ], - }, - }, - default: 'v1', - }, - ], - }; + description: INodeTypeDescription = nodeDescription; static async listRecords(options: INetSuiteOperationOptions): Promise { const { fns, credentials, itemIndex } = options; @@ -518,7 +117,7 @@ export class NetSuite implements INodeType { requestType, path: `services/rest/record/${apiVersion}/${recordType}${prefix}`, }; - // console.log('requestData', requestData); + // debug('requestData', requestData); while ((returnAll || returnData.length < limit) && hasMore === true) { const response = await makeRequest(getConfig(credentials), requestData); const body: any = handleNetsuiteResponse(fns, response); @@ -540,7 +139,7 @@ export class NetSuite implements INodeType { } static async getRecord(options: INetSuiteOperationOptions): Promise { - const { fns, credentials, itemIndex } = options; + const { item, fns, credentials, itemIndex } = options; const params = new URLSearchParams(); const expandSubResources = fns.getNodeParameter('expandSubResources', itemIndex) as boolean; const simpleEnumFormat = fns.getNodeParameter('simpleEnumFormat', itemIndex) as boolean; @@ -560,6 +159,7 @@ export class NetSuite implements INodeType { path: `services/rest/record/${apiVersion}/${recordType}/${internalId}${q ? `?${q}` : ''}`, }; const response = await makeRequest(getConfig(credentials), requestData); + if (item) response.body.orderNo = item.json.orderNo; return handleNetsuiteResponse(fns, response); } @@ -621,7 +221,7 @@ export class NetSuite implements INodeType { path, }; if (query && !['GET', 'HEAD', 'OPTIONS'].includes(method)) requestData.query = query; - // console.log('requestData', requestData); + // debug('requestData', requestData); const response = await makeRequest(getConfig(credentials), requestData); return { json: { @@ -635,32 +235,51 @@ export class NetSuite implements INodeType { async execute(this: IExecuteFunctions): Promise { const credentials: INetSuiteCredentials = (await this.getCredentials('netsuite')) as INetSuiteCredentials; const operation = this.getNodeParameter('operation', 0) as string; - let items: INodeExecutionData[] = this.getInputData(); - let returnData: INodeExecutionData[] = []; + const items: INodeExecutionData[] = this.getInputData(); + const returnData: INodeExecutionData[] = []; + const promises = []; + const options = this.getNodeParameter('options', 0) as IDataObject; + const concurrency = options.concurrency as number || 1; + const limit = pLimit(concurrency); for (let itemIndex: number = 0; itemIndex < items.length; itemIndex++) { - console.log(`Processing ${operation} for ${itemIndex+1} of ${items.length}`); const item: INodeExecutionData = items[itemIndex]; - if (operation === 'getRecord') { - const record = await NetSuite.getRecord({ item, fns: this, credentials, itemIndex}); - record.json.orderNo = item.json.orderNo; - returnData.push(record); - } else if (operation === 'listRecords') { - const records = await NetSuite.listRecords({ item, fns: this, credentials, itemIndex}); - returnData.push(...records); - } else if (operation === 'removeRecord') { - const record = await NetSuite.removeRecord({ item, fns: this, credentials, itemIndex}); - returnData.push(record); - } else if (operation === 'insertRecord') { - const record = await NetSuite.insertRecord({ item, fns: this, credentials, itemIndex}); - console.log(record); - returnData.push(record); - } else if (operation === 'updateRecord') { - const record = await NetSuite.updateRecord({ item, fns: this, credentials, itemIndex}); - returnData.push(record); - } else if (operation === 'rawRequest') { - const record = await NetSuite.rawRequest({ item, fns: this, credentials, itemIndex}); - returnData.push(record); + let data: INodeExecutionData | INodeExecutionData[]; + + promises.push(limit(async () =>{ + debug(`Processing ${operation} for ${itemIndex+1} of ${items.length}`); + if (operation === 'getRecord') { + data = await NetSuite.getRecord({ item, fns: this, credentials, itemIndex}); + } else if (operation === 'listRecords') { + data = await NetSuite.listRecords({ item, fns: this, credentials, itemIndex}); + } else if (operation === 'removeRecord') { + data = await NetSuite.removeRecord({ item, fns: this, credentials, itemIndex}); + } else if (operation === 'insertRecord') { + data = await NetSuite.insertRecord({ item, fns: this, credentials, itemIndex}); + } else if (operation === 'updateRecord') { + data = await NetSuite.updateRecord({ item, fns: this, credentials, itemIndex}); + } else if (operation === 'rawRequest') { + data = await NetSuite.rawRequest({ item, fns: this, credentials, itemIndex}); + } else { + const error = `The operation "${operation}" is not supported!`; + if (this.continueOnFail() !== true) { + throw new Error(error); + } else { + data = { json: { error } }; + } + } + return data; + })); + } + + const results = await Promise.all(promises); + for await (const result of results) { + if (result) { + if (Array.isArray(result)) { + returnData.push(...result); + } else { + returnData.push(result); + } } } diff --git a/package.json b/package.json index 4fa2ca1..c3b1103 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-netsuite", - "version": "0.4.1", + "version": "0.5.0", "description": "n8n node for NetSuite using the REST API", "license": "MIT", "homepage": "https://github.com/drudge/n8n-nodes-netsuite", @@ -49,7 +49,8 @@ }, "dependencies": { "@fye/netsuite-rest-api": "^2.0.0", - "n8n-core": "~0.101.0" + "n8n-core": "~0.101.0", + "p-limit": "^3.1.0" }, "jest": { "transform": {