From c38f6af4993cd695888ff18b3f95e0d900e65711 Mon Sep 17 00:00:00 2001 From: pemontto <939704+pemontto@users.noreply.github.com> Date: Fri, 27 May 2022 17:39:55 +0100 Subject: [PATCH] feat(ServiceNow Node): Add attachment functionality (#3137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Add ServiceNow attachment functionality * :hammer: download fix * :zap: improvements * :zap: parameter name fix * :zap: download attachment for get all operation * :zap: filters update * :zap: hint update * :zap: Small improvements Co-authored-by: Michael Kret Co-authored-by: ricardo --- .../ServiceNowBasicApi.credentials.ts | 18 ++ .../nodes/ServiceNow/AttachmentDescription.ts | 290 ++++++++++++++++++ .../nodes/ServiceNow/GenericFunctions.ts | 25 ++ .../nodes/ServiceNow/ServiceNow.node.ts | 133 +++++++- 4 files changed, 464 insertions(+), 2 deletions(-) create mode 100644 packages/nodes-base/nodes/ServiceNow/AttachmentDescription.ts diff --git a/packages/nodes-base/credentials/ServiceNowBasicApi.credentials.ts b/packages/nodes-base/credentials/ServiceNowBasicApi.credentials.ts index f5cdd04d5b..09c01fe27a 100644 --- a/packages/nodes-base/credentials/ServiceNowBasicApi.credentials.ts +++ b/packages/nodes-base/credentials/ServiceNowBasicApi.credentials.ts @@ -13,6 +13,24 @@ export class ServiceNowBasicApi implements ICredentialType { displayName = 'ServiceNow Basic Auth API'; documentationUrl = 'serviceNow'; properties: INodeProperties[] = [ + { + displayName: 'User', + name: 'user', + type: 'string', + required: true, + default: '', + + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + required: true, + typeOptions: { + password: true, + }, + default: '', + }, { displayName: 'Subdomain', name: 'subdomain', diff --git a/packages/nodes-base/nodes/ServiceNow/AttachmentDescription.ts b/packages/nodes-base/nodes/ServiceNow/AttachmentDescription.ts new file mode 100644 index 0000000000..be80dba35e --- /dev/null +++ b/packages/nodes-base/nodes/ServiceNow/AttachmentDescription.ts @@ -0,0 +1,290 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const attachmentOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: [ + 'attachment', + ], + }, + }, + options: [ + { + name: 'Upload', + value: 'upload', + description: 'Upload an attachment to a specific table record', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete an attachment', + }, + { + name: 'Get', + value: 'get', + description: 'Get an attachment', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all attachments on a table', + }, + ], + default: 'upload', + }, +]; + +export const attachmentFields: INodeProperties[] = [ + + /* -------------------------------------------------------------------------- */ + /* attachment common fields */ + /* -------------------------------------------------------------------------- */ + + { + displayName: 'Table Name', + name: 'tableName', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTables', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'attachment', + ], + operation: [ + 'upload', + 'getAll', + ], + }, + }, + required: true, + }, + + /* -------------------------------------------------------------------------- */ + /* attachment:upload */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Table Record ID', + name: 'id', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'attachment', + ], + operation: [ + 'upload', + ], + }, + }, + required: true, + description: 'Sys_id of the record in the table specified in Table Name that you want to attach the file to', + }, + { + displayName: 'Input Data Field Name', + name: 'inputDataFieldName', + type: 'string', + default: 'data', + required: true, + displayOptions: { + show: { + resource: [ + 'attachment', + ], + operation: [ + 'upload', + ], + }, + }, + description: 'Name of the binary property that contains the data to upload', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + resource: [ + 'attachment', + ], + operation: [ + 'upload', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'File Name for the Attachment', + name: 'file_name', + type: 'string', + default: '', + description: 'Name to give the attachment', + }, + ], + }, + /* -------------------------------------------------------------------------- */ + /* attachment:delete */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Attachment ID', + name: 'attachmentId', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'attachment', + ], + operation: [ + 'delete', + ], + }, + }, + required: true, + description: 'Sys_id value of the attachment to delete', + }, + /* -------------------------------------------------------------------------- */ + /* attachment:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Attachment ID', + name: 'attachmentId', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'attachment', + ], + operation: [ + 'get', + ], + }, + }, + required: true, + description: 'Sys_id value of the attachment to delete', + }, + /* -------------------------------------------------------------------------- */ + /* attachment:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'attachment', + ], + operation: [ + 'getAll', + ], + }, + }, + default: false, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: [ + 'attachment', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 50, + description: 'Max number of results to return', + }, + { + displayName: 'Download Attachments', + name: 'download', + type: 'boolean', + default: false, + required: true, + displayOptions: { + show: { + resource: [ + 'attachment', + ], + operation: [ + 'get', + 'getAll', + ], + }, + }, + }, + { + displayName: 'Output Field', + name: 'outputField', + type: 'string', + default: 'data', + description: 'Field name where downloaded data will be placed', + displayOptions: { + show: { + resource: [ + 'attachment', + ], + operation: [ + 'get', + 'getAll', + ], + download: [ + true, + ], + }, + }, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + resource: [ + 'attachment', + ], + operation: [ + 'get', 'getAll', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Filter', + name: 'queryFilter', + type: 'string', + placeholder: '', + default: '', + description: 'An encoded query string used to filter the results', + hint: 'All parameters are case-sensitive. Queries can contain more than one entry. more information.', + }, + ], + }, +]; diff --git a/packages/nodes-base/nodes/ServiceNow/GenericFunctions.ts b/packages/nodes-base/nodes/ServiceNow/GenericFunctions.ts index 074d8e7e0d..33d6a7262a 100644 --- a/packages/nodes-base/nodes/ServiceNow/GenericFunctions.ts +++ b/packages/nodes-base/nodes/ServiceNow/GenericFunctions.ts @@ -82,9 +82,34 @@ export async function serviceNowRequestAllItems(this: IExecuteFunctions | ILoadO return returnData; } +export async function serviceNowDownloadAttachment( + this: IExecuteFunctions, + endpoint: string, + fileName: string, + contentType: string, + ) { + const fileData = await serviceNowApiRequest.call( + this, + 'GET', + `${endpoint}/file`, + {}, + {}, + '', + { json: false, encoding: null, resolveWithFullResponse: true }, + ); + const binaryData = await this.helpers.prepareBinaryData( + Buffer.from(fileData.body as string), + fileName, + contentType, + ); + + return binaryData; +} + export const mapEndpoint = (resource: string, operation: string) => { const resourceEndpoint = new Map([ + ['attachment', 'sys_dictionary'], ['tableRecord', 'sys_dictionary'], ['businessService', 'cmdb_ci_service'], ['configurationItems', 'cmdb_ci'], diff --git a/packages/nodes-base/nodes/ServiceNow/ServiceNow.node.ts b/packages/nodes-base/nodes/ServiceNow/ServiceNow.node.ts index fe29ee0505..f47d768fab 100644 --- a/packages/nodes-base/nodes/ServiceNow/ServiceNow.node.ts +++ b/packages/nodes-base/nodes/ServiceNow/ServiceNow.node.ts @@ -4,6 +4,7 @@ import { } from 'n8n-core'; import { + IBinaryData, IDataObject, INodeExecutionData, INodePropertyOptions, @@ -16,10 +17,16 @@ import { import { mapEndpoint, serviceNowApiRequest, + serviceNowDownloadAttachment, serviceNowRequestAllItems, sortData } from './GenericFunctions'; +import { + attachmentFields, + attachmentOperations, +} from './AttachmentDescription'; + import { businessServiceFields, businessServiceOperations, @@ -127,6 +134,10 @@ export class ServiceNow implements INodeType { type: 'options', noDataExpression: true, options: [ + { + name: 'Attachment', + value: 'attachment', + }, { name: 'Business Service', value: 'businessService', @@ -166,7 +177,9 @@ export class ServiceNow implements INodeType { ], default: 'user', }, - + // ATTACHMENT SERVICE + ...attachmentOperations, + ...attachmentFields, // BUSINESS SERVICE ...businessServiceOperations, ...businessServiceFields, @@ -456,7 +469,117 @@ export class ServiceNow implements INodeType { for (let i = 0; i < length; i++) { try { - if (resource === 'businessService') { + // https://developer.servicenow.com/dev.do#!/reference/api/rome/rest/c_AttachmentAPI + if (resource === 'attachment') { + if (operation === 'get') { + + const attachmentsSysId = this.getNodeParameter('attachmentId', i) as string; + const download = this.getNodeParameter('download', i) as boolean; + const endpoint = `/now/attachment/${attachmentsSysId}`; + + const response = await serviceNowApiRequest.call(this, 'GET', endpoint, {}); + const fileMetadata = response.result; + + responseData = { + json: fileMetadata, + }; + + if (download) { + const outputField = this.getNodeParameter('outputField', i) as string; + responseData = { + ...responseData, + binary: { + [outputField]: await serviceNowDownloadAttachment.call( + this, + endpoint, + fileMetadata.file_name, + fileMetadata.content_type, + ), + }, + }; + } + + } else if (operation === 'getAll') { + const download = this.getNodeParameter('download', i) as boolean; + const tableName = this.getNodeParameter('tableName', i) as string; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + const options = this.getNodeParameter('options', i) as IDataObject; + + qs = {} as IDataObject; + + qs.sysparm_query = `table_name=${tableName}`; + + if (options.queryFilter) { + qs.sysparm_query = `${qs.sysparm_query}^${options.queryFilter}`; + } + + if (!returnAll) { + const limit = this.getNodeParameter('limit', i) as number; + qs.sysparm_limit = limit; + const response = await serviceNowApiRequest.call(this, 'GET', '/now/attachment', {}, qs); + responseData = response.result; + } else { + responseData = await serviceNowRequestAllItems.call(this, 'GET', '/now/attachment', {}, qs); + } + + if (download) { + const outputField = this.getNodeParameter('outputField', i) as string; + const responseDataWithAttachments: IDataObject[] = []; + + for (const data of responseData as IDataObject[]) { + responseDataWithAttachments.push({ + json: data, + binary: { + [outputField]: await serviceNowDownloadAttachment.call( + this, + `/now/attachment/${data.sys_id}`, + data.file_name as string, + data.content_type as string, + ), + }, + }); + } + + responseData = responseDataWithAttachments; + } else { + responseData = (responseData as IDataObject[]).map( data => ({ json: data })); + } + + } else if (operation === 'upload') { + const tableName = this.getNodeParameter('tableName', i) as string; + const recordId = this.getNodeParameter('id', i) as string; + const inputDataFieldName = this.getNodeParameter('inputDataFieldName', i) as string; + const options = this.getNodeParameter('options', i) as IDataObject; + + let binaryData: IBinaryData; + + if (items[i].binary && items[i].binary![inputDataFieldName]) { + binaryData = items[i].binary![inputDataFieldName]; + } else { + throw new NodeOperationError(this.getNode(), `No binary data property "${inputDataFieldName}" does not exists on item!`); + } + + const headers: IDataObject = { + 'Content-Type': binaryData.mimeType, + }; + + const qs: IDataObject = { + table_name: tableName, + table_sys_id: recordId, + file_name: binaryData.fileName ? binaryData.fileName : `${inputDataFieldName}.${binaryData.fileExtension}`, + ...options, + }; + + const body = await this.helpers.getBinaryDataBuffer(i, inputDataFieldName) as Buffer; + + const response = await serviceNowApiRequest.call(this, 'POST', '/now/attachment/file', body, qs, '', {headers}); + responseData = response.result; + } else if (operation === 'delete') { + const attachmentsSysId = this.getNodeParameter('attachmentId', i) as string; + await serviceNowApiRequest.call(this, 'DELETE', `/now/attachment/${attachmentsSysId}`); + responseData = {'success': true}; + } + } else if (resource === 'businessService') { if (operation === 'getAll') { @@ -818,6 +941,12 @@ export class ServiceNow implements INodeType { ? returnData.push(...responseData) : returnData.push(responseData); } + + if (resource === 'attachment') { + if (operation === 'get' || operation === 'getAll') { + return this.prepareOutputData(returnData as INodeExecutionData[]); + } + } return [this.helpers.returnJsonArray(returnData)]; } }