From 823ae846bfae1ca29b91dd002dadb9075682568d Mon Sep 17 00:00:00 2001 From: Denis Palashevskii Date: Wed, 5 Aug 2020 00:34:22 +0400 Subject: [PATCH 01/46] Add Custom S3 Endpoint credentials --- .../CustomS3Endpoint.credentials.ts | 45 +++++++++++++++++++ packages/nodes-base/package.json | 1 + 2 files changed, 46 insertions(+) create mode 100644 packages/nodes-base/credentials/CustomS3Endpoint.credentials.ts diff --git a/packages/nodes-base/credentials/CustomS3Endpoint.credentials.ts b/packages/nodes-base/credentials/CustomS3Endpoint.credentials.ts new file mode 100644 index 0000000000..266a38fddf --- /dev/null +++ b/packages/nodes-base/credentials/CustomS3Endpoint.credentials.ts @@ -0,0 +1,45 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class CustomS3Endpoint implements ICredentialType { + name = 'customS3Endpoint'; + displayName = 'Custom S3'; + properties = [ + { + displayName: 'S3 endpoint', + name: 'endpoint', + type: 'string' as NodePropertyTypes, + default: '' + }, + { + displayName: 'Region', + name: 'region', + type: 'string' as NodePropertyTypes, + default: 'us-east-1', + }, + { + displayName: 'Access Key Id', + name: 'accessKeyId', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Secret Access Key', + name: 'secretAccessKey', + type: 'string' as NodePropertyTypes, + default: '', + typeOptions: { + password: true, + }, + }, + { + displayName: 'Force path style', + name: 'forcePathStyle', + type: 'boolean' as NodePropertyTypes, + default: false + }, + ]; +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 6f22c97458..31248624b1 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -49,6 +49,7 @@ "dist/credentials/CopperApi.credentials.js", "dist/credentials/CalendlyApi.credentials.js", "dist/credentials/CustomerIoApi.credentials.js", + "dist/credentials/CustomS3Endpoint.credentials.js", "dist/credentials/CrateDb.credentials.js", "dist/credentials/DisqusApi.credentials.js", "dist/credentials/DriftApi.credentials.js", From 4b22df31ad292cff8330c2eef1084890caa9e83c Mon Sep 17 00:00:00 2001 From: Denis Palashevskii Date: Wed, 5 Aug 2020 14:40:45 +0400 Subject: [PATCH 02/46] Add support for custom S3 endpoint in AWS S3 node --- .../nodes-base/nodes/Aws/S3/AwsS3.node.ts | 113 +++++++++++++----- .../nodes/Aws/S3/GenericFunctions.ts | 55 +++++++-- 2 files changed, 124 insertions(+), 44 deletions(-) diff --git a/packages/nodes-base/nodes/Aws/S3/AwsS3.node.ts b/packages/nodes-base/nodes/Aws/S3/AwsS3.node.ts index 5af9ad3d33..5afa5dbff0 100644 --- a/packages/nodes-base/nodes/Aws/S3/AwsS3.node.ts +++ b/packages/nodes-base/nodes/Aws/S3/AwsS3.node.ts @@ -41,9 +41,9 @@ import { } from './FileDescription'; import { - awsApiRequestREST, - awsApiRequestSOAP, - awsApiRequestSOAPAllItems, + s3ApiRequestREST, + s3ApiRequestSOAP, + s3ApiRequestSOAPAllItems, } from './GenericFunctions'; export class AwsS3 implements INodeType { @@ -65,9 +65,44 @@ export class AwsS3 implements INodeType { { name: 'aws', required: true, - } + displayOptions: { + show: { + endpoint: [ + 'aws', + ], + }, + }, + }, + { + name: 'customS3Endpoint', + required: true, + displayOptions: { + show: { + endpoint: [ + 'customS3Endpoint', + ], + }, + }, + }, ], properties: [ + { + displayName: 'Endpoint', + name: 'endpoint', + type: 'options', + options: [ + { + name: 'AWS', + value: 'aws', + }, + { + name: 'Custom S3 endpoint', + value: 'customS3Endpoint', + }, + ], + default: 'aws', + description: 'The endpoint of S3 compatible service.', + }, { displayName: 'Resource', name: 'resource', @@ -113,7 +148,21 @@ export class AwsS3 implements INodeType { if (resource === 'bucket') { //https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateBucket.html if (operation === 'create') { - const credentials = this.getCredentials('aws'); + + let credentials; + + const endpointType = this.getNodeParameter('endpoint', 0); + + try { + if (endpointType === 'aws') { + credentials = this.getCredentials('aws'); + } else { + credentials = this.getCredentials('customS3Endpoint'); + } + } catch (error) { + throw new Error(error); + } + const name = this.getNodeParameter('name', i) as string; const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; if (additionalFields.acl) { @@ -158,7 +207,7 @@ export class AwsS3 implements INodeType { const builder = new Builder(); data = builder.buildObject(body); } - responseData = await awsApiRequestSOAP.call(this, `${name}.s3`, 'PUT', '', data, qs, headers); + responseData = await s3ApiRequestSOAP.call(this, `${name}`, 'PUT', '', data, qs, headers); returnData.push({ success: true }); } @@ -166,10 +215,10 @@ export class AwsS3 implements INodeType { if (operation === 'getAll') { const returnAll = this.getNodeParameter('returnAll', 0) as boolean; if (returnAll) { - responseData = await awsApiRequestSOAPAllItems.call(this, 'ListAllMyBucketsResult.Buckets.Bucket', 's3', 'GET', ''); + responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListAllMyBucketsResult.Buckets.Bucket', '', 'GET', ''); } else { qs.limit = this.getNodeParameter('limit', 0) as number; - responseData = await awsApiRequestSOAPAllItems.call(this, 'ListAllMyBucketsResult.Buckets.Bucket', 's3', 'GET', '', '', qs); + responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListAllMyBucketsResult.Buckets.Bucket', '', 'GET', '', '', qs); responseData = responseData.slice(0, qs.limit); } returnData.push.apply(returnData, responseData); @@ -208,15 +257,15 @@ export class AwsS3 implements INodeType { qs['list-type'] = 2; - responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', { location: '' }); + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); const region = responseData.LocationConstraint._ as string; if (returnAll) { - responseData = await awsApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', `${bucketName}.s3`, 'GET', '', '', qs, {}, {}, region); + responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', bucketName, 'GET', '', '', qs, {}, {}, region); } else { qs['max-keys'] = this.getNodeParameter('limit', 0) as number; - responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', qs, {}, {}, region); + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', qs, {}, {}, region); responseData = responseData.ListBucketResult.Contents; } if (Array.isArray(responseData)) { @@ -243,11 +292,11 @@ export class AwsS3 implements INodeType { if (additionalFields.storageClass) { headers['x-amz-storage-class'] = (snakeCase(additionalFields.storageClass as string)).toUpperCase(); } - responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', { location: '' }); + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); const region = responseData.LocationConstraint._; - responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'PUT', path, '', qs, headers, {}, region); + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'PUT', path, '', qs, headers, {}, region); returnData.push({ success: true }); } //https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html @@ -255,16 +304,16 @@ export class AwsS3 implements INodeType { const bucketName = this.getNodeParameter('bucketName', i) as string; const folderKey = this.getNodeParameter('folderKey', i) as string; - responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', { location: '' }); + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); const region = responseData.LocationConstraint._; - responseData = await awsApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', `${bucketName}.s3`, 'GET', '/', '', { 'list-type': 2, prefix: folderKey }, {}, {}, region); + responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', bucketName, 'GET', '/', '', { 'list-type': 2, prefix: folderKey }, {}, {}, region); // folder empty then just delete it if (responseData.length === 0) { - responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'DELETE', `/${folderKey}`, '', qs, {}, {}, region); + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'DELETE', `/${folderKey}`, '', qs, {}, {}, region); responseData = { deleted: [ { 'Key': folderKey } ] }; @@ -293,7 +342,7 @@ export class AwsS3 implements INodeType { headers['Content-Type'] = 'application/xml'; - responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'POST', '/', data, { delete: '' } , headers, {}, region); + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'POST', '/', data, { delete: '' } , headers, {}, region); responseData = { deleted: responseData.DeleteResult.Deleted }; } @@ -315,15 +364,15 @@ export class AwsS3 implements INodeType { qs['list-type'] = 2; - responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', { location: '' }); + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); const region = responseData.LocationConstraint._; if (returnAll) { - responseData = await awsApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', `${bucketName}.s3`, 'GET', '', '', qs, {}, {}, region); + responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', bucketName, 'GET', '', '', qs, {}, {}, region); } else { qs.limit = this.getNodeParameter('limit', 0) as number; - responseData = await awsApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', `${bucketName}.s3`, 'GET', '', '', qs, {}, {}, region); + responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', bucketName, 'GET', '', '', qs, {}, {}, region); } if (Array.isArray(responseData)) { responseData = responseData.filter((e: IDataObject) => (e.Key as string).endsWith('/') && e.Size === '0' && e.Key !== options.folderKey); @@ -404,11 +453,11 @@ export class AwsS3 implements INodeType { const destination = `/${destinationParts.slice(2, destinationParts.length).join('/')}`; - responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', { location: '' }); + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); const region = responseData.LocationConstraint._; - responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'PUT', destination, '', qs, headers, {}, region); + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'PUT', destination, '', qs, headers, {}, region); returnData.push(responseData.CopyObjectResult); } @@ -425,11 +474,11 @@ export class AwsS3 implements INodeType { throw new Error('Downloding a whole directory is not yet supported, please provide a file key'); } - let region = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', { location: '' }); + let region = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); region = region.LocationConstraint._; - const response = await awsApiRequestREST.call(this, `${bucketName}.s3`, 'GET', `/${fileKey}`, '', qs, {}, { encoding: null, resolveWithFullResponse: true }, region); + const response = await s3ApiRequestREST.call(this, bucketName, 'GET', `/${fileKey}`, '', qs, {}, { encoding: null, resolveWithFullResponse: true }, region); let mimeType: string | undefined; if (response.headers['content-type']) { @@ -468,11 +517,11 @@ export class AwsS3 implements INodeType { qs.versionId = options.versionId as string; } - responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', { location: '' }); + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); const region = responseData.LocationConstraint._; - responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'DELETE', `/${fileKey}`, '', qs, {}, {}, region); + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'DELETE', `/${fileKey}`, '', qs, {}, {}, region); returnData.push({ success: true }); } @@ -494,15 +543,15 @@ export class AwsS3 implements INodeType { qs['list-type'] = 2; - responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', { location: '' }); + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); const region = responseData.LocationConstraint._; if (returnAll) { - responseData = await awsApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', `${bucketName}.s3`, 'GET', '', '', qs, {}, {}, region); + responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', bucketName, 'GET', '', '', qs, {}, {}, region); } else { qs.limit = this.getNodeParameter('limit', 0) as number; - responseData = await awsApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', `${bucketName}.s3`, 'GET', '', '', qs, {}, {}, region); + responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', bucketName, 'GET', '', '', qs, {}, {}, region); responseData = responseData.splice(0, qs.limit); } if (Array.isArray(responseData)) { @@ -581,7 +630,7 @@ export class AwsS3 implements INodeType { headers['x-amz-tagging'] = tags.join('&'); } - responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', { location: '' }); + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); const region = responseData.LocationConstraint._; @@ -604,7 +653,7 @@ export class AwsS3 implements INodeType { headers['Content-MD5'] = createHash('md5').update(body).digest('base64'); - responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'PUT', `${path}${fileName || binaryData.fileName}`, body, qs, headers, {}, region); + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'PUT', `${path}${fileName || binaryData.fileName}`, body, qs, headers, {}, region); } else { @@ -616,7 +665,7 @@ export class AwsS3 implements INodeType { headers['Content-MD5'] = createHash('md5').update(fileContent).digest('base64'); - responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'PUT', `${path}${fileName}`, body, qs, headers, {}, region); + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'PUT', `${path}${fileName}`, body, qs, headers, {}, region); } returnData.push({ success: true }); } diff --git a/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts b/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts index 92d620d102..40ecb96949 100644 --- a/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts @@ -25,16 +25,47 @@ import { IDataObject, } from 'n8n-workflow'; -export async function awsApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, service: string, method: string, path: string, body?: string | Buffer, query: IDataObject = {}, headers?: object, option: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any - const credentials = this.getCredentials('aws'); +export async function s3ApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, bucket: string, method: string, path: string, body?: string | Buffer, query: IDataObject = {}, headers?: object, option: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any + + let credentials; + + const endpointType = this.getNodeParameter('endpoint', 0); + + try { + if (endpointType === 'aws') { + credentials = this.getCredentials('aws'); + } else { + credentials = this.getCredentials('customS3Endpoint'); + } + } catch (error) { + throw new Error(error); + } + if (credentials === undefined) { throw new Error('No credentials got returned!'); } - const endpoint = `${service}.${region || credentials.region}.amazonaws.com`; + let endpoint = endpointType === 'aws' ? `${bucket}.s3.${region || credentials.region}.amazonaws.com` : credentials.endpoint; + + // Using path-style for non-AWS services + if (bucket && endpointType === 'customS3Endpoint') { + if (credentials.forcePathStyle) { + path = `/${bucket}${path}`; + } else { + endpoint = `${bucket}.${endpoint}`; + } + } // Sign AWS API request with the user credentials - const signOpts = {headers: headers || {}, host: endpoint, method, path: `${path}?${queryToString(query).replace(/\+/g, '%2B')}`, body}; + const signOpts = { + headers: headers || {}, + region: region || credentials.region, + host: endpoint, + method, + path: `${path}?${queryToString(query).replace(/\+/g, '%2B')}`, + service: 's3', + body + }; sign(signOpts, { accessKeyId: `${credentials.accessKeyId}`, secretAccessKey: `${credentials.secretAccessKey}`}); @@ -42,7 +73,7 @@ export async function awsApiRequest(this: IHookFunctions | IExecuteFunctions | I headers: signOpts.headers, method, qs: query, - uri: `https://${endpoint}${signOpts.path}`, + uri: `https://${endpoint}${path}`, body: signOpts.body, }; @@ -52,7 +83,7 @@ export async function awsApiRequest(this: IHookFunctions | IExecuteFunctions | I try { return await this.helpers.request!(options); } catch (error) { - const errorMessage = error.response.body.message || error.response.body.Message || error.message; + const errorMessage = error.response?.body.message || error.response?.body.Message || error.message; if (error.statusCode === 403) { if (errorMessage === 'The security token included in the request is invalid.') { @@ -66,8 +97,8 @@ export async function awsApiRequest(this: IHookFunctions | IExecuteFunctions | I } } -export async function awsApiRequestREST(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, service: string, method: string, path: string, body?: string, query: IDataObject = {}, headers?: object, options: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any - const response = await awsApiRequest.call(this, service, method, path, body, query, headers, options, region); +export async function s3ApiRequestREST(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, bucket: string, method: string, path: string, body?: string, query: IDataObject = {}, headers?: object, options: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any + const response = await s3ApiRequest.call(this, bucket, method, path, body, query, headers, options, region); try { return JSON.parse(response); } catch (e) { @@ -75,8 +106,8 @@ export async function awsApiRequestREST(this: IHookFunctions | IExecuteFunctions } } -export async function awsApiRequestSOAP(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, service: string, method: string, path: string, body?: string | Buffer, query: IDataObject = {}, headers?: object, option: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any - const response = await awsApiRequest.call(this, service, method, path, body, query, headers, option, region); +export async function s3ApiRequestSOAP(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, bucket: string, method: string, path: string, body?: string | Buffer, query: IDataObject = {}, headers?: object, option: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any + const response = await s3ApiRequest.call(this, bucket, method, path, body, query, headers, option, region); try { return await new Promise((resolve, reject) => { parseString(response, { explicitArray: false }, (err, data) => { @@ -91,14 +122,14 @@ export async function awsApiRequestSOAP(this: IHookFunctions | IExecuteFunctions } } -export async function awsApiRequestSOAPAllItems(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, propertyName: string, service: string, method: string, path: string, body?: string, query: IDataObject = {}, headers: IDataObject = {}, option: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any +export async function s3ApiRequestSOAPAllItems(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, propertyName: string, service: string, method: string, path: string, body?: string, query: IDataObject = {}, headers: IDataObject = {}, option: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any const returnData: IDataObject[] = []; let responseData; do { - responseData = await awsApiRequestSOAP.call(this, service, method, path, body, query, headers, option, region); + responseData = await s3ApiRequestSOAP.call(this, service, method, path, body, query, headers, option, region); //https://forums.aws.amazon.com/thread.jspa?threadID=55746 if (get(responseData, `${propertyName.split('.')[0]}.NextContinuationToken`)) { From a3c5a971e50874cbb6ce743c0c77dbb6df18f5fe Mon Sep 17 00:00:00 2001 From: Denis Palashevskii Date: Wed, 5 Aug 2020 15:22:57 +0400 Subject: [PATCH 03/46] Add support for custom endpoint URL scheme (both HTTP and HTTPS) --- .../nodes-base/nodes/Aws/S3/GenericFunctions.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts b/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts index 40ecb96949..1fe0e7b98e 100644 --- a/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts @@ -25,6 +25,8 @@ import { IDataObject, } from 'n8n-workflow'; +import { URL } from 'url'; + export async function s3ApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, bucket: string, method: string, path: string, body?: string | Buffer, query: IDataObject = {}, headers?: object, option: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any let credentials; @@ -45,22 +47,27 @@ export async function s3ApiRequest(this: IHookFunctions | IExecuteFunctions | IL throw new Error('No credentials got returned!'); } - let endpoint = endpointType === 'aws' ? `${bucket}.s3.${region || credentials.region}.amazonaws.com` : credentials.endpoint; + if (endpointType === "customS3Endpoint" && !(credentials.endpoint as string).startsWith('http')) { + throw new Error('HTTP(S) Scheme is required in endpoint definition'); + } + + const endpoint = new URL(endpointType === 'aws' ? `https://${bucket}.s3.${region || credentials.region}.amazonaws.com` : credentials.endpoint as string); - // Using path-style for non-AWS services if (bucket && endpointType === 'customS3Endpoint') { if (credentials.forcePathStyle) { path = `/${bucket}${path}`; } else { - endpoint = `${bucket}.${endpoint}`; + endpoint.host = `${bucket}.${endpoint.host}`; } } + endpoint.pathname = path; + // Sign AWS API request with the user credentials const signOpts = { headers: headers || {}, region: region || credentials.region, - host: endpoint, + host: endpoint.host, method, path: `${path}?${queryToString(query).replace(/\+/g, '%2B')}`, service: 's3', @@ -73,7 +80,7 @@ export async function s3ApiRequest(this: IHookFunctions | IExecuteFunctions | IL headers: signOpts.headers, method, qs: query, - uri: `https://${endpoint}${path}`, + uri: endpoint, body: signOpts.body, }; From 77eb0f4955cd81bd3a2af954549c3999cad7d36f Mon Sep 17 00:00:00 2001 From: Denis Palashevskii Date: Tue, 18 Aug 2020 16:56:53 +0400 Subject: [PATCH 04/46] Create generic S3 node --- .../nodes-base/nodes/Aws/S3/AwsS3.node.ts | 43 +- .../nodes/Aws/S3/GenericFunctions.ts | 19 +- .../nodes-base/nodes/S3/BucketDescription.ts | 327 +++++++ .../nodes-base/nodes/S3/FileDescription.ts | 922 ++++++++++++++++++ .../nodes-base/nodes/S3/FolderDescription.ts | 278 ++++++ .../nodes-base/nodes/S3/GenericFunctions.ts | 155 +++ packages/nodes-base/nodes/S3/S3.node.ts | 640 ++++++++++++ packages/nodes-base/nodes/S3/generic-s3.png | Bin 0 -> 5600 bytes packages/nodes-base/package.json | 1 + 9 files changed, 2325 insertions(+), 60 deletions(-) create mode 100644 packages/nodes-base/nodes/S3/BucketDescription.ts create mode 100644 packages/nodes-base/nodes/S3/FileDescription.ts create mode 100644 packages/nodes-base/nodes/S3/FolderDescription.ts create mode 100644 packages/nodes-base/nodes/S3/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/S3/S3.node.ts create mode 100644 packages/nodes-base/nodes/S3/generic-s3.png diff --git a/packages/nodes-base/nodes/Aws/S3/AwsS3.node.ts b/packages/nodes-base/nodes/Aws/S3/AwsS3.node.ts index 5afa5dbff0..34122f1225 100644 --- a/packages/nodes-base/nodes/Aws/S3/AwsS3.node.ts +++ b/packages/nodes-base/nodes/Aws/S3/AwsS3.node.ts @@ -65,44 +65,9 @@ export class AwsS3 implements INodeType { { name: 'aws', required: true, - displayOptions: { - show: { - endpoint: [ - 'aws', - ], - }, - }, - }, - { - name: 'customS3Endpoint', - required: true, - displayOptions: { - show: { - endpoint: [ - 'customS3Endpoint', - ], - }, - }, }, ], properties: [ - { - displayName: 'Endpoint', - name: 'endpoint', - type: 'options', - options: [ - { - name: 'AWS', - value: 'aws', - }, - { - name: 'Custom S3 endpoint', - value: 'customS3Endpoint', - }, - ], - default: 'aws', - description: 'The endpoint of S3 compatible service.', - }, { displayName: 'Resource', name: 'resource', @@ -151,14 +116,8 @@ export class AwsS3 implements INodeType { let credentials; - const endpointType = this.getNodeParameter('endpoint', 0); - try { - if (endpointType === 'aws') { - credentials = this.getCredentials('aws'); - } else { - credentials = this.getCredentials('customS3Endpoint'); - } + credentials = this.getCredentials('aws'); } catch (error) { throw new Error(error); } diff --git a/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts b/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts index 1fe0e7b98e..bf0839a461 100644 --- a/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts @@ -31,14 +31,8 @@ export async function s3ApiRequest(this: IHookFunctions | IExecuteFunctions | IL let credentials; - const endpointType = this.getNodeParameter('endpoint', 0); - try { - if (endpointType === 'aws') { credentials = this.getCredentials('aws'); - } else { - credentials = this.getCredentials('customS3Endpoint'); - } } catch (error) { throw new Error(error); } @@ -47,19 +41,8 @@ export async function s3ApiRequest(this: IHookFunctions | IExecuteFunctions | IL throw new Error('No credentials got returned!'); } - if (endpointType === "customS3Endpoint" && !(credentials.endpoint as string).startsWith('http')) { - throw new Error('HTTP(S) Scheme is required in endpoint definition'); - } - const endpoint = new URL(endpointType === 'aws' ? `https://${bucket}.s3.${region || credentials.region}.amazonaws.com` : credentials.endpoint as string); - - if (bucket && endpointType === 'customS3Endpoint') { - if (credentials.forcePathStyle) { - path = `/${bucket}${path}`; - } else { - endpoint.host = `${bucket}.${endpoint.host}`; - } - } + const endpoint = new URL(`https://${bucket}.s3.${region || credentials.region}.amazonaws.com`); endpoint.pathname = path; diff --git a/packages/nodes-base/nodes/S3/BucketDescription.ts b/packages/nodes-base/nodes/S3/BucketDescription.ts new file mode 100644 index 0000000000..a6c8d67cd1 --- /dev/null +++ b/packages/nodes-base/nodes/S3/BucketDescription.ts @@ -0,0 +1,327 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const bucketOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'bucket', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a bucket', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all buckets', + }, + { + name: 'Search', + value: 'search', + description: 'Search within a bucket', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const bucketFields = [ + +/* -------------------------------------------------------------------------- */ +/* bucket:create */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Name', + name: 'name', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'bucket', + ], + operation: [ + 'create', + ], + }, + }, + description: 'A succinct description of the nature, symptoms, cause, or effect of the bucket.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + resource: [ + 'bucket', + ], + operation: [ + 'create', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'ACL', + name: 'acl', + type: 'options', + options: [ + { + name: 'Authenticated Read', + value: 'authenticatedRead', + }, + { + name: 'Private', + value: 'Private', + }, + { + name: 'Public Read', + value: 'publicRead', + }, + { + name: 'Public Read Write', + value: 'publicReadWrite', + }, + ], + default: '', + description: 'The canned ACL to apply to the bucket.', + }, + { + displayName: 'Bucket Object Lock Enabled', + name: 'bucketObjectLockEnabled', + type: 'boolean', + default: false, + description: 'Specifies whether you want S3 Object Lock to be enabled for the new bucket.', + }, + { + displayName: 'Grant Full Control', + name: 'grantFullControl', + type: 'boolean', + default: false, + description: 'Allows grantee the read, write, read ACP, and write ACP permissions on the bucket.', + }, + { + displayName: 'Grant Read', + name: 'grantRead', + type: 'boolean', + default: false, + description: 'Allows grantee to list the objects in the bucket.', + }, + { + displayName: 'Grant Read ACP', + name: 'grantReadAcp', + type: 'boolean', + default: false, + description: 'Allows grantee to read the bucket ACL.', + }, + { + displayName: 'Grant Write', + name: 'grantWrite', + type: 'boolean', + default: false, + description: 'Allows grantee to create, overwrite, and delete any object in the bucket.', + }, + { + displayName: 'Grant Write ACP', + name: 'grantWriteAcp', + type: 'boolean', + default: false, + description: 'Allows grantee to write the ACL for the applicable bucket.', + }, + { + displayName: 'Region', + name: 'region', + type: 'string', + default: '', + description: 'Region you want to create the bucket in, by default the buckets are created on the region defined on the credentials.', + }, + ], + }, +/* -------------------------------------------------------------------------- */ +/* bucket:getAll */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'bucket', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'bucket', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return.', + }, +/* -------------------------------------------------------------------------- */ +/* bucket:search */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Bucket Name', + name: 'bucketName', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'bucket', + ], + operation: [ + 'search', + ], + }, + }, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'search', + ], + resource: [ + 'bucket', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'search', + ], + resource: [ + 'bucket', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + resource: [ + 'bucket', + ], + operation: [ + 'search', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Delimiter', + name: 'delimiter', + type: 'string', + default: '', + description: 'A delimiter is a character you use to group keys.', + }, + { + displayName: 'Encoding Type', + name: 'encodingType', + type: 'options', + options: [ + { + name: 'URL', + value: 'url', + }, + ], + default: '', + description: 'Encoding type used by Amazon S3 to encode object keys in the response.', + }, + { + displayName: 'Fetch Owner', + name: 'fetchOwner', + type: 'boolean', + default: false, + description: 'The owner field is not present in listV2 by default, if you want to return owner field with each key in the result then set the fetch owner field to true.', + }, + { + displayName: 'Prefix', + name: 'prefix', + type: 'string', + default: '', + description: 'Limits the response to keys that begin with the specified prefix.', + }, + { + displayName: 'Requester Pays', + name: 'requesterPays', + type: 'boolean', + default: false, + description: 'Weather the requester will pay for requests and data transfer. While Requester Pays is enabled, anonymous access to this bucket is disabled.', + }, + { + displayName: 'Start After', + name: 'startAfter', + type: 'string', + default: '', + description: 'StartAfter is where you want Amazon S3 to start listing from. Amazon S3 starts listing after this specified key', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/S3/FileDescription.ts b/packages/nodes-base/nodes/S3/FileDescription.ts new file mode 100644 index 0000000000..208b847b3d --- /dev/null +++ b/packages/nodes-base/nodes/S3/FileDescription.ts @@ -0,0 +1,922 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const fileOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'file', + ], + }, + }, + options: [ + { + name: 'Copy', + value: 'copy', + description: 'Copy a file', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a file', + }, + { + name: 'Download', + value: 'download', + description: 'Download a file', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all files', + }, + { + name: 'Upload', + value: 'upload', + description: 'Upload a file', + }, + ], + default: 'download', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const fileFields = [ + +/* -------------------------------------------------------------------------- */ +/* file:copy */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Source Path', + name: 'sourcePath', + type: 'string', + required: true, + default: '', + placeholder: '/bucket/my-image.jpg', + displayOptions: { + show: { + resource: [ + 'file', + ], + operation: [ + 'copy', + ], + }, + }, + description: 'The name of the source bucket and key name of the source object, separated by a slash (/)', + }, + { + displayName: 'Destination Path', + name: 'destinationPath', + type: 'string', + required: true, + default: '', + placeholder: '/bucket/my-second-image.jpg', + displayOptions: { + show: { + resource: [ + 'file', + ], + operation: [ + 'copy', + ], + }, + }, + description: 'The name of the destination bucket and key name of the destination object, separated by a slash (/)', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + resource: [ + 'file', + ], + operation: [ + 'copy', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'ACL', + name: 'acl', + type: 'options', + options: [ + { + name: 'Authenticated Read', + value: 'authenticatedRead' + }, + { + name: 'AWS Exec Read', + value: 'awsExecRead' + }, + { + name: 'Bucket Owner Full Control', + value: 'bucketOwnerFullControl' + }, + { + name: 'Bucket Owner Read', + value: 'bucketOwnerRead' + }, + { + name: 'Private', + value: 'private', + }, + { + name: 'Public Read', + value: 'publicRead' + }, + { + name: 'Public Read Write', + value: 'publicReadWrite' + }, + ], + default: 'private', + description: 'The canned ACL to apply to the object.' + }, + { + displayName: 'Grant Full Control', + name: 'grantFullControl', + type: 'boolean', + default: false, + description: 'Gives the grantee READ, READ_ACP, and WRITE_ACP permissions on the object.', + }, + { + displayName: 'Grant Read', + name: 'grantRead', + type: 'boolean', + default: false, + description: 'Allows grantee to read the object data and its metadata.', + }, + { + displayName: 'Grant Read ACP', + name: 'grantReadAcp', + type: 'boolean', + default: false, + description: 'Allows grantee to read the object ACL.', + }, + { + displayName: 'Grant Write ACP', + name: 'grantWriteAcp', + type: 'boolean', + default: false, + description: 'Allows grantee to write the ACL for the applicable object.', + }, + { + displayName: 'Lock Legal Hold', + name: 'lockLegalHold', + type: 'boolean', + default: false, + description: 'Specifies whether a legal hold will be applied to this object', + }, + { + displayName: 'Lock Mode', + name: 'lockMode', + type: 'options', + options: [ + { + name: 'Governance', + value: 'governance', + }, + { + name: 'Compliance', + value: 'compliance', + }, + ], + default: '', + description: 'The Object Lock mode that you want to apply to this object.', + }, + { + displayName: 'Lock Retain Until Date', + name: 'lockRetainUntilDate', + type: 'dateTime', + default: '', + description: `The date and time when you want this object's Object Lock to expire.`, + }, + { + displayName: 'Metadata Directive', + name: 'metadataDirective', + type: 'options', + options: [ + { + name: 'Copy', + value: 'copy', + }, + { + name: 'Replace', + value: 'replace', + }, + ], + default: '', + description: 'Specifies whether the metadata is copied from the source object or replaced with metadata provided in the request.', + }, + { + displayName: 'Requester Pays', + name: 'requesterPays', + type: 'boolean', + default: false, + description: 'Weather the requester will pay for requests and data transfer. While Requester Pays is enabled, anonymous access to this bucket is disabled.', + }, + { + displayName: 'Server Side Encryption', + name: 'serverSideEncryption', + type: 'options', + options: [ + { + name: 'AES256', + value: 'AES256', + }, + { + name: 'AWS:KMS', + value: 'aws:kms', + }, + ], + default: '', + description: 'The server-side encryption algorithm used when storing this object in Amazon S3', + }, + { + displayName: 'Server Side Encryption Context', + name: 'serverSideEncryptionContext', + type: 'string', + default: '', + description: 'Specifies the AWS KMS Encryption Context to use for object encryption', + }, + { + displayName: 'Server Side Encryption AWS KMS Key ID', + name: 'encryptionAwsKmsKeyId', + type: 'string', + default: '', + description: 'If x-amz-server-side-encryption is present and has the value of aws:kms', + }, + { + displayName: 'Server Side Encryption Customer Algorithm', + name: 'serversideEncryptionCustomerAlgorithm', + type: 'string', + default: '', + description: 'Specifies the algorithm to use to when encrypting the object (for example, AES256).', + }, + { + displayName: 'Server Side Encryption Customer Key', + name: 'serversideEncryptionCustomerKey', + type: 'string', + default: '', + description: 'Specifies the customer-provided encryption key for Amazon S3 to use in encrypting data', + }, + { + displayName: 'Server Side Encryption Customer Key MD5', + name: 'serversideEncryptionCustomerKeyMD5', + type: 'string', + default: '', + description: 'Specifies the 128-bit MD5 digest of the encryption key according to RFC 1321.', + }, + { + displayName: 'Storage Class', + name: 'storageClass', + type: 'options', + options: [ + { + name: 'Deep Archive', + value: 'deepArchive', + }, + { + name: 'Intelligent Tiering', + value: 'intelligentTiering', + }, + { + name: 'One Zone IA', + value: 'onezoneIA', + }, + { + name: 'Glacier', + value: 'glacier', + }, + { + name: 'Standard', + value: 'standard', + }, + { + name: 'Standard IA', + value: 'standardIA', + }, + ], + default: 'standard', + description: 'Amazon S3 storage classes.', + }, + { + displayName: 'Tagging Directive', + name: 'taggingDirective', + type: 'options', + options: [ + { + name: 'Copy', + value: 'copy', + }, + { + name: 'Replace', + value: 'replace', + }, + ], + default: '', + description: 'Specifies whether the metadata is copied from the source object or replaced with metadata provided in the request.', + }, + ], + }, +/* -------------------------------------------------------------------------- */ +/* file:upload */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Bucket Name', + name: 'bucketName', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'file', + ], + operation: [ + 'upload', + ], + }, + }, + }, + { + displayName: 'File Name', + name: 'fileName', + type: 'string', + default: '', + placeholder: 'hello.txt', + required: true, + displayOptions: { + show: { + resource: [ + 'file', + ], + operation: [ + 'upload', + ], + binaryData: [ + false, + ], + }, + }, + }, + { + displayName: 'File Name', + name: 'fileName', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'file', + ], + operation: [ + 'upload', + ], + binaryData: [ + true, + ], + }, + }, + description: 'If not set the binary data filename will be used.', + }, + { + displayName: 'Binary Data', + name: 'binaryData', + type: 'boolean', + default: true, + displayOptions: { + show: { + operation: [ + 'upload' + ], + resource: [ + 'file', + ], + }, + }, + description: 'If the data to upload should be taken from binary field.', + }, + { + displayName: 'File Content', + name: 'fileContent', + type: 'string', + default: '', + displayOptions: { + show: { + operation: [ + 'upload' + ], + resource: [ + 'file', + ], + binaryData: [ + false + ], + }, + }, + placeholder: '', + description: 'The text content of the file to upload.', + }, + { + displayName: 'Binary Property', + name: 'binaryPropertyName', + type: 'string', + default: 'data', + required: true, + displayOptions: { + show: { + operation: [ + 'upload' + ], + resource: [ + 'file', + ], + binaryData: [ + true + ], + }, + + }, + placeholder: '', + description: 'Name of the binary property which contains
the data for the file to be uploaded.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + resource: [ + 'file', + ], + operation: [ + 'upload', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'ACL', + name: 'acl', + type: 'options', + options: [ + { + name: 'Authenticated Read', + value: 'authenticatedRead' + }, + { + name: 'AWS Exec Read', + value: 'awsExecRead' + }, + { + name: 'Bucket Owner Full Control', + value: 'bucketOwnerFullControl' + }, + { + name: 'Bucket Owner Read', + value: 'bucketOwnerRead' + }, + { + name: 'Private', + value: 'private', + }, + { + name: 'Public Read', + value: 'publicRead' + }, + { + name: 'Public Read Write', + value: 'publicReadWrite' + }, + ], + default: 'private', + description: 'The canned ACL to apply to the object.' + }, + { + displayName: 'Grant Full Control', + name: 'grantFullControl', + type: 'boolean', + default: false, + description: 'Gives the grantee READ, READ_ACP, and WRITE_ACP permissions on the object.', + }, + { + displayName: 'Grant Read', + name: 'grantRead', + type: 'boolean', + default: false, + description: 'Allows grantee to read the object data and its metadata.', + }, + { + displayName: 'Grant Read ACP', + name: 'grantReadAcp', + type: 'boolean', + default: false, + description: 'Allows grantee to read the object ACL.', + }, + { + displayName: 'Grant Write ACP', + name: 'grantWriteAcp', + type: 'boolean', + default: false, + description: 'Allows grantee to write the ACL for the applicable object.', + }, + { + displayName: 'Lock Legal Hold', + name: 'lockLegalHold', + type: 'boolean', + default: false, + description: 'Specifies whether a legal hold will be applied to this object', + }, + { + displayName: 'Lock Mode', + name: 'lockMode', + type: 'options', + options: [ + { + name: 'Governance', + value: 'governance', + }, + { + name: 'Compliance', + value: 'compliance', + }, + ], + default: '', + description: 'The Object Lock mode that you want to apply to this object.', + }, + { + displayName: 'Lock Retain Until Date', + name: 'lockRetainUntilDate', + type: 'dateTime', + default: '', + description: `The date and time when you want this object's Object Lock to expire.`, + }, + { + displayName: 'Parent Folder Key', + name: 'parentFolderKey', + type: 'string', + default: '', + description: 'Parent file you want to create the file in', + }, + { + displayName: 'Requester Pays', + name: 'requesterPays', + type: 'boolean', + default: false, + description: 'Weather the requester will pay for requests and data transfer. While Requester Pays is enabled, anonymous access to this bucket is disabled.', + }, + { + displayName: 'Server Side Encryption', + name: 'serverSideEncryption', + type: 'options', + options: [ + { + name: 'AES256', + value: 'AES256', + }, + { + name: 'AWS:KMS', + value: 'aws:kms', + }, + ], + default: '', + description: 'The server-side encryption algorithm used when storing this object in Amazon S3', + }, + { + displayName: 'Server Side Encryption Context', + name: 'serverSideEncryptionContext', + type: 'string', + default: '', + description: 'Specifies the AWS KMS Encryption Context to use for object encryption', + }, + { + displayName: 'Server Side Encryption AWS KMS Key ID', + name: 'encryptionAwsKmsKeyId', + type: 'string', + default: '', + description: 'If x-amz-server-side-encryption is present and has the value of aws:kms', + }, + { + displayName: 'Server Side Encryption Customer Algorithm', + name: 'serversideEncryptionCustomerAlgorithm', + type: 'string', + default: '', + description: 'Specifies the algorithm to use to when encrypting the object (for example, AES256).', + }, + { + displayName: 'Server Side Encryption Customer Key', + name: 'serversideEncryptionCustomerKey', + type: 'string', + default: '', + description: 'Specifies the customer-provided encryption key for Amazon S3 to use in encrypting data', + }, + { + displayName: 'Server Side Encryption Customer Key MD5', + name: 'serversideEncryptionCustomerKeyMD5', + type: 'string', + default: '', + description: 'Specifies the 128-bit MD5 digest of the encryption key according to RFC 1321.', + }, + { + displayName: 'Storage Class', + name: 'storageClass', + type: 'options', + options: [ + { + name: 'Deep Archive', + value: 'deepArchive', + }, + { + name: 'Intelligent Tiering', + value: 'intelligentTiering', + }, + { + name: 'One Zone IA', + value: 'onezoneIA', + }, + { + name: 'Glacier', + value: 'glacier', + }, + { + name: 'Standard', + value: 'standard', + }, + { + name: 'Standard IA', + value: 'standardIA', + }, + ], + default: 'standard', + description: 'Amazon S3 storage classes.', + }, + ], + }, + { + displayName: 'Tags', + name: 'tagsUi', + placeholder: 'Add Tag', + type: 'fixedCollection', + default: '', + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + resource: [ + 'file', + ], + operation: [ + 'upload', + ], + }, + }, + options: [ + { + name: 'tagsValues', + displayName: 'Tag', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + default: '', + description: '', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: '', + }, + ], + } + ], + description: 'Optional extra headers to add to the message (most headers are allowed).', + }, +/* -------------------------------------------------------------------------- */ +/* file:download */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Bucket Name', + name: 'bucketName', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'file', + ], + operation: [ + 'download', + ], + }, + }, + }, + { + displayName: 'File Key', + name: 'fileKey', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'file', + ], + operation: [ + 'download', + ], + }, + }, + }, + { + displayName: 'Binary Property', + name: 'binaryPropertyName', + type: 'string', + required: true, + default: 'data', + displayOptions: { + show: { + operation: [ + 'download' + ], + resource: [ + 'file', + ], + }, + }, + description: 'Name of the binary property to which to
write the data of the read file.', + }, +/* -------------------------------------------------------------------------- */ +/* file:delete */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Bucket Name', + name: 'bucketName', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'file', + ], + operation: [ + 'delete', + ], + }, + }, + }, + { + displayName: 'File Key', + name: 'fileKey', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'file', + ], + operation: [ + 'delete', + ], + }, + }, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'file', + ], + operation: [ + 'delete', + ], + }, + }, + options: [ + { + displayName: 'Version ID', + name: 'versionId', + type: 'string', + default: '', + }, + ], + }, +/* -------------------------------------------------------------------------- */ +/* file:getAll */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Bucket Name', + name: 'bucketName', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'file', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'file', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'file', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return.', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'file', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Fetch Owner', + name: 'fetchOwner', + type: 'boolean', + default: false, + description: 'The owner field is not present in listV2 by default, if you want to return owner field with each key in the result then set the fetch owner field to true.', + }, + { + displayName: 'Folder Key', + name: 'folderKey', + type: 'string', + default: '', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/S3/FolderDescription.ts b/packages/nodes-base/nodes/S3/FolderDescription.ts new file mode 100644 index 0000000000..2884206d66 --- /dev/null +++ b/packages/nodes-base/nodes/S3/FolderDescription.ts @@ -0,0 +1,278 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const folderOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'folder', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a folder', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a folder', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all folders', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const folderFields = [ + +/* -------------------------------------------------------------------------- */ +/* folder:create */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Bucket Name', + name: 'bucketName', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'folder', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Folder Name', + name: 'folderName', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'folder', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + resource: [ + 'folder', + ], + operation: [ + 'create', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Parent Folder Key', + name: 'parentFolderKey', + type: 'string', + default: '', + description: 'Parent folder you want to create the folder in' + }, + { + displayName: 'Requester Pays', + name: 'requesterPays', + type: 'boolean', + default: false, + description: 'Weather the requester will pay for requests and data transfer. While Requester Pays is enabled, anonymous access to this bucket is disabled.', + }, + { + displayName: 'Storage Class', + name: 'storageClass', + type: 'options', + options: [ + { + name: 'Deep Archive', + value: 'deepArchive', + }, + { + name: 'Intelligent Tiering', + value: 'intelligentTiering', + }, + { + name: 'One Zone IA', + value: 'onezoneIA', + }, + { + name: 'Glacier', + value: 'glacier', + }, + { + name: 'Reduced Redundancy', + value: 'RecudedRedundancy', + }, + { + name: 'Standard', + value: 'standard', + }, + { + name: 'Standard IA', + value: 'standardIA', + }, + ], + default: 'standard', + description: 'Amazon S3 storage classes.' + }, + ], + }, +/* -------------------------------------------------------------------------- */ +/* folder:delete */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Bucket Name', + name: 'bucketName', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'folder', + ], + operation: [ + 'delete', + ], + }, + }, + }, + { + displayName: 'Folder Key', + name: 'folderKey', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'folder', + ], + operation: [ + 'delete', + ], + }, + }, + }, +/* -------------------------------------------------------------------------- */ +/* folder:getAll */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Bucket Name', + name: 'bucketName', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'folder', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'folder', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'folder', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return.', + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'folder', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Fetch Owner', + name: 'fetchOwner', + type: 'boolean', + default: false, + description: 'The owner field is not present in listV2 by default, if you want to return owner field with each key in the result then set the fetch owner field to true.', + }, + { + displayName: 'Folder Key', + name: 'folderKey', + type: 'string', + default: '', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/S3/GenericFunctions.ts b/packages/nodes-base/nodes/S3/GenericFunctions.ts new file mode 100644 index 0000000000..00f59b3f70 --- /dev/null +++ b/packages/nodes-base/nodes/S3/GenericFunctions.ts @@ -0,0 +1,155 @@ +import { + sign, +} from 'aws4'; + +import { + get, +} from 'lodash'; + +import { + OptionsWithUri, +} from 'request'; + +import { + parseString, +} from 'xml2js'; + +import { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + IDataObject, + } from 'n8n-workflow'; + +import { URL } from 'url'; + +export async function s3ApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, bucket: string, method: string, path: string, body?: string | Buffer, query: IDataObject = {}, headers?: object, option: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any + + let credentials; + + credentials = this.getCredentials('customS3Endpoint'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + if (!(credentials.endpoint as string).startsWith('http')) { + throw new Error('HTTP(S) Scheme is required in endpoint definition'); + } + + const endpoint = new URL(credentials.endpoint as string); + + if (bucket) { + if (credentials.forcePathStyle) { + path = `/${bucket}${path}`; + } else { + endpoint.host = `${bucket}.${endpoint.host}`; + } + } + + endpoint.pathname = path; + + // Sign AWS API request with the user credentials + const signOpts = { + headers: headers || {}, + region: region || credentials.region, + host: endpoint.host, + method, + path: `${path}?${queryToString(query).replace(/\+/g, '%2B')}`, + service: 's3', + body + }; + + sign(signOpts, { accessKeyId: `${credentials.accessKeyId}`, secretAccessKey: `${credentials.secretAccessKey}`}); + + const options: OptionsWithUri = { + headers: signOpts.headers, + method, + qs: query, + uri: endpoint, + body: signOpts.body, + }; + + if (Object.keys(option).length !== 0) { + Object.assign(options, option); + } + try { + return await this.helpers.request!(options); + } catch (error) { + const errorMessage = error.response?.body.message || error.response?.body.Message || error.message; + + if (error.statusCode === 403) { + if (errorMessage === 'The security token included in the request is invalid.') { + throw new Error('The S3 credentials are not valid!'); + } else if (errorMessage.startsWith('The request signature we calculated does not match the signature you provided')) { + throw new Error('The S3 credentials are not valid!'); + } + } + + throw new Error(`S3 error response [${error.statusCode}]: ${errorMessage}`); + } +} + +export async function s3ApiRequestREST(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, bucket: string, method: string, path: string, body?: string, query: IDataObject = {}, headers?: object, options: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any + const response = await s3ApiRequest.call(this, bucket, method, path, body, query, headers, options, region); + try { + return JSON.parse(response); + } catch (e) { + return response; + } +} + +export async function s3ApiRequestSOAP(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, bucket: string, method: string, path: string, body?: string | Buffer, query: IDataObject = {}, headers?: object, option: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any + const response = await s3ApiRequest.call(this, bucket, method, path, body, query, headers, option, region); + try { + return await new Promise((resolve, reject) => { + parseString(response, { explicitArray: false }, (err, data) => { + if (err) { + return reject(err); + } + resolve(data); + }); + }); + } catch (e) { + return e; + } +} + +export async function s3ApiRequestSOAPAllItems(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, propertyName: string, service: string, method: string, path: string, body?: string, query: IDataObject = {}, headers: IDataObject = {}, option: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + + do { + responseData = await s3ApiRequestSOAP.call(this, service, method, path, body, query, headers, option, region); + + //https://forums.aws.amazon.com/thread.jspa?threadID=55746 + if (get(responseData, `${propertyName.split('.')[0]}.NextContinuationToken`)) { + query['continuation-token'] = get(responseData, `${propertyName.split('.')[0]}.NextContinuationToken`); + } + if (get(responseData, propertyName)) { + if (Array.isArray(get(responseData, propertyName))) { + returnData.push.apply(returnData, get(responseData, propertyName)); + } else { + returnData.push(get(responseData, propertyName)); + } + } + if (query.limit && query.limit <= returnData.length) { + return returnData; + } + } while ( + get(responseData, `${propertyName.split('.')[0]}.IsTruncated`) !== undefined && + get(responseData, `${propertyName.split('.')[0]}.IsTruncated`) !== 'false' + ); + + return returnData; +} + +function queryToString(params: IDataObject) { + return Object.keys(params).map(key => key + '=' + params[key]).join('&'); +} diff --git a/packages/nodes-base/nodes/S3/S3.node.ts b/packages/nodes-base/nodes/S3/S3.node.ts new file mode 100644 index 0000000000..9eba6ab6d3 --- /dev/null +++ b/packages/nodes-base/nodes/S3/S3.node.ts @@ -0,0 +1,640 @@ + +import { + snakeCase, + paramCase, +} from 'change-case'; + +import { + createHash, +} from 'crypto'; + +import { + Builder, +} from 'xml2js'; + +import { + BINARY_ENCODING, + IExecuteFunctions, +} from 'n8n-core'; + +import { + IBinaryKeyData, + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + bucketFields, + bucketOperations, +} from './BucketDescription'; + +import { + folderFields, + folderOperations, +} from './FolderDescription'; + +import { + fileFields, + fileOperations, +} from './FileDescription'; + +import { + s3ApiRequestREST, + s3ApiRequestSOAP, + s3ApiRequestSOAPAllItems, +} from './GenericFunctions'; + +export class S3 implements INodeType { + description: INodeTypeDescription = { + displayName: 'S3', + name: 'S3', + icon: 'file:generic-s3.png', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Sends data to any S3-compatible services', + defaults: { + name: 'S3', + color: '#d05b4b', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'customS3Endpoint', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Bucket', + value: 'bucket', + }, + { + name: 'File', + value: 'file', + }, + { + name: 'Folder', + value: 'folder', + }, + ], + default: 'file', + description: 'The operation to perform.', + }, + // BUCKET + ...bucketOperations, + ...bucketFields, + // FOLDER + ...folderOperations, + ...folderFields, + // UPLOAD + ...fileOperations, + ...fileFields, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const qs: IDataObject = {}; + const headers: IDataObject = {}; + let responseData; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + for (let i = 0; i < items.length; i++) { + if (resource === 'bucket') { + //https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateBucket.html + if (operation === 'create') { + + let credentials; + + try { + credentials = this.getCredentials('customS3Endpoint'); + } catch (error) { + throw new Error(error); + } + + const name = this.getNodeParameter('name', i) as string; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + if (additionalFields.acl) { + headers['x-amz-acl'] = paramCase(additionalFields.acl as string); + } + if (additionalFields.bucketObjectLockEnabled) { + headers['x-amz-bucket-object-lock-enabled'] = additionalFields.bucketObjectLockEnabled as boolean; + } + if (additionalFields.grantFullControl) { + headers['x-amz-grant-full-control'] = ''; + } + if (additionalFields.grantRead) { + headers['x-amz-grant-read'] = ''; + } + if (additionalFields.grantReadAcp) { + headers['x-amz-grant-read-acp'] = ''; + } + if (additionalFields.grantWrite) { + headers['x-amz-grant-write'] = ''; + } + if (additionalFields.grantWriteAcp) { + headers['x-amz-grant-write-acp'] = ''; + } + let region = credentials!.region as string; + + if (additionalFields.region) { + region = additionalFields.region as string; + } + + const body: IDataObject = { + CreateBucketConfiguration: { + '$': { + xmlns: 'http://s3.amazonaws.com/doc/2006-03-01/', + }, + } + }; + let data = ''; + // if credentials has the S3 defaul region (us-east-1) the body (XML) does not have to be sent. + if (region !== 'us-east-1') { + // @ts-ignore + body.CreateBucketConfiguration.LocationConstraint = [region]; + const builder = new Builder(); + data = builder.buildObject(body); + } + responseData = await s3ApiRequestSOAP.call(this, `${name}`, 'PUT', '', data, qs, headers); + + returnData.push({ success: true }); + } + //https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListBuckets.html + if (operation === 'getAll') { + const returnAll = this.getNodeParameter('returnAll', 0) as boolean; + if (returnAll) { + responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListAllMyBucketsResult.Buckets.Bucket', '', 'GET', ''); + } else { + qs.limit = this.getNodeParameter('limit', 0) as number; + responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListAllMyBucketsResult.Buckets.Bucket', '', 'GET', '', '', qs); + responseData = responseData.slice(0, qs.limit); + } + returnData.push.apply(returnData, responseData); + } + + //https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html + if (operation === 'search') { + const bucketName = this.getNodeParameter('bucketName', i) as string; + const returnAll = this.getNodeParameter('returnAll', 0) as boolean; + const additionalFields = this.getNodeParameter('additionalFields', 0) as IDataObject; + + if (additionalFields.prefix) { + qs['prefix'] = additionalFields.prefix as string; + } + + if (additionalFields.encodingType) { + qs['encoding-type'] = additionalFields.encodingType as string; + } + + if (additionalFields.delmiter) { + qs['delimiter'] = additionalFields.delmiter as string; + } + + if (additionalFields.fetchOwner) { + qs['fetch-owner'] = additionalFields.fetchOwner as string; + } + + if (additionalFields.startAfter) { + qs['start-after'] = additionalFields.startAfter as string; + } + + if (additionalFields.requesterPays) { + qs['x-amz-request-payer'] = 'requester'; + } + + qs['list-type'] = 2; + + + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); + + const region = responseData.LocationConstraint._ as string; + + if (returnAll) { + responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', bucketName, 'GET', '', '', qs, {}, {}, region); + } else { + qs['max-keys'] = this.getNodeParameter('limit', 0) as number; + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', qs, {}, {}, region); + responseData = responseData.ListBucketResult.Contents; + } + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData); + } else { + returnData.push(responseData); + } + } + } + if (resource === 'folder') { + //https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html + if (operation === 'create') { + const bucketName = this.getNodeParameter('bucketName', i) as string; + const folderName = this.getNodeParameter('folderName', i) as string; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + let path = `/${folderName}/`; + + if (additionalFields.requesterPays) { + headers['x-amz-request-payer'] = 'requester'; + } + if (additionalFields.parentFolderKey) { + path = `/${additionalFields.parentFolderKey}${folderName}/`; + } + if (additionalFields.storageClass) { + headers['x-amz-storage-class'] = (snakeCase(additionalFields.storageClass as string)).toUpperCase(); + } + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); + + const region = responseData.LocationConstraint._; + + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'PUT', path, '', qs, headers, {}, region); + returnData.push({ success: true }); + } + //https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html + if (operation === 'delete') { + const bucketName = this.getNodeParameter('bucketName', i) as string; + const folderKey = this.getNodeParameter('folderKey', i) as string; + + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); + + const region = responseData.LocationConstraint._; + + responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', bucketName, 'GET', '/', '', { 'list-type': 2, prefix: folderKey }, {}, {}, region); + + // folder empty then just delete it + if (responseData.length === 0) { + + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'DELETE', `/${folderKey}`, '', qs, {}, {}, region); + + responseData = { deleted: [ { 'Key': folderKey } ] }; + + } else { + // delete everything inside the folder + const body: IDataObject = { + Delete: { + '$': { + xmlns: 'http://s3.amazonaws.com/doc/2006-03-01/', + }, + Object: [], + }, + }; + + for (const childObject of responseData) { + //@ts-ignore + (body.Delete.Object as IDataObject[]).push({ + Key: childObject.Key as string + }); + } + + const builder = new Builder(); + const data = builder.buildObject(body); + + headers['Content-MD5'] = createHash('md5').update(data).digest('base64'); + + headers['Content-Type'] = 'application/xml'; + + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'POST', '/', data, { delete: '' } , headers, {}, region); + + responseData = { deleted: responseData.DeleteResult.Deleted }; + } + returnData.push(responseData); + } + //https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html + if (operation === 'getAll') { + const bucketName = this.getNodeParameter('bucketName', i) as string; + const returnAll = this.getNodeParameter('returnAll', 0) as boolean; + const options = this.getNodeParameter('options', 0) as IDataObject; + + if (options.folderKey) { + qs['prefix'] = options.folderKey as string; + } + + if (options.fetchOwner) { + qs['fetch-owner'] = options.fetchOwner as string; + } + + qs['list-type'] = 2; + + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); + + const region = responseData.LocationConstraint._; + + if (returnAll) { + responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', bucketName, 'GET', '', '', qs, {}, {}, region); + } else { + qs.limit = this.getNodeParameter('limit', 0) as number; + responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', bucketName, 'GET', '', '', qs, {}, {}, region); + } + if (Array.isArray(responseData)) { + responseData = responseData.filter((e: IDataObject) => (e.Key as string).endsWith('/') && e.Size === '0' && e.Key !== options.folderKey); + if (qs.limit) { + responseData = responseData.splice(0, qs.limit as number); + } + returnData.push.apply(returnData, responseData); + } + } + } + if (resource === 'file') { + //https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html + if (operation === 'copy') { + const sourcePath = this.getNodeParameter('sourcePath', i) as string; + const destinationPath = this.getNodeParameter('destinationPath', i) as string; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + headers['x-amz-copy-source'] = sourcePath; + + if (additionalFields.requesterPays) { + headers['x-amz-request-payer'] = 'requester'; + } + if (additionalFields.storageClass) { + headers['x-amz-storage-class'] = (snakeCase(additionalFields.storageClass as string)).toUpperCase(); + } + if (additionalFields.acl) { + headers['x-amz-acl'] = paramCase(additionalFields.acl as string); + } + if (additionalFields.grantFullControl) { + headers['x-amz-grant-full-control'] = ''; + } + if (additionalFields.grantRead) { + headers['x-amz-grant-read'] = ''; + } + if (additionalFields.grantReadAcp) { + headers['x-amz-grant-read-acp'] = ''; + } + if (additionalFields.grantWriteAcp) { + headers['x-amz-grant-write-acp'] = ''; + } + if (additionalFields.lockLegalHold) { + headers['x-amz-object-lock-legal-hold'] = (additionalFields.lockLegalHold as boolean) ? 'ON' : 'OFF'; + } + if (additionalFields.lockMode) { + headers['x-amz-object-lock-mode'] = (additionalFields.lockMode as string).toUpperCase(); + } + if (additionalFields.lockRetainUntilDate) { + headers['x-amz-object-lock-retain-until-date'] = additionalFields.lockRetainUntilDate as string; + } + if (additionalFields.serverSideEncryption) { + headers['x-amz-server-side-encryption'] = additionalFields.serverSideEncryption as string; + } + if (additionalFields.encryptionAwsKmsKeyId) { + headers['x-amz-server-side-encryption-aws-kms-key-id'] = additionalFields.encryptionAwsKmsKeyId as string; + } + if (additionalFields.serverSideEncryptionContext) { + headers['x-amz-server-side-encryption-context'] = additionalFields.serverSideEncryptionContext as string; + } + if (additionalFields.serversideEncryptionCustomerAlgorithm) { + headers['x-amz-server-side-encryption-customer-algorithm'] = additionalFields.serversideEncryptionCustomerAlgorithm as string; + } + if (additionalFields.serversideEncryptionCustomerKey) { + headers['x-amz-server-side-encryption-customer-key'] = additionalFields.serversideEncryptionCustomerKey as string; + } + if (additionalFields.serversideEncryptionCustomerKeyMD5) { + headers['x-amz-server-side-encryption-customer-key-MD5'] = additionalFields.serversideEncryptionCustomerKeyMD5 as string; + } + if (additionalFields.taggingDirective) { + headers['x-amz-tagging-directive'] = (additionalFields.taggingDirective as string).toUpperCase(); + } + if (additionalFields.metadataDirective) { + headers['x-amz-metadata-directive'] = (additionalFields.metadataDirective as string).toUpperCase(); + } + + const destinationParts = destinationPath.split('/'); + + const bucketName = destinationParts[1]; + + const destination = `/${destinationParts.slice(2, destinationParts.length).join('/')}`; + + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); + + const region = responseData.LocationConstraint._; + + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'PUT', destination, '', qs, headers, {}, region); + returnData.push(responseData.CopyObjectResult); + + } + //https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html + if (operation === 'download') { + + const bucketName = this.getNodeParameter('bucketName', i) as string; + + const fileKey = this.getNodeParameter('fileKey', i) as string; + + const fileName = fileKey.split('/')[fileKey.split('/').length - 1]; + + if (fileKey.substring(fileKey.length - 1) === '/') { + throw new Error('Downloding a whole directory is not yet supported, please provide a file key'); + } + + let region = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); + + region = region.LocationConstraint._; + + const response = await s3ApiRequestREST.call(this, bucketName, 'GET', `/${fileKey}`, '', qs, {}, { encoding: null, resolveWithFullResponse: true }, region); + + let mimeType: string | undefined; + if (response.headers['content-type']) { + mimeType = response.headers['content-type']; + } + + const newItem: INodeExecutionData = { + json: items[i].json, + binary: {}, + }; + + if (items[i].binary !== undefined) { + // Create a shallow copy of the binary data so that the old + // data references which do not get changed still stay behind + // but the incoming data does not get changed. + Object.assign(newItem.binary, items[i].binary); + } + + items[i] = newItem; + + const dataPropertyNameDownload = this.getNodeParameter('binaryPropertyName', i) as string; + + const data = Buffer.from(response.body as string, 'utf8'); + + items[i].binary![dataPropertyNameDownload] = await this.helpers.prepareBinaryData(data as unknown as Buffer, fileName, mimeType); + } + //https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html + if (operation === 'delete') { + const bucketName = this.getNodeParameter('bucketName', i) as string; + + const fileKey = this.getNodeParameter('fileKey', i) as string; + + const options = this.getNodeParameter('options', i) as IDataObject; + + if (options.versionId) { + qs.versionId = options.versionId as string; + } + + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); + + const region = responseData.LocationConstraint._; + + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'DELETE', `/${fileKey}`, '', qs, {}, {}, region); + + returnData.push({ success: true }); + } + //https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html + if (operation === 'getAll') { + const bucketName = this.getNodeParameter('bucketName', i) as string; + const returnAll = this.getNodeParameter('returnAll', 0) as boolean; + const options = this.getNodeParameter('options', 0) as IDataObject; + + if (options.folderKey) { + qs['prefix'] = options.folderKey as string; + } + + if (options.fetchOwner) { + qs['fetch-owner'] = options.fetchOwner as string; + } + + qs['delimiter'] = '/'; + + qs['list-type'] = 2; + + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); + + const region = responseData.LocationConstraint._; + + if (returnAll) { + responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', bucketName, 'GET', '', '', qs, {}, {}, region); + } else { + qs.limit = this.getNodeParameter('limit', 0) as number; + responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', bucketName, 'GET', '', '', qs, {}, {}, region); + responseData = responseData.splice(0, qs.limit); + } + if (Array.isArray(responseData)) { + responseData = responseData.filter((e: IDataObject) => !(e.Key as string).endsWith('/') && e.Size !== '0'); + if (qs.limit) { + responseData = responseData.splice(0, qs.limit as number); + } + returnData.push.apply(returnData, responseData); + } + } + //https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html + if (operation === 'upload') { + const bucketName = this.getNodeParameter('bucketName', i) as string; + const fileName = this.getNodeParameter('fileName', i) as string; + const isBinaryData = this.getNodeParameter('binaryData', i) as boolean; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + const tagsValues = (this.getNodeParameter('tagsUi', i) as IDataObject).tagsValues as IDataObject[]; + let path = '/'; + let body; + + if (additionalFields.requesterPays) { + headers['x-amz-request-payer'] = 'requester'; + } + + if (additionalFields.parentFolderKey) { + path = `/${additionalFields.parentFolderKey}/`; + } + if (additionalFields.storageClass) { + headers['x-amz-storage-class'] = (snakeCase(additionalFields.storageClass as string)).toUpperCase(); + } + if (additionalFields.acl) { + headers['x-amz-acl'] = paramCase(additionalFields.acl as string); + } + if (additionalFields.grantFullControl) { + headers['x-amz-grant-full-control'] = ''; + } + if (additionalFields.grantRead) { + headers['x-amz-grant-read'] = ''; + } + if (additionalFields.grantReadAcp) { + headers['x-amz-grant-read-acp'] = ''; + } + if (additionalFields.grantWriteAcp) { + headers['x-amz-grant-write-acp'] = ''; + } + if (additionalFields.lockLegalHold) { + headers['x-amz-object-lock-legal-hold'] = (additionalFields.lockLegalHold as boolean) ? 'ON' : 'OFF'; + } + if (additionalFields.lockMode) { + headers['x-amz-object-lock-mode'] = (additionalFields.lockMode as string).toUpperCase(); + } + if (additionalFields.lockRetainUntilDate) { + headers['x-amz-object-lock-retain-until-date'] = additionalFields.lockRetainUntilDate as string; + } + if (additionalFields.serverSideEncryption) { + headers['x-amz-server-side-encryption'] = additionalFields.serverSideEncryption as string; + } + if (additionalFields.encryptionAwsKmsKeyId) { + headers['x-amz-server-side-encryption-aws-kms-key-id'] = additionalFields.encryptionAwsKmsKeyId as string; + } + if (additionalFields.serverSideEncryptionContext) { + headers['x-amz-server-side-encryption-context'] = additionalFields.serverSideEncryptionContext as string; + } + if (additionalFields.serversideEncryptionCustomerAlgorithm) { + headers['x-amz-server-side-encryption-customer-algorithm'] = additionalFields.serversideEncryptionCustomerAlgorithm as string; + } + if (additionalFields.serversideEncryptionCustomerKey) { + headers['x-amz-server-side-encryption-customer-key'] = additionalFields.serversideEncryptionCustomerKey as string; + } + if (additionalFields.serversideEncryptionCustomerKeyMD5) { + headers['x-amz-server-side-encryption-customer-key-MD5'] = additionalFields.serversideEncryptionCustomerKeyMD5 as string; + } + if (tagsValues) { + const tags: string[] = []; + tagsValues.forEach((o: IDataObject) => { tags.push(`${o.key}=${o.value}`); }); + headers['x-amz-tagging'] = tags.join('&'); + } + + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); + + const region = responseData.LocationConstraint._; + + if (isBinaryData) { + const binaryPropertyName = this.getNodeParameter('binaryPropertyName', 0) as string; + + if (items[i].binary === undefined) { + throw new Error('No binary data exists on item!'); + } + + if ((items[i].binary as IBinaryKeyData)[binaryPropertyName] === undefined) { + throw new Error(`No binary data property "${binaryPropertyName}" does not exists on item!`); + } + + const binaryData = (items[i].binary as IBinaryKeyData)[binaryPropertyName]; + + body = Buffer.from(binaryData.data, BINARY_ENCODING) as Buffer; + + headers['Content-Type'] = binaryData.mimeType; + + headers['Content-MD5'] = createHash('md5').update(body).digest('base64'); + + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'PUT', `${path}${fileName || binaryData.fileName}`, body, qs, headers, {}, region); + + } else { + + const fileContent = this.getNodeParameter('fileContent', i) as string; + + body = Buffer.from(fileContent, 'utf8'); + + headers['Content-Type'] = 'text/html'; + + headers['Content-MD5'] = createHash('md5').update(fileContent).digest('base64'); + + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'PUT', `${path}${fileName}`, body, qs, headers, {}, region); + } + returnData.push({ success: true }); + } + } + } + if (resource === 'file' && operation === 'download') { + // For file downloads the files get attached to the existing items + return this.prepareOutputData(items); + } else { + return [this.helpers.returnJsonArray(returnData)]; + } + } +} diff --git a/packages/nodes-base/nodes/S3/generic-s3.png b/packages/nodes-base/nodes/S3/generic-s3.png new file mode 100644 index 0000000000000000000000000000000000000000..4f9faf6dd14612bf493cae796f551cab0d3d7e4e GIT binary patch literal 5600 zcmeHLYcy2t-yX&(jgVtW#yrU6Fc^u+m@tJz^PrI9U^2s)5yOnbU?`!S<&a}iNF_qb zX@pTBq#TA&l2a%tLi|T>>v`U_-t~Uy!~5Y~t9$Le@4fHebzl2;U%z{;eXSjfu{049 z+$#tG0EEm;(N>%uvwaHiajprXOLsY4Y_N?T#R^LW`}&d35r{Z2CCC>C#?c7p000_0 zAfZF3MFV8Kcn$IwM$Wun_gE6(O~Hgof)fvNSr&Z)EYSNoD8EWb`gng~$m1LLtI}+< z5@NYk$FCncV1Bj3?5_8Ls|5WJW?sRbF#PPB?)w7d#&z}A>X&Ab_J&liw0PSksMi?Z z4}X^)-sK3@H+X%CfZcGO z1QHUmSl0vr6FiU*+d~!_7QV(fJi#>B4`&^0Y2zO3?XKqmK^X|@(-0g29~=b>ruh&_ z{sSRApn5Led9!x_ErlqCEL8$o$ktkT28p;2!ZHoWmK;!(~ z{RqAk0+|Hf=ES;@11LxcgcAq)&1}5~2x#ZL0|TB_pRQuz$+3t^`}30D()f>Df8_ct1%3$ep6E%2Y+_5VvQ!GDb%j>K_uRE}*s(^R~dWBGXejm&HWI4xAbBZlJ+m(9?IHZ=Bp zjswwgARxMFs}n>nM1O{)JhrREnK!m(C>(m!!Po#jU)}X%cRE z8mATqZ+Ux6K}tiqGmHLhXfD6H-+6t^TUhmZ;Xj9CYT;1mYFzQRNMn{NL<>ypSuF`2 z0w6aHBW*In5}`)z_byGzMX1yqN=^~r(dmfTYaH*ZRw!u=yv_Ywbdsr6z~dNs8kBG% zZZBja>g3RVV7$CAgRg>Llx~MSD%_*ki`Dg9S1mDqSC4Ti5TBybu?yU_#CCHuD`k>l z7xhTC)_Pj9<#+*~GPj^&Asj0);hG4Jt~jEQz(xq}GumwGsVXk4$ZO1XILFU??qgx6 zno5y52miF^)7_Y}*9RRF>{(`=_C2EGQcqFM^>;OD2fv{&qX% zw3P;M+_ZrkPOtNv@0s7-Y-DOdG#Y?wMXD}GqGyacKNHvDq{###-n(LIxjUnt@5m66|M~B9B6y=2n+lX}h}GSDz5?KXRcRkFL!av0k`# zc0eie#b!lrA$?x|rAVcak~ylTcpgt~S7bc3Tg7oVWDcH2`4RRE)Ka0V)=W9bnDB_Q4K4ni zPNW`Rz1J$WD7|%I%{=eSqlQuq?Zn;a@K;E_;?55yc!e%1N?dp~XanuyO$T=Hn5#-f#W702 z=Muj#AUIQQ_i=f9<7ln|;Su+;OH8L4L^_&ZZ2pK`M+~TBv?ejlh^Iqn1(+z`Sfu?U zx+~K+$hQ7E$`&9RX8kf(&M#KHN2rP$#dTI>k>5Qk_Qj%sPVt2Eu?c4dTUQdmKykJH z?S9{zMNcm+hdwysy!9Dcp?NjlqT4LkFC~Wn(!E6Ax2!T@Y-GY*9G*1+Nr@W_Xd}l2-64@)m8czN?MR|q6fUZnOx{wyR2GZ+(76(#qPHNV0s-bzak!eX}FhC~X{|eZ|Q^61*oh`9;>F z=)rLNg*uH)=?YY8&7Ot6HG!U;XY&@D`65k|=mn>4-TJaZf%z(s0cQ=$Fb70~_l-XWl)3W(Rm z!x4)_;l#aio!=VolzreeVM-ZZ{Vu)Q>;Ge8H6CvNrVpjX!f16Kj-4dc{Kzg7Ep8fRMmLps29idHTiPh?h?UnsZdTUX}MDa4>7#jVZ|7(YYv&$U?x<5%i(ATVUj$2vj;#+p%ZdJ5L})ep(qSuyGp*tE z=atrT5qb>0Ybqc;vd>L{wfjmQCHb2p@wU8+OI#aY|B@NPqz*GmV01qTIR~fXUn`9o zH<@dWjx%?~4acqk2W865HJjPz+g@^SPA|OPvr=7O6agE1pfsl`ehj!d`$Z<^iudR0 zF_-q+lS7ip-@jI=!$ISkS?0$b+*;jO%*Gt-{BS+X%c z^I7em1Fk9(8>sK1in38>x{z}!S~Igun=P8#EU(g<^5u|^8vt@n=LQd2pq}=L!kXXO zX#Q$*)iPA~wua=}YzLi_jqiG24Sg*I;&)xKopDL2=-F`k(tE|}`_+ftU#y0|Uty0v z_Jf`xUJH%hT8@_>KDzPYV9Ph@TLJxC+F?7_-(4!-TKciS@vG5r5qQ6TZ%ab>kyiXc zc=UG;H)db9{#N2~?#61|U%j!NJ&xL8A^lt@(}V*UqjfK--CIlAr}RT21`-0`ls!pU z0VgSw)diLIpp7A5&YaA%Y5ROT2Uhy9w?OXZdhs*ohj$!VQm98>kS#jwZ_QVSCI!-k z)o)Z*K-CGu7n)LBjV+*u2hy#qL?(%g8b0$43VbQ{NKcC(&9aUM=E=K(H|mEqeJ_f4 zhCH4Sino4^goU?x48k%+m_0LHH*QSb3OrZW;2HBR@4Eh#0iBQCmllGcU&Mp^zG)1^ zWr{Xu`|~j|IcP+vg=@w=CgP*27ob>{nfK-^kmXWMr;%j#^X&qR$jK;;I-HL&)|9jbb+ZVFDB}w~LusaPovA zEq8n+B7g5SMh&`Ne(`)T`yuuArd# Date: Tue, 18 Aug 2020 17:11:20 +0400 Subject: [PATCH 05/46] Revert AWS S3 node to its original state --- .../nodes-base/nodes/Aws/S3/AwsS3.node.ts | 72 +++++++++---------- .../nodes/Aws/S3/GenericFunctions.ts | 45 ++++-------- 2 files changed, 44 insertions(+), 73 deletions(-) diff --git a/packages/nodes-base/nodes/Aws/S3/AwsS3.node.ts b/packages/nodes-base/nodes/Aws/S3/AwsS3.node.ts index 34122f1225..5af9ad3d33 100644 --- a/packages/nodes-base/nodes/Aws/S3/AwsS3.node.ts +++ b/packages/nodes-base/nodes/Aws/S3/AwsS3.node.ts @@ -41,9 +41,9 @@ import { } from './FileDescription'; import { - s3ApiRequestREST, - s3ApiRequestSOAP, - s3ApiRequestSOAPAllItems, + awsApiRequestREST, + awsApiRequestSOAP, + awsApiRequestSOAPAllItems, } from './GenericFunctions'; export class AwsS3 implements INodeType { @@ -65,7 +65,7 @@ export class AwsS3 implements INodeType { { name: 'aws', required: true, - }, + } ], properties: [ { @@ -113,15 +113,7 @@ export class AwsS3 implements INodeType { if (resource === 'bucket') { //https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateBucket.html if (operation === 'create') { - - let credentials; - - try { - credentials = this.getCredentials('aws'); - } catch (error) { - throw new Error(error); - } - + const credentials = this.getCredentials('aws'); const name = this.getNodeParameter('name', i) as string; const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; if (additionalFields.acl) { @@ -166,7 +158,7 @@ export class AwsS3 implements INodeType { const builder = new Builder(); data = builder.buildObject(body); } - responseData = await s3ApiRequestSOAP.call(this, `${name}`, 'PUT', '', data, qs, headers); + responseData = await awsApiRequestSOAP.call(this, `${name}.s3`, 'PUT', '', data, qs, headers); returnData.push({ success: true }); } @@ -174,10 +166,10 @@ export class AwsS3 implements INodeType { if (operation === 'getAll') { const returnAll = this.getNodeParameter('returnAll', 0) as boolean; if (returnAll) { - responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListAllMyBucketsResult.Buckets.Bucket', '', 'GET', ''); + responseData = await awsApiRequestSOAPAllItems.call(this, 'ListAllMyBucketsResult.Buckets.Bucket', 's3', 'GET', ''); } else { qs.limit = this.getNodeParameter('limit', 0) as number; - responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListAllMyBucketsResult.Buckets.Bucket', '', 'GET', '', '', qs); + responseData = await awsApiRequestSOAPAllItems.call(this, 'ListAllMyBucketsResult.Buckets.Bucket', 's3', 'GET', '', '', qs); responseData = responseData.slice(0, qs.limit); } returnData.push.apply(returnData, responseData); @@ -216,15 +208,15 @@ export class AwsS3 implements INodeType { qs['list-type'] = 2; - responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); + responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', { location: '' }); const region = responseData.LocationConstraint._ as string; if (returnAll) { - responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', bucketName, 'GET', '', '', qs, {}, {}, region); + responseData = await awsApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', `${bucketName}.s3`, 'GET', '', '', qs, {}, {}, region); } else { qs['max-keys'] = this.getNodeParameter('limit', 0) as number; - responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', qs, {}, {}, region); + responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', qs, {}, {}, region); responseData = responseData.ListBucketResult.Contents; } if (Array.isArray(responseData)) { @@ -251,11 +243,11 @@ export class AwsS3 implements INodeType { if (additionalFields.storageClass) { headers['x-amz-storage-class'] = (snakeCase(additionalFields.storageClass as string)).toUpperCase(); } - responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); + responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', { location: '' }); const region = responseData.LocationConstraint._; - responseData = await s3ApiRequestSOAP.call(this, bucketName, 'PUT', path, '', qs, headers, {}, region); + responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'PUT', path, '', qs, headers, {}, region); returnData.push({ success: true }); } //https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html @@ -263,16 +255,16 @@ export class AwsS3 implements INodeType { const bucketName = this.getNodeParameter('bucketName', i) as string; const folderKey = this.getNodeParameter('folderKey', i) as string; - responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); + responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', { location: '' }); const region = responseData.LocationConstraint._; - responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', bucketName, 'GET', '/', '', { 'list-type': 2, prefix: folderKey }, {}, {}, region); + responseData = await awsApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', `${bucketName}.s3`, 'GET', '/', '', { 'list-type': 2, prefix: folderKey }, {}, {}, region); // folder empty then just delete it if (responseData.length === 0) { - responseData = await s3ApiRequestSOAP.call(this, bucketName, 'DELETE', `/${folderKey}`, '', qs, {}, {}, region); + responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'DELETE', `/${folderKey}`, '', qs, {}, {}, region); responseData = { deleted: [ { 'Key': folderKey } ] }; @@ -301,7 +293,7 @@ export class AwsS3 implements INodeType { headers['Content-Type'] = 'application/xml'; - responseData = await s3ApiRequestSOAP.call(this, bucketName, 'POST', '/', data, { delete: '' } , headers, {}, region); + responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'POST', '/', data, { delete: '' } , headers, {}, region); responseData = { deleted: responseData.DeleteResult.Deleted }; } @@ -323,15 +315,15 @@ export class AwsS3 implements INodeType { qs['list-type'] = 2; - responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); + responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', { location: '' }); const region = responseData.LocationConstraint._; if (returnAll) { - responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', bucketName, 'GET', '', '', qs, {}, {}, region); + responseData = await awsApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', `${bucketName}.s3`, 'GET', '', '', qs, {}, {}, region); } else { qs.limit = this.getNodeParameter('limit', 0) as number; - responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', bucketName, 'GET', '', '', qs, {}, {}, region); + responseData = await awsApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', `${bucketName}.s3`, 'GET', '', '', qs, {}, {}, region); } if (Array.isArray(responseData)) { responseData = responseData.filter((e: IDataObject) => (e.Key as string).endsWith('/') && e.Size === '0' && e.Key !== options.folderKey); @@ -412,11 +404,11 @@ export class AwsS3 implements INodeType { const destination = `/${destinationParts.slice(2, destinationParts.length).join('/')}`; - responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); + responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', { location: '' }); const region = responseData.LocationConstraint._; - responseData = await s3ApiRequestSOAP.call(this, bucketName, 'PUT', destination, '', qs, headers, {}, region); + responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'PUT', destination, '', qs, headers, {}, region); returnData.push(responseData.CopyObjectResult); } @@ -433,11 +425,11 @@ export class AwsS3 implements INodeType { throw new Error('Downloding a whole directory is not yet supported, please provide a file key'); } - let region = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); + let region = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', { location: '' }); region = region.LocationConstraint._; - const response = await s3ApiRequestREST.call(this, bucketName, 'GET', `/${fileKey}`, '', qs, {}, { encoding: null, resolveWithFullResponse: true }, region); + const response = await awsApiRequestREST.call(this, `${bucketName}.s3`, 'GET', `/${fileKey}`, '', qs, {}, { encoding: null, resolveWithFullResponse: true }, region); let mimeType: string | undefined; if (response.headers['content-type']) { @@ -476,11 +468,11 @@ export class AwsS3 implements INodeType { qs.versionId = options.versionId as string; } - responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); + responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', { location: '' }); const region = responseData.LocationConstraint._; - responseData = await s3ApiRequestSOAP.call(this, bucketName, 'DELETE', `/${fileKey}`, '', qs, {}, {}, region); + responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'DELETE', `/${fileKey}`, '', qs, {}, {}, region); returnData.push({ success: true }); } @@ -502,15 +494,15 @@ export class AwsS3 implements INodeType { qs['list-type'] = 2; - responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); + responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', { location: '' }); const region = responseData.LocationConstraint._; if (returnAll) { - responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', bucketName, 'GET', '', '', qs, {}, {}, region); + responseData = await awsApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', `${bucketName}.s3`, 'GET', '', '', qs, {}, {}, region); } else { qs.limit = this.getNodeParameter('limit', 0) as number; - responseData = await s3ApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', bucketName, 'GET', '', '', qs, {}, {}, region); + responseData = await awsApiRequestSOAPAllItems.call(this, 'ListBucketResult.Contents', `${bucketName}.s3`, 'GET', '', '', qs, {}, {}, region); responseData = responseData.splice(0, qs.limit); } if (Array.isArray(responseData)) { @@ -589,7 +581,7 @@ export class AwsS3 implements INodeType { headers['x-amz-tagging'] = tags.join('&'); } - responseData = await s3ApiRequestSOAP.call(this, bucketName, 'GET', '', '', { location: '' }); + responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'GET', '', '', { location: '' }); const region = responseData.LocationConstraint._; @@ -612,7 +604,7 @@ export class AwsS3 implements INodeType { headers['Content-MD5'] = createHash('md5').update(body).digest('base64'); - responseData = await s3ApiRequestSOAP.call(this, bucketName, 'PUT', `${path}${fileName || binaryData.fileName}`, body, qs, headers, {}, region); + responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'PUT', `${path}${fileName || binaryData.fileName}`, body, qs, headers, {}, region); } else { @@ -624,7 +616,7 @@ export class AwsS3 implements INodeType { headers['Content-MD5'] = createHash('md5').update(fileContent).digest('base64'); - responseData = await s3ApiRequestSOAP.call(this, bucketName, 'PUT', `${path}${fileName}`, body, qs, headers, {}, region); + responseData = await awsApiRequestSOAP.call(this, `${bucketName}.s3`, 'PUT', `${path}${fileName}`, body, qs, headers, {}, region); } returnData.push({ success: true }); } diff --git a/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts b/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts index bf0839a461..92d620d102 100644 --- a/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts @@ -25,37 +25,16 @@ import { IDataObject, } from 'n8n-workflow'; -import { URL } from 'url'; - -export async function s3ApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, bucket: string, method: string, path: string, body?: string | Buffer, query: IDataObject = {}, headers?: object, option: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any - - let credentials; - - try { - credentials = this.getCredentials('aws'); - } catch (error) { - throw new Error(error); - } - +export async function awsApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, service: string, method: string, path: string, body?: string | Buffer, query: IDataObject = {}, headers?: object, option: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('aws'); if (credentials === undefined) { throw new Error('No credentials got returned!'); } - - const endpoint = new URL(`https://${bucket}.s3.${region || credentials.region}.amazonaws.com`); - - endpoint.pathname = path; + const endpoint = `${service}.${region || credentials.region}.amazonaws.com`; // Sign AWS API request with the user credentials - const signOpts = { - headers: headers || {}, - region: region || credentials.region, - host: endpoint.host, - method, - path: `${path}?${queryToString(query).replace(/\+/g, '%2B')}`, - service: 's3', - body - }; + const signOpts = {headers: headers || {}, host: endpoint, method, path: `${path}?${queryToString(query).replace(/\+/g, '%2B')}`, body}; sign(signOpts, { accessKeyId: `${credentials.accessKeyId}`, secretAccessKey: `${credentials.secretAccessKey}`}); @@ -63,7 +42,7 @@ export async function s3ApiRequest(this: IHookFunctions | IExecuteFunctions | IL headers: signOpts.headers, method, qs: query, - uri: endpoint, + uri: `https://${endpoint}${signOpts.path}`, body: signOpts.body, }; @@ -73,7 +52,7 @@ export async function s3ApiRequest(this: IHookFunctions | IExecuteFunctions | IL try { return await this.helpers.request!(options); } catch (error) { - const errorMessage = error.response?.body.message || error.response?.body.Message || error.message; + const errorMessage = error.response.body.message || error.response.body.Message || error.message; if (error.statusCode === 403) { if (errorMessage === 'The security token included in the request is invalid.') { @@ -87,8 +66,8 @@ export async function s3ApiRequest(this: IHookFunctions | IExecuteFunctions | IL } } -export async function s3ApiRequestREST(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, bucket: string, method: string, path: string, body?: string, query: IDataObject = {}, headers?: object, options: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any - const response = await s3ApiRequest.call(this, bucket, method, path, body, query, headers, options, region); +export async function awsApiRequestREST(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, service: string, method: string, path: string, body?: string, query: IDataObject = {}, headers?: object, options: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any + const response = await awsApiRequest.call(this, service, method, path, body, query, headers, options, region); try { return JSON.parse(response); } catch (e) { @@ -96,8 +75,8 @@ export async function s3ApiRequestREST(this: IHookFunctions | IExecuteFunctions } } -export async function s3ApiRequestSOAP(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, bucket: string, method: string, path: string, body?: string | Buffer, query: IDataObject = {}, headers?: object, option: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any - const response = await s3ApiRequest.call(this, bucket, method, path, body, query, headers, option, region); +export async function awsApiRequestSOAP(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, service: string, method: string, path: string, body?: string | Buffer, query: IDataObject = {}, headers?: object, option: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any + const response = await awsApiRequest.call(this, service, method, path, body, query, headers, option, region); try { return await new Promise((resolve, reject) => { parseString(response, { explicitArray: false }, (err, data) => { @@ -112,14 +91,14 @@ export async function s3ApiRequestSOAP(this: IHookFunctions | IExecuteFunctions } } -export async function s3ApiRequestSOAPAllItems(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, propertyName: string, service: string, method: string, path: string, body?: string, query: IDataObject = {}, headers: IDataObject = {}, option: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any +export async function awsApiRequestSOAPAllItems(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, propertyName: string, service: string, method: string, path: string, body?: string, query: IDataObject = {}, headers: IDataObject = {}, option: IDataObject = {}, region?: string): Promise { // tslint:disable-line:no-any const returnData: IDataObject[] = []; let responseData; do { - responseData = await s3ApiRequestSOAP.call(this, service, method, path, body, query, headers, option, region); + responseData = await awsApiRequestSOAP.call(this, service, method, path, body, query, headers, option, region); //https://forums.aws.amazon.com/thread.jspa?threadID=55746 if (get(responseData, `${propertyName.split('.')[0]}.NextContinuationToken`)) { From b77fd593039dcd778a5f25cef2f035a55bc28bec Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Tue, 1 Sep 2020 11:40:18 -0400 Subject: [PATCH 06/46] :sparkles: Add MQTT Trigger-Node (#885) * Improvements to MQTT-node * :zap: Small improvements done * :zap: Improvements Co-authored-by: LEE SANG JUN --- .../credentials/Mqtt.credentials.ts | 59 +++++++ .../nodes-base/nodes/MQTT/MqttTrigger.node.ts | 158 ++++++++++++++++++ packages/nodes-base/nodes/MQTT/mqtt.png | Bin 0 -> 2310 bytes packages/nodes-base/package.json | 4 + 4 files changed, 221 insertions(+) create mode 100644 packages/nodes-base/credentials/Mqtt.credentials.ts create mode 100644 packages/nodes-base/nodes/MQTT/MqttTrigger.node.ts create mode 100644 packages/nodes-base/nodes/MQTT/mqtt.png diff --git a/packages/nodes-base/credentials/Mqtt.credentials.ts b/packages/nodes-base/credentials/Mqtt.credentials.ts new file mode 100644 index 0000000000..bcc419a524 --- /dev/null +++ b/packages/nodes-base/credentials/Mqtt.credentials.ts @@ -0,0 +1,59 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class Mqtt implements ICredentialType { + name = 'mqtt'; + displayName = 'MQTT'; + properties = [ + // The credentials to get from user and save encrypted. + // Properties can be defined exactly in the same way + // as node properties. + { + displayName: 'Protocol', + name: 'protocol', + type: 'options' as NodePropertyTypes, + options: [ + { + name: 'mqtt', + value: 'mqtt', + }, + { + name: 'ws', + value: 'ws', + }, + ], + default: 'mqtt', + }, + { + displayName: 'Host', + name: 'host', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Port', + name: 'port', + type: 'number' as NodePropertyTypes, + default: 1883, + }, + { + displayName: 'Username', + name: 'username', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Password', + name: 'password', + type: 'string' as NodePropertyTypes, + typeOptions: { + password: true, + }, + default: '', + }, + ]; +} + diff --git a/packages/nodes-base/nodes/MQTT/MqttTrigger.node.ts b/packages/nodes-base/nodes/MQTT/MqttTrigger.node.ts new file mode 100644 index 0000000000..0344f290b3 --- /dev/null +++ b/packages/nodes-base/nodes/MQTT/MqttTrigger.node.ts @@ -0,0 +1,158 @@ +import { + ITriggerFunctions, +} from 'n8n-core'; + +import { + INodeType, + INodeTypeDescription, + ITriggerResponse, + IDataObject, +} from 'n8n-workflow'; + +import * as mqtt from 'mqtt'; + +import { + IClientOptions, +} from 'mqtt'; + +export class MqttTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'MQTT Trigger', + name: 'mqttTrigger', + icon: 'file:mqtt.png', + group: ['trigger'], + version: 1, + description: 'Listens to MQTT events', + defaults: { + name: 'MQTT Trigger', + color: '#9b27af', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'mqtt', + required: true, + }, + ], + properties: [ + { + displayName: 'Topics', + name: 'topics', + type: 'string', + default: '', + description: `Topics to subscribe to, multiple can be defined with comma.
+ wildcard characters are supported (+ - for single level and # - for multi level)`, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + options: [ + { + displayName: 'Only Message', + name: 'onlyMessage', + type: 'boolean', + default: false, + description: 'Returns only the message property.', + }, + { + displayName: 'JSON Parse Message', + name: 'jsonParseMessage', + type: 'boolean', + default: false, + description: 'Try to parse the message to an object.', + }, + ], + }, + ], + }; + + async trigger(this: ITriggerFunctions): Promise { + + const credentials = this.getCredentials('mqtt'); + + if (!credentials) { + throw new Error('Credentials are mandatory!'); + } + + const topics = (this.getNodeParameter('topics') as string).split(','); + + const options = this.getNodeParameter('options') as IDataObject; + + if (!topics) { + throw new Error('Topics are mandatory!'); + } + + const protocol = credentials.protocol as string || 'mqtt'; + const host = credentials.host as string; + const brokerUrl = `${protocol}://${host}`; + const port = credentials.port as number || 1883; + + const clientOptions: IClientOptions = { + port, + }; + + if (credentials.username && credentials.password) { + clientOptions.username = credentials.username as string; + clientOptions.password = credentials.password as string; + } + + const client = mqtt.connect(brokerUrl, clientOptions); + + const self = this; + + async function manualTriggerFunction() { + await new Promise(( resolve, reject ) => { + client.on('connect', () => { + client.subscribe(topics, (err, granted) => { + if (err) { + reject(err); + } + client.on('message', (topic: string, message: Buffer | string) => { // tslint:disable-line:no-any + + let result: IDataObject = {}; + + message = message.toString() as string; + + if (options.jsonParseMessage) { + try { + message = JSON.parse(message.toString()); + } catch (err) {} + } + + result.message = message; + result.topic = topic; + + if (options.onlyMessage) { + //@ts-ignore + result = message; + } + + self.emit([self.helpers.returnJsonArray([result], + )]); + resolve(true); + }); + }); + }); + + client.on('error', (error) => { + reject(error); + }); + }); + } + + manualTriggerFunction(); + + async function closeFunction() { + client.end(); + } + + return { + closeFunction, + manualTriggerFunction, + }; + } +} diff --git a/packages/nodes-base/nodes/MQTT/mqtt.png b/packages/nodes-base/nodes/MQTT/mqtt.png new file mode 100644 index 0000000000000000000000000000000000000000..12c5f24952b3cc910c95bbd7809f443be26385cd GIT binary patch literal 2310 zcmV+h3HkPkP)q_E1s6v?#7r7 z%bcyxM!zw0Kvh39%mcJA^YbP{8>J$jqFA1Ii=c~CW)-B$ut%UV5y!+MOV72}c9F`b zk4CJh3L~=h%fN|ak4B3aDp*$BcHQ{nKqGZHm32CWz$nM^p9}iv(&F~o5%dUp1U-Tt zL64wE&<<#x6$D0r8Gh^_-YG9#X(WazOI#XsBv!Xg{=2uT^Vt5@$Nu$~)TD-16RXRYD77@KUb3U&+?LDFEf3H5Z~>3_&whKU-(kWrkO4~p`Qesfk^ zz482}a#BMfR%oth#R-byDeY7`>y%6A2lu*lV?9d$b+!1N%W8#YYQ~hsJEJ-pP2NGC zfIj!UfaxbS{$JE^9vMkadW%ojG#ffe(! z&Wsx6#WQHke6JOGNu7;d`{D{oKufo)(jEm1nw%K4EH}RSYU@YIdI@d6vx0Efg{#&S zrIQ#rWOV(ho2_3PV>Zq@m{Qn&qiaL`FK+$!?%nd1xVPq9e6OPH-_;wwTCLP5a$^dN zcTE&cTD!R!uY@iB%ltd#EqQ5rg(pq1bl`lyw7hsnH0%zd4q7@iF=*)^G}K}&rU_T9 zEy{2O-FT__(~TEjE6X(GT)SP?vV33S(Y0sC&-0u6>j*%XzF)QB>(wfqa$o$h&Rboi zf@B`Bv)4^a*cSEe#ioy!f2Q=Jp~ION6Zm4@qRvL+zPJ;B#^eu%4Hx=eP5YkM{MQYc zv zb!XlzS9vLTM(~dwv+{6qXQOFf+;Kp|)E6F85(5XI<&yv%yvJ0<8of*Cwua7}*@xpc z&5nQjd04Fe^v%3<{mP@s;mblwc2u5w`|^S_5wQ)yXB4g33!f z-CWs9t7$+7MEkBd3^8rmA6H29503$8Y22}$1RGkpMnA-u4o>u6vS)tBt*#H|ee%o% z&tH|TfW`8&&(sxsvm!q!G-*QFKdXx0F{pLQmB*9)rjKDQtg*4%XNngLXkX@BKflGG z^pyVe0rdEov6*>`O)cGfZM{do@JI?u+4I8vn)X8pfF|L9WR76PY%M($G>pl9&yS9O zZ`S>q4mkBZkJ5K7&;@uL$vVTBm_^&>hNXrWc2!sGt)mnqsidMeP7h6+pwQ7OodO!G z9 zk8J%i(mgk}k{j*^c^t!??(gx%m_4T5hqhgZJhF2A-EI_1BLs_U!YOy69o?91XmG1o zM#C^pyBRIJIVnyAw~Mjfm>veSEZ`6?NY|w_xl4@MyS!|Ds{7G)H-~Ux@OA03xUb9m zLFj_i;Y4P~ z!Zay0eB^UPDYHZ+JF2SQy`g-DcHuE|pIiRfcWGi$p7LG2Tkqn%4QE>sQZ%D=^6!0@ zZb|^-qo3G9K8~TLJ5BmmFR|5_LZyJj(j~VbU_Ab?_2467{hz`2mOF55U6HL2))Wv6 zV{QbSws%2bU|?UW?F5Ywg1QIh2e-eyU45wDRKbuOg$@0wKCHpRjS3QN7<_Q8(_eVs z7qlHEdPXXzXN3k&2=dVA287nYw-BVpMySiwW$Nj&Sj{kxx%4i=i{eRd~8@3dsPcox#uO g9i|`o^piyYA3KLr@}Cs({r~^~07*qoM6N<$f^m3?c>n+a literal 0 HcmV?d00001 diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index baa3ed6483..f8b3b6db92 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -115,6 +115,7 @@ "dist/credentials/MoceanApi.credentials.js", "dist/credentials/MondayComApi.credentials.js", "dist/credentials/MongoDb.credentials.js", + "dist/credentials/Mqtt.credentials.js", "dist/credentials/Msg91Api.credentials.js", "dist/credentials/MySql.credentials.js", "dist/credentials/NextCloudApi.credentials.js", @@ -288,6 +289,7 @@ "dist/nodes/Mocean/Mocean.node.js", "dist/nodes/MondayCom/MondayCom.node.js", "dist/nodes/MongoDb/MongoDb.node.js", + "dist/nodes/MQTT/MqttTrigger.node.js", "dist/nodes/MoveBinaryData.node.js", "dist/nodes/Msg91/Msg91.node.js", "dist/nodes/MySql/MySql.node.js", @@ -373,6 +375,7 @@ "@types/mailparser": "^2.7.3", "@types/moment-timezone": "^0.5.12", "@types/mongodb": "^3.5.4", + "@types/mqtt": "^2.5.0", "@types/mssql": "^6.0.2", "@types/node": "^14.0.27", "@types/nodemailer": "^6.4.0", @@ -409,6 +412,7 @@ "moment": "2.24.0", "moment-timezone": "^0.5.28", "mongodb": "^3.5.5", + "mqtt": "^4.2.0", "mssql": "^6.2.0", "mysql2": "^2.0.1", "n8n-core": "~0.43.0", From 23f8b2b4df76f6ca6e693036e8b1c08501d779af Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 1 Sep 2020 17:46:32 +0200 Subject: [PATCH 07/46] :zap: Minor improvements to MQTT Trigger-Node --- packages/nodes-base/nodes/MQTT/MqttTrigger.node.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/nodes-base/nodes/MQTT/MqttTrigger.node.ts b/packages/nodes-base/nodes/MQTT/MqttTrigger.node.ts index 0344f290b3..3a40941662 100644 --- a/packages/nodes-base/nodes/MQTT/MqttTrigger.node.ts +++ b/packages/nodes-base/nodes/MQTT/MqttTrigger.node.ts @@ -3,10 +3,10 @@ import { } from 'n8n-core'; import { + IDataObject, INodeType, INodeTypeDescription, ITriggerResponse, - IDataObject, } from 'n8n-workflow'; import * as mqtt from 'mqtt'; @@ -75,7 +75,7 @@ export class MqttTrigger implements INodeType { const credentials = this.getCredentials('mqtt'); if (!credentials) { - throw new Error('Credentials are mandatory!'); + throw new Error('Credentials are mandatory!'); } const topics = (this.getNodeParameter('topics') as string).split(','); @@ -105,7 +105,7 @@ export class MqttTrigger implements INodeType { const self = this; async function manualTriggerFunction() { - await new Promise(( resolve, reject ) => { + await new Promise((resolve, reject) => { client.on('connect', () => { client.subscribe(topics, (err, granted) => { if (err) { @@ -120,7 +120,7 @@ export class MqttTrigger implements INodeType { if (options.jsonParseMessage) { try { message = JSON.parse(message.toString()); - } catch (err) {} + } catch (err) { } } result.message = message; @@ -131,8 +131,7 @@ export class MqttTrigger implements INodeType { result = message; } - self.emit([self.helpers.returnJsonArray([result], - )]); + self.emit([self.helpers.returnJsonArray([result])]); resolve(true); }); }); From a9bfb506ff792645b1d64563ee1e8956210186b2 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 1 Sep 2020 17:50:51 +0200 Subject: [PATCH 08/46] :zap: Update buffer code to not get deprecation message --- packages/cli/src/Server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index aa3a30a6f4..43886323a7 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -298,7 +298,7 @@ class App { this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => { (req as ICustomRequest).parsedUrl = parseUrl(req); // @ts-ignore - req.rawBody = new Buffer('', 'base64'); + req.rawBody = Buffer.from('', 'base64'); next(); }); From 76ab07d60f114f3dd6158b0c9c5c38e69dd436e7 Mon Sep 17 00:00:00 2001 From: Denis Palashevskii Date: Tue, 1 Sep 2020 19:51:45 +0400 Subject: [PATCH 09/46] Remove code duplication --- .../nodes-base/nodes/S3/BucketDescription.ts | 327 ------- .../nodes-base/nodes/S3/FileDescription.ts | 922 ------------------ .../nodes-base/nodes/S3/FolderDescription.ts | 278 ------ packages/nodes-base/nodes/S3/S3.node.ts | 6 +- 4 files changed, 3 insertions(+), 1530 deletions(-) delete mode 100644 packages/nodes-base/nodes/S3/BucketDescription.ts delete mode 100644 packages/nodes-base/nodes/S3/FileDescription.ts delete mode 100644 packages/nodes-base/nodes/S3/FolderDescription.ts diff --git a/packages/nodes-base/nodes/S3/BucketDescription.ts b/packages/nodes-base/nodes/S3/BucketDescription.ts deleted file mode 100644 index a6c8d67cd1..0000000000 --- a/packages/nodes-base/nodes/S3/BucketDescription.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { - INodeProperties, -} from 'n8n-workflow'; - -export const bucketOperations = [ - { - displayName: 'Operation', - name: 'operation', - type: 'options', - displayOptions: { - show: { - resource: [ - 'bucket', - ], - }, - }, - options: [ - { - name: 'Create', - value: 'create', - description: 'Create a bucket', - }, - { - name: 'Get All', - value: 'getAll', - description: 'Get all buckets', - }, - { - name: 'Search', - value: 'search', - description: 'Search within a bucket', - }, - ], - default: 'create', - description: 'The operation to perform.', - }, -] as INodeProperties[]; - -export const bucketFields = [ - -/* -------------------------------------------------------------------------- */ -/* bucket:create */ -/* -------------------------------------------------------------------------- */ - { - displayName: 'Name', - name: 'name', - type: 'string', - required: true, - default: '', - displayOptions: { - show: { - resource: [ - 'bucket', - ], - operation: [ - 'create', - ], - }, - }, - description: 'A succinct description of the nature, symptoms, cause, or effect of the bucket.', - }, - { - displayName: 'Additional Fields', - name: 'additionalFields', - type: 'collection', - placeholder: 'Add Field', - displayOptions: { - show: { - resource: [ - 'bucket', - ], - operation: [ - 'create', - ], - }, - }, - default: {}, - options: [ - { - displayName: 'ACL', - name: 'acl', - type: 'options', - options: [ - { - name: 'Authenticated Read', - value: 'authenticatedRead', - }, - { - name: 'Private', - value: 'Private', - }, - { - name: 'Public Read', - value: 'publicRead', - }, - { - name: 'Public Read Write', - value: 'publicReadWrite', - }, - ], - default: '', - description: 'The canned ACL to apply to the bucket.', - }, - { - displayName: 'Bucket Object Lock Enabled', - name: 'bucketObjectLockEnabled', - type: 'boolean', - default: false, - description: 'Specifies whether you want S3 Object Lock to be enabled for the new bucket.', - }, - { - displayName: 'Grant Full Control', - name: 'grantFullControl', - type: 'boolean', - default: false, - description: 'Allows grantee the read, write, read ACP, and write ACP permissions on the bucket.', - }, - { - displayName: 'Grant Read', - name: 'grantRead', - type: 'boolean', - default: false, - description: 'Allows grantee to list the objects in the bucket.', - }, - { - displayName: 'Grant Read ACP', - name: 'grantReadAcp', - type: 'boolean', - default: false, - description: 'Allows grantee to read the bucket ACL.', - }, - { - displayName: 'Grant Write', - name: 'grantWrite', - type: 'boolean', - default: false, - description: 'Allows grantee to create, overwrite, and delete any object in the bucket.', - }, - { - displayName: 'Grant Write ACP', - name: 'grantWriteAcp', - type: 'boolean', - default: false, - description: 'Allows grantee to write the ACL for the applicable bucket.', - }, - { - displayName: 'Region', - name: 'region', - type: 'string', - default: '', - description: 'Region you want to create the bucket in, by default the buckets are created on the region defined on the credentials.', - }, - ], - }, -/* -------------------------------------------------------------------------- */ -/* bucket:getAll */ -/* -------------------------------------------------------------------------- */ - { - displayName: 'Return All', - name: 'returnAll', - type: 'boolean', - displayOptions: { - show: { - operation: [ - 'getAll', - ], - resource: [ - 'bucket', - ], - }, - }, - default: false, - description: 'If all results should be returned or only up to a given limit.', - }, - { - displayName: 'Limit', - name: 'limit', - type: 'number', - displayOptions: { - show: { - operation: [ - 'getAll', - ], - resource: [ - 'bucket', - ], - returnAll: [ - false, - ], - }, - }, - typeOptions: { - minValue: 1, - maxValue: 500, - }, - default: 100, - description: 'How many results to return.', - }, -/* -------------------------------------------------------------------------- */ -/* bucket:search */ -/* -------------------------------------------------------------------------- */ - { - displayName: 'Bucket Name', - name: 'bucketName', - type: 'string', - required: true, - default: '', - displayOptions: { - show: { - resource: [ - 'bucket', - ], - operation: [ - 'search', - ], - }, - }, - }, - { - displayName: 'Return All', - name: 'returnAll', - type: 'boolean', - displayOptions: { - show: { - operation: [ - 'search', - ], - resource: [ - 'bucket', - ], - }, - }, - default: false, - description: 'If all results should be returned or only up to a given limit.', - }, - { - displayName: 'Limit', - name: 'limit', - type: 'number', - displayOptions: { - show: { - operation: [ - 'search', - ], - resource: [ - 'bucket', - ], - returnAll: [ - false, - ], - }, - }, - typeOptions: { - minValue: 1, - maxValue: 500, - }, - default: 100, - description: 'How many results to return.', - }, - { - displayName: 'Additional Fields', - name: 'additionalFields', - type: 'collection', - placeholder: 'Add Field', - displayOptions: { - show: { - resource: [ - 'bucket', - ], - operation: [ - 'search', - ], - }, - }, - default: {}, - options: [ - { - displayName: 'Delimiter', - name: 'delimiter', - type: 'string', - default: '', - description: 'A delimiter is a character you use to group keys.', - }, - { - displayName: 'Encoding Type', - name: 'encodingType', - type: 'options', - options: [ - { - name: 'URL', - value: 'url', - }, - ], - default: '', - description: 'Encoding type used by Amazon S3 to encode object keys in the response.', - }, - { - displayName: 'Fetch Owner', - name: 'fetchOwner', - type: 'boolean', - default: false, - description: 'The owner field is not present in listV2 by default, if you want to return owner field with each key in the result then set the fetch owner field to true.', - }, - { - displayName: 'Prefix', - name: 'prefix', - type: 'string', - default: '', - description: 'Limits the response to keys that begin with the specified prefix.', - }, - { - displayName: 'Requester Pays', - name: 'requesterPays', - type: 'boolean', - default: false, - description: 'Weather the requester will pay for requests and data transfer. While Requester Pays is enabled, anonymous access to this bucket is disabled.', - }, - { - displayName: 'Start After', - name: 'startAfter', - type: 'string', - default: '', - description: 'StartAfter is where you want Amazon S3 to start listing from. Amazon S3 starts listing after this specified key', - }, - ], - }, -] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/S3/FileDescription.ts b/packages/nodes-base/nodes/S3/FileDescription.ts deleted file mode 100644 index 208b847b3d..0000000000 --- a/packages/nodes-base/nodes/S3/FileDescription.ts +++ /dev/null @@ -1,922 +0,0 @@ -import { - INodeProperties, -} from 'n8n-workflow'; - -export const fileOperations = [ - { - displayName: 'Operation', - name: 'operation', - type: 'options', - displayOptions: { - show: { - resource: [ - 'file', - ], - }, - }, - options: [ - { - name: 'Copy', - value: 'copy', - description: 'Copy a file', - }, - { - name: 'Delete', - value: 'delete', - description: 'Delete a file', - }, - { - name: 'Download', - value: 'download', - description: 'Download a file', - }, - { - name: 'Get All', - value: 'getAll', - description: 'Get all files', - }, - { - name: 'Upload', - value: 'upload', - description: 'Upload a file', - }, - ], - default: 'download', - description: 'The operation to perform.', - }, -] as INodeProperties[]; - -export const fileFields = [ - -/* -------------------------------------------------------------------------- */ -/* file:copy */ -/* -------------------------------------------------------------------------- */ - { - displayName: 'Source Path', - name: 'sourcePath', - type: 'string', - required: true, - default: '', - placeholder: '/bucket/my-image.jpg', - displayOptions: { - show: { - resource: [ - 'file', - ], - operation: [ - 'copy', - ], - }, - }, - description: 'The name of the source bucket and key name of the source object, separated by a slash (/)', - }, - { - displayName: 'Destination Path', - name: 'destinationPath', - type: 'string', - required: true, - default: '', - placeholder: '/bucket/my-second-image.jpg', - displayOptions: { - show: { - resource: [ - 'file', - ], - operation: [ - 'copy', - ], - }, - }, - description: 'The name of the destination bucket and key name of the destination object, separated by a slash (/)', - }, - { - displayName: 'Additional Fields', - name: 'additionalFields', - type: 'collection', - placeholder: 'Add Field', - displayOptions: { - show: { - resource: [ - 'file', - ], - operation: [ - 'copy', - ], - }, - }, - default: {}, - options: [ - { - displayName: 'ACL', - name: 'acl', - type: 'options', - options: [ - { - name: 'Authenticated Read', - value: 'authenticatedRead' - }, - { - name: 'AWS Exec Read', - value: 'awsExecRead' - }, - { - name: 'Bucket Owner Full Control', - value: 'bucketOwnerFullControl' - }, - { - name: 'Bucket Owner Read', - value: 'bucketOwnerRead' - }, - { - name: 'Private', - value: 'private', - }, - { - name: 'Public Read', - value: 'publicRead' - }, - { - name: 'Public Read Write', - value: 'publicReadWrite' - }, - ], - default: 'private', - description: 'The canned ACL to apply to the object.' - }, - { - displayName: 'Grant Full Control', - name: 'grantFullControl', - type: 'boolean', - default: false, - description: 'Gives the grantee READ, READ_ACP, and WRITE_ACP permissions on the object.', - }, - { - displayName: 'Grant Read', - name: 'grantRead', - type: 'boolean', - default: false, - description: 'Allows grantee to read the object data and its metadata.', - }, - { - displayName: 'Grant Read ACP', - name: 'grantReadAcp', - type: 'boolean', - default: false, - description: 'Allows grantee to read the object ACL.', - }, - { - displayName: 'Grant Write ACP', - name: 'grantWriteAcp', - type: 'boolean', - default: false, - description: 'Allows grantee to write the ACL for the applicable object.', - }, - { - displayName: 'Lock Legal Hold', - name: 'lockLegalHold', - type: 'boolean', - default: false, - description: 'Specifies whether a legal hold will be applied to this object', - }, - { - displayName: 'Lock Mode', - name: 'lockMode', - type: 'options', - options: [ - { - name: 'Governance', - value: 'governance', - }, - { - name: 'Compliance', - value: 'compliance', - }, - ], - default: '', - description: 'The Object Lock mode that you want to apply to this object.', - }, - { - displayName: 'Lock Retain Until Date', - name: 'lockRetainUntilDate', - type: 'dateTime', - default: '', - description: `The date and time when you want this object's Object Lock to expire.`, - }, - { - displayName: 'Metadata Directive', - name: 'metadataDirective', - type: 'options', - options: [ - { - name: 'Copy', - value: 'copy', - }, - { - name: 'Replace', - value: 'replace', - }, - ], - default: '', - description: 'Specifies whether the metadata is copied from the source object or replaced with metadata provided in the request.', - }, - { - displayName: 'Requester Pays', - name: 'requesterPays', - type: 'boolean', - default: false, - description: 'Weather the requester will pay for requests and data transfer. While Requester Pays is enabled, anonymous access to this bucket is disabled.', - }, - { - displayName: 'Server Side Encryption', - name: 'serverSideEncryption', - type: 'options', - options: [ - { - name: 'AES256', - value: 'AES256', - }, - { - name: 'AWS:KMS', - value: 'aws:kms', - }, - ], - default: '', - description: 'The server-side encryption algorithm used when storing this object in Amazon S3', - }, - { - displayName: 'Server Side Encryption Context', - name: 'serverSideEncryptionContext', - type: 'string', - default: '', - description: 'Specifies the AWS KMS Encryption Context to use for object encryption', - }, - { - displayName: 'Server Side Encryption AWS KMS Key ID', - name: 'encryptionAwsKmsKeyId', - type: 'string', - default: '', - description: 'If x-amz-server-side-encryption is present and has the value of aws:kms', - }, - { - displayName: 'Server Side Encryption Customer Algorithm', - name: 'serversideEncryptionCustomerAlgorithm', - type: 'string', - default: '', - description: 'Specifies the algorithm to use to when encrypting the object (for example, AES256).', - }, - { - displayName: 'Server Side Encryption Customer Key', - name: 'serversideEncryptionCustomerKey', - type: 'string', - default: '', - description: 'Specifies the customer-provided encryption key for Amazon S3 to use in encrypting data', - }, - { - displayName: 'Server Side Encryption Customer Key MD5', - name: 'serversideEncryptionCustomerKeyMD5', - type: 'string', - default: '', - description: 'Specifies the 128-bit MD5 digest of the encryption key according to RFC 1321.', - }, - { - displayName: 'Storage Class', - name: 'storageClass', - type: 'options', - options: [ - { - name: 'Deep Archive', - value: 'deepArchive', - }, - { - name: 'Intelligent Tiering', - value: 'intelligentTiering', - }, - { - name: 'One Zone IA', - value: 'onezoneIA', - }, - { - name: 'Glacier', - value: 'glacier', - }, - { - name: 'Standard', - value: 'standard', - }, - { - name: 'Standard IA', - value: 'standardIA', - }, - ], - default: 'standard', - description: 'Amazon S3 storage classes.', - }, - { - displayName: 'Tagging Directive', - name: 'taggingDirective', - type: 'options', - options: [ - { - name: 'Copy', - value: 'copy', - }, - { - name: 'Replace', - value: 'replace', - }, - ], - default: '', - description: 'Specifies whether the metadata is copied from the source object or replaced with metadata provided in the request.', - }, - ], - }, -/* -------------------------------------------------------------------------- */ -/* file:upload */ -/* -------------------------------------------------------------------------- */ - { - displayName: 'Bucket Name', - name: 'bucketName', - type: 'string', - required: true, - default: '', - displayOptions: { - show: { - resource: [ - 'file', - ], - operation: [ - 'upload', - ], - }, - }, - }, - { - displayName: 'File Name', - name: 'fileName', - type: 'string', - default: '', - placeholder: 'hello.txt', - required: true, - displayOptions: { - show: { - resource: [ - 'file', - ], - operation: [ - 'upload', - ], - binaryData: [ - false, - ], - }, - }, - }, - { - displayName: 'File Name', - name: 'fileName', - type: 'string', - default: '', - displayOptions: { - show: { - resource: [ - 'file', - ], - operation: [ - 'upload', - ], - binaryData: [ - true, - ], - }, - }, - description: 'If not set the binary data filename will be used.', - }, - { - displayName: 'Binary Data', - name: 'binaryData', - type: 'boolean', - default: true, - displayOptions: { - show: { - operation: [ - 'upload' - ], - resource: [ - 'file', - ], - }, - }, - description: 'If the data to upload should be taken from binary field.', - }, - { - displayName: 'File Content', - name: 'fileContent', - type: 'string', - default: '', - displayOptions: { - show: { - operation: [ - 'upload' - ], - resource: [ - 'file', - ], - binaryData: [ - false - ], - }, - }, - placeholder: '', - description: 'The text content of the file to upload.', - }, - { - displayName: 'Binary Property', - name: 'binaryPropertyName', - type: 'string', - default: 'data', - required: true, - displayOptions: { - show: { - operation: [ - 'upload' - ], - resource: [ - 'file', - ], - binaryData: [ - true - ], - }, - - }, - placeholder: '', - description: 'Name of the binary property which contains
the data for the file to be uploaded.', - }, - { - displayName: 'Additional Fields', - name: 'additionalFields', - type: 'collection', - placeholder: 'Add Field', - displayOptions: { - show: { - resource: [ - 'file', - ], - operation: [ - 'upload', - ], - }, - }, - default: {}, - options: [ - { - displayName: 'ACL', - name: 'acl', - type: 'options', - options: [ - { - name: 'Authenticated Read', - value: 'authenticatedRead' - }, - { - name: 'AWS Exec Read', - value: 'awsExecRead' - }, - { - name: 'Bucket Owner Full Control', - value: 'bucketOwnerFullControl' - }, - { - name: 'Bucket Owner Read', - value: 'bucketOwnerRead' - }, - { - name: 'Private', - value: 'private', - }, - { - name: 'Public Read', - value: 'publicRead' - }, - { - name: 'Public Read Write', - value: 'publicReadWrite' - }, - ], - default: 'private', - description: 'The canned ACL to apply to the object.' - }, - { - displayName: 'Grant Full Control', - name: 'grantFullControl', - type: 'boolean', - default: false, - description: 'Gives the grantee READ, READ_ACP, and WRITE_ACP permissions on the object.', - }, - { - displayName: 'Grant Read', - name: 'grantRead', - type: 'boolean', - default: false, - description: 'Allows grantee to read the object data and its metadata.', - }, - { - displayName: 'Grant Read ACP', - name: 'grantReadAcp', - type: 'boolean', - default: false, - description: 'Allows grantee to read the object ACL.', - }, - { - displayName: 'Grant Write ACP', - name: 'grantWriteAcp', - type: 'boolean', - default: false, - description: 'Allows grantee to write the ACL for the applicable object.', - }, - { - displayName: 'Lock Legal Hold', - name: 'lockLegalHold', - type: 'boolean', - default: false, - description: 'Specifies whether a legal hold will be applied to this object', - }, - { - displayName: 'Lock Mode', - name: 'lockMode', - type: 'options', - options: [ - { - name: 'Governance', - value: 'governance', - }, - { - name: 'Compliance', - value: 'compliance', - }, - ], - default: '', - description: 'The Object Lock mode that you want to apply to this object.', - }, - { - displayName: 'Lock Retain Until Date', - name: 'lockRetainUntilDate', - type: 'dateTime', - default: '', - description: `The date and time when you want this object's Object Lock to expire.`, - }, - { - displayName: 'Parent Folder Key', - name: 'parentFolderKey', - type: 'string', - default: '', - description: 'Parent file you want to create the file in', - }, - { - displayName: 'Requester Pays', - name: 'requesterPays', - type: 'boolean', - default: false, - description: 'Weather the requester will pay for requests and data transfer. While Requester Pays is enabled, anonymous access to this bucket is disabled.', - }, - { - displayName: 'Server Side Encryption', - name: 'serverSideEncryption', - type: 'options', - options: [ - { - name: 'AES256', - value: 'AES256', - }, - { - name: 'AWS:KMS', - value: 'aws:kms', - }, - ], - default: '', - description: 'The server-side encryption algorithm used when storing this object in Amazon S3', - }, - { - displayName: 'Server Side Encryption Context', - name: 'serverSideEncryptionContext', - type: 'string', - default: '', - description: 'Specifies the AWS KMS Encryption Context to use for object encryption', - }, - { - displayName: 'Server Side Encryption AWS KMS Key ID', - name: 'encryptionAwsKmsKeyId', - type: 'string', - default: '', - description: 'If x-amz-server-side-encryption is present and has the value of aws:kms', - }, - { - displayName: 'Server Side Encryption Customer Algorithm', - name: 'serversideEncryptionCustomerAlgorithm', - type: 'string', - default: '', - description: 'Specifies the algorithm to use to when encrypting the object (for example, AES256).', - }, - { - displayName: 'Server Side Encryption Customer Key', - name: 'serversideEncryptionCustomerKey', - type: 'string', - default: '', - description: 'Specifies the customer-provided encryption key for Amazon S3 to use in encrypting data', - }, - { - displayName: 'Server Side Encryption Customer Key MD5', - name: 'serversideEncryptionCustomerKeyMD5', - type: 'string', - default: '', - description: 'Specifies the 128-bit MD5 digest of the encryption key according to RFC 1321.', - }, - { - displayName: 'Storage Class', - name: 'storageClass', - type: 'options', - options: [ - { - name: 'Deep Archive', - value: 'deepArchive', - }, - { - name: 'Intelligent Tiering', - value: 'intelligentTiering', - }, - { - name: 'One Zone IA', - value: 'onezoneIA', - }, - { - name: 'Glacier', - value: 'glacier', - }, - { - name: 'Standard', - value: 'standard', - }, - { - name: 'Standard IA', - value: 'standardIA', - }, - ], - default: 'standard', - description: 'Amazon S3 storage classes.', - }, - ], - }, - { - displayName: 'Tags', - name: 'tagsUi', - placeholder: 'Add Tag', - type: 'fixedCollection', - default: '', - typeOptions: { - multipleValues: true, - }, - displayOptions: { - show: { - resource: [ - 'file', - ], - operation: [ - 'upload', - ], - }, - }, - options: [ - { - name: 'tagsValues', - displayName: 'Tag', - values: [ - { - displayName: 'Key', - name: 'key', - type: 'string', - default: '', - description: '', - }, - { - displayName: 'Value', - name: 'value', - type: 'string', - default: '', - description: '', - }, - ], - } - ], - description: 'Optional extra headers to add to the message (most headers are allowed).', - }, -/* -------------------------------------------------------------------------- */ -/* file:download */ -/* -------------------------------------------------------------------------- */ - { - displayName: 'Bucket Name', - name: 'bucketName', - type: 'string', - required: true, - default: '', - displayOptions: { - show: { - resource: [ - 'file', - ], - operation: [ - 'download', - ], - }, - }, - }, - { - displayName: 'File Key', - name: 'fileKey', - type: 'string', - required: true, - default: '', - displayOptions: { - show: { - resource: [ - 'file', - ], - operation: [ - 'download', - ], - }, - }, - }, - { - displayName: 'Binary Property', - name: 'binaryPropertyName', - type: 'string', - required: true, - default: 'data', - displayOptions: { - show: { - operation: [ - 'download' - ], - resource: [ - 'file', - ], - }, - }, - description: 'Name of the binary property to which to
write the data of the read file.', - }, -/* -------------------------------------------------------------------------- */ -/* file:delete */ -/* -------------------------------------------------------------------------- */ - { - displayName: 'Bucket Name', - name: 'bucketName', - type: 'string', - required: true, - default: '', - displayOptions: { - show: { - resource: [ - 'file', - ], - operation: [ - 'delete', - ], - }, - }, - }, - { - displayName: 'File Key', - name: 'fileKey', - type: 'string', - required: true, - default: '', - displayOptions: { - show: { - resource: [ - 'file', - ], - operation: [ - 'delete', - ], - }, - }, - }, - { - displayName: 'Options', - name: 'options', - type: 'collection', - placeholder: 'Add Field', - default: {}, - displayOptions: { - show: { - resource: [ - 'file', - ], - operation: [ - 'delete', - ], - }, - }, - options: [ - { - displayName: 'Version ID', - name: 'versionId', - type: 'string', - default: '', - }, - ], - }, -/* -------------------------------------------------------------------------- */ -/* file:getAll */ -/* -------------------------------------------------------------------------- */ - { - displayName: 'Bucket Name', - name: 'bucketName', - type: 'string', - required: true, - default: '', - displayOptions: { - show: { - resource: [ - 'file', - ], - operation: [ - 'getAll', - ], - }, - }, - }, - { - displayName: 'Return All', - name: 'returnAll', - type: 'boolean', - displayOptions: { - show: { - operation: [ - 'getAll', - ], - resource: [ - 'file', - ], - }, - }, - default: false, - description: 'If all results should be returned or only up to a given limit.', - }, - { - displayName: 'Limit', - name: 'limit', - type: 'number', - displayOptions: { - show: { - operation: [ - 'getAll', - ], - resource: [ - 'file', - ], - returnAll: [ - false, - ], - }, - }, - typeOptions: { - minValue: 1, - maxValue: 500, - }, - default: 100, - description: 'How many results to return.', - }, - { - displayName: 'Options', - name: 'options', - type: 'collection', - placeholder: 'Add Field', - default: {}, - displayOptions: { - show: { - resource: [ - 'file', - ], - operation: [ - 'getAll', - ], - }, - }, - options: [ - { - displayName: 'Fetch Owner', - name: 'fetchOwner', - type: 'boolean', - default: false, - description: 'The owner field is not present in listV2 by default, if you want to return owner field with each key in the result then set the fetch owner field to true.', - }, - { - displayName: 'Folder Key', - name: 'folderKey', - type: 'string', - default: '', - }, - ], - }, -] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/S3/FolderDescription.ts b/packages/nodes-base/nodes/S3/FolderDescription.ts deleted file mode 100644 index 2884206d66..0000000000 --- a/packages/nodes-base/nodes/S3/FolderDescription.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { - INodeProperties, -} from 'n8n-workflow'; - -export const folderOperations = [ - { - displayName: 'Operation', - name: 'operation', - type: 'options', - displayOptions: { - show: { - resource: [ - 'folder', - ], - }, - }, - options: [ - { - name: 'Create', - value: 'create', - description: 'Create a folder', - }, - { - name: 'Delete', - value: 'delete', - description: 'Delete a folder', - }, - { - name: 'Get All', - value: 'getAll', - description: 'Get all folders', - }, - ], - default: 'create', - description: 'The operation to perform.', - }, -] as INodeProperties[]; - -export const folderFields = [ - -/* -------------------------------------------------------------------------- */ -/* folder:create */ -/* -------------------------------------------------------------------------- */ - { - displayName: 'Bucket Name', - name: 'bucketName', - type: 'string', - required: true, - default: '', - displayOptions: { - show: { - resource: [ - 'folder', - ], - operation: [ - 'create', - ], - }, - }, - }, - { - displayName: 'Folder Name', - name: 'folderName', - type: 'string', - required: true, - default: '', - displayOptions: { - show: { - resource: [ - 'folder', - ], - operation: [ - 'create', - ], - }, - }, - }, - { - displayName: 'Additional Fields', - name: 'additionalFields', - type: 'collection', - placeholder: 'Add Field', - displayOptions: { - show: { - resource: [ - 'folder', - ], - operation: [ - 'create', - ], - }, - }, - default: {}, - options: [ - { - displayName: 'Parent Folder Key', - name: 'parentFolderKey', - type: 'string', - default: '', - description: 'Parent folder you want to create the folder in' - }, - { - displayName: 'Requester Pays', - name: 'requesterPays', - type: 'boolean', - default: false, - description: 'Weather the requester will pay for requests and data transfer. While Requester Pays is enabled, anonymous access to this bucket is disabled.', - }, - { - displayName: 'Storage Class', - name: 'storageClass', - type: 'options', - options: [ - { - name: 'Deep Archive', - value: 'deepArchive', - }, - { - name: 'Intelligent Tiering', - value: 'intelligentTiering', - }, - { - name: 'One Zone IA', - value: 'onezoneIA', - }, - { - name: 'Glacier', - value: 'glacier', - }, - { - name: 'Reduced Redundancy', - value: 'RecudedRedundancy', - }, - { - name: 'Standard', - value: 'standard', - }, - { - name: 'Standard IA', - value: 'standardIA', - }, - ], - default: 'standard', - description: 'Amazon S3 storage classes.' - }, - ], - }, -/* -------------------------------------------------------------------------- */ -/* folder:delete */ -/* -------------------------------------------------------------------------- */ - { - displayName: 'Bucket Name', - name: 'bucketName', - type: 'string', - required: true, - default: '', - displayOptions: { - show: { - resource: [ - 'folder', - ], - operation: [ - 'delete', - ], - }, - }, - }, - { - displayName: 'Folder Key', - name: 'folderKey', - type: 'string', - required: true, - default: '', - displayOptions: { - show: { - resource: [ - 'folder', - ], - operation: [ - 'delete', - ], - }, - }, - }, -/* -------------------------------------------------------------------------- */ -/* folder:getAll */ -/* -------------------------------------------------------------------------- */ - { - displayName: 'Bucket Name', - name: 'bucketName', - type: 'string', - required: true, - default: '', - displayOptions: { - show: { - resource: [ - 'folder', - ], - operation: [ - 'getAll', - ], - }, - }, - }, - { - displayName: 'Return All', - name: 'returnAll', - type: 'boolean', - displayOptions: { - show: { - operation: [ - 'getAll', - ], - resource: [ - 'folder', - ], - }, - }, - default: false, - description: 'If all results should be returned or only up to a given limit.', - }, - { - displayName: 'Limit', - name: 'limit', - type: 'number', - displayOptions: { - show: { - operation: [ - 'getAll', - ], - resource: [ - 'folder', - ], - returnAll: [ - false, - ], - }, - }, - typeOptions: { - minValue: 1, - maxValue: 500, - }, - default: 100, - description: 'How many results to return.', - }, - { - displayName: 'Options', - name: 'options', - type: 'collection', - placeholder: 'Add Field', - default: {}, - displayOptions: { - show: { - resource: [ - 'folder', - ], - operation: [ - 'getAll', - ], - }, - }, - options: [ - { - displayName: 'Fetch Owner', - name: 'fetchOwner', - type: 'boolean', - default: false, - description: 'The owner field is not present in listV2 by default, if you want to return owner field with each key in the result then set the fetch owner field to true.', - }, - { - displayName: 'Folder Key', - name: 'folderKey', - type: 'string', - default: '', - }, - ], - }, -] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/S3/S3.node.ts b/packages/nodes-base/nodes/S3/S3.node.ts index 9eba6ab6d3..4272adb5b2 100644 --- a/packages/nodes-base/nodes/S3/S3.node.ts +++ b/packages/nodes-base/nodes/S3/S3.node.ts @@ -28,17 +28,17 @@ import { import { bucketFields, bucketOperations, -} from './BucketDescription'; +} from '../Aws/S3/BucketDescription'; import { folderFields, folderOperations, -} from './FolderDescription'; +} from '../Aws/S3/FolderDescription'; import { fileFields, fileOperations, -} from './FileDescription'; +} from '../Aws/S3/FileDescription'; import { s3ApiRequestREST, From 2f9bab571d5e95e41cb7408f1d13d1f5a945e328 Mon Sep 17 00:00:00 2001 From: Denis Palashevskii Date: Wed, 2 Sep 2020 00:29:56 +0400 Subject: [PATCH 10/46] Trim whitespaces in AWS credentials --- packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts | 2 +- packages/nodes-base/nodes/S3/GenericFunctions.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts b/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts index 92d620d102..fd7fe838f6 100644 --- a/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Aws/S3/GenericFunctions.ts @@ -36,7 +36,7 @@ export async function awsApiRequest(this: IHookFunctions | IExecuteFunctions | I // Sign AWS API request with the user credentials const signOpts = {headers: headers || {}, host: endpoint, method, path: `${path}?${queryToString(query).replace(/\+/g, '%2B')}`, body}; - sign(signOpts, { accessKeyId: `${credentials.accessKeyId}`, secretAccessKey: `${credentials.secretAccessKey}`}); + sign(signOpts, { accessKeyId: `${credentials.accessKeyId}`.trim(), secretAccessKey: `${credentials.secretAccessKey}`.trim()}); const options: OptionsWithUri = { headers: signOpts.headers, diff --git a/packages/nodes-base/nodes/S3/GenericFunctions.ts b/packages/nodes-base/nodes/S3/GenericFunctions.ts index 00f59b3f70..093b7cfb71 100644 --- a/packages/nodes-base/nodes/S3/GenericFunctions.ts +++ b/packages/nodes-base/nodes/S3/GenericFunctions.ts @@ -64,7 +64,7 @@ export async function s3ApiRequest(this: IHookFunctions | IExecuteFunctions | IL body }; - sign(signOpts, { accessKeyId: `${credentials.accessKeyId}`, secretAccessKey: `${credentials.secretAccessKey}`}); + sign(signOpts, { accessKeyId: `${credentials.accessKeyId}`.trim(), secretAccessKey: `${credentials.secretAccessKey}`.trim()}); const options: OptionsWithUri = { headers: signOpts.headers, From de50e2479d7a6f46720e66b9fffe57e14cd02591 Mon Sep 17 00:00:00 2001 From: Denis Palashevskii Date: Wed, 2 Sep 2020 00:41:51 +0400 Subject: [PATCH 11/46] Trim whitespaces in the AWS credentials in other nodes --- packages/nodes-base/nodes/Aws/GenericFunctions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Aws/GenericFunctions.ts b/packages/nodes-base/nodes/Aws/GenericFunctions.ts index ef334b1d67..880c0a02bb 100644 --- a/packages/nodes-base/nodes/Aws/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Aws/GenericFunctions.ts @@ -20,7 +20,7 @@ export async function awsApiRequest(this: IHookFunctions | IExecuteFunctions | I // Sign AWS API request with the user credentials const signOpts = { headers: headers || {}, host: endpoint, method, path, body }; - sign(signOpts, { accessKeyId: `${credentials.accessKeyId}`, secretAccessKey: `${credentials.secretAccessKey}` }); + sign(signOpts, { accessKeyId: `${credentials.accessKeyId}`.trim(), secretAccessKey: `${credentials.secretAccessKey}`.trim() }); const options: OptionsWithUri = { headers: signOpts.headers, From 248ccce5c3236068bf341bda613d6f436c2d4112 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 2 Sep 2020 08:30:01 +0200 Subject: [PATCH 12/46] :zap: Minor improvements to S3-Node --- ...point.credentials.ts => S3.credentials.ts} | 6 +++--- .../nodes-base/nodes/S3/GenericFunctions.ts | 8 +++---- packages/nodes-base/nodes/S3/S3.node.ts | 20 +++++++++--------- packages/nodes-base/nodes/S3/generic-s3.png | Bin 5600 -> 0 bytes packages/nodes-base/nodes/S3/s3.png | Bin 0 -> 1145 bytes packages/nodes-base/package.json | 2 +- 6 files changed, 18 insertions(+), 18 deletions(-) rename packages/nodes-base/credentials/{CustomS3Endpoint.credentials.ts => S3.credentials.ts} (86%) delete mode 100644 packages/nodes-base/nodes/S3/generic-s3.png create mode 100644 packages/nodes-base/nodes/S3/s3.png diff --git a/packages/nodes-base/credentials/CustomS3Endpoint.credentials.ts b/packages/nodes-base/credentials/S3.credentials.ts similarity index 86% rename from packages/nodes-base/credentials/CustomS3Endpoint.credentials.ts rename to packages/nodes-base/credentials/S3.credentials.ts index 266a38fddf..9d5b08c19d 100644 --- a/packages/nodes-base/credentials/CustomS3Endpoint.credentials.ts +++ b/packages/nodes-base/credentials/S3.credentials.ts @@ -4,9 +4,9 @@ import { } from 'n8n-workflow'; -export class CustomS3Endpoint implements ICredentialType { - name = 'customS3Endpoint'; - displayName = 'Custom S3'; +export class S3 implements ICredentialType { + name = 's3'; + displayName = 'S3'; properties = [ { displayName: 'S3 endpoint', diff --git a/packages/nodes-base/nodes/S3/GenericFunctions.ts b/packages/nodes-base/nodes/S3/GenericFunctions.ts index 093b7cfb71..19dbfa6df9 100644 --- a/packages/nodes-base/nodes/S3/GenericFunctions.ts +++ b/packages/nodes-base/nodes/S3/GenericFunctions.ts @@ -23,7 +23,7 @@ import { import { IDataObject, - } from 'n8n-workflow'; +} from 'n8n-workflow'; import { URL } from 'url'; @@ -31,7 +31,7 @@ export async function s3ApiRequest(this: IHookFunctions | IExecuteFunctions | IL let credentials; - credentials = this.getCredentials('customS3Endpoint'); + credentials = this.getCredentials('s3'); if (credentials === undefined) { throw new Error('No credentials got returned!'); @@ -64,13 +64,13 @@ export async function s3ApiRequest(this: IHookFunctions | IExecuteFunctions | IL body }; - sign(signOpts, { accessKeyId: `${credentials.accessKeyId}`.trim(), secretAccessKey: `${credentials.secretAccessKey}`.trim()}); + sign(signOpts, { accessKeyId: `${credentials.accessKeyId}`.trim(), secretAccessKey: `${credentials.secretAccessKey}`.trim() }); const options: OptionsWithUri = { headers: signOpts.headers, method, qs: query, - uri: endpoint, + uri: endpoint.toString(), body: signOpts.body, }; diff --git a/packages/nodes-base/nodes/S3/S3.node.ts b/packages/nodes-base/nodes/S3/S3.node.ts index 4272adb5b2..accb8494fd 100644 --- a/packages/nodes-base/nodes/S3/S3.node.ts +++ b/packages/nodes-base/nodes/S3/S3.node.ts @@ -49,12 +49,12 @@ import { export class S3 implements INodeType { description: INodeTypeDescription = { displayName: 'S3', - name: 'S3', - icon: 'file:generic-s3.png', + name: 's3', + icon: 'file:s3.png', group: ['output'], version: 1, subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', - description: 'Sends data to any S3-compatible services', + description: 'Sends data to any S3-compatible service', defaults: { name: 'S3', color: '#d05b4b', @@ -63,7 +63,7 @@ export class S3 implements INodeType { outputs: ['main'], credentials: [ { - name: 'customS3Endpoint', + name: 's3', required: true, }, ], @@ -117,7 +117,7 @@ export class S3 implements INodeType { let credentials; try { - credentials = this.getCredentials('customS3Endpoint'); + credentials = this.getCredentials('s3'); } catch (error) { throw new Error(error); } @@ -274,10 +274,10 @@ export class S3 implements INodeType { responseData = await s3ApiRequestSOAP.call(this, bucketName, 'DELETE', `/${folderKey}`, '', qs, {}, {}, region); - responseData = { deleted: [ { 'Key': folderKey } ] }; + responseData = { deleted: [{ 'Key': folderKey }] }; } else { - // delete everything inside the folder + // delete everything inside the folder const body: IDataObject = { Delete: { '$': { @@ -301,7 +301,7 @@ export class S3 implements INodeType { headers['Content-Type'] = 'application/xml'; - responseData = await s3ApiRequestSOAP.call(this, bucketName, 'POST', '/', data, { delete: '' } , headers, {}, region); + responseData = await s3ApiRequestSOAP.call(this, bucketName, 'POST', '/', data, { delete: '' }, headers, {}, region); responseData = { deleted: responseData.DeleteResult.Deleted }; } @@ -437,7 +437,7 @@ export class S3 implements INodeType { region = region.LocationConstraint._; - const response = await s3ApiRequestREST.call(this, bucketName, 'GET', `/${fileKey}`, '', qs, {}, { encoding: null, resolveWithFullResponse: true }, region); + const response = await s3ApiRequestREST.call(this, bucketName, 'GET', `/${fileKey}`, '', qs, {}, { encoding: null, resolveWithFullResponse: true }, region); let mimeType: string | undefined; if (response.headers['content-type']) { @@ -462,7 +462,7 @@ export class S3 implements INodeType { const data = Buffer.from(response.body as string, 'utf8'); - items[i].binary![dataPropertyNameDownload] = await this.helpers.prepareBinaryData(data as unknown as Buffer, fileName, mimeType); + items[i].binary![dataPropertyNameDownload] = await this.helpers.prepareBinaryData(data as unknown as Buffer, fileName, mimeType); } //https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html if (operation === 'delete') { diff --git a/packages/nodes-base/nodes/S3/generic-s3.png b/packages/nodes-base/nodes/S3/generic-s3.png deleted file mode 100644 index 4f9faf6dd14612bf493cae796f551cab0d3d7e4e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5600 zcmeHLYcy2t-yX&(jgVtW#yrU6Fc^u+m@tJz^PrI9U^2s)5yOnbU?`!S<&a}iNF_qb zX@pTBq#TA&l2a%tLi|T>>v`U_-t~Uy!~5Y~t9$Le@4fHebzl2;U%z{;eXSjfu{049 z+$#tG0EEm;(N>%uvwaHiajprXOLsY4Y_N?T#R^LW`}&d35r{Z2CCC>C#?c7p000_0 zAfZF3MFV8Kcn$IwM$Wun_gE6(O~Hgof)fvNSr&Z)EYSNoD8EWb`gng~$m1LLtI}+< z5@NYk$FCncV1Bj3?5_8Ls|5WJW?sRbF#PPB?)w7d#&z}A>X&Ab_J&liw0PSksMi?Z z4}X^)-sK3@H+X%CfZcGO z1QHUmSl0vr6FiU*+d~!_7QV(fJi#>B4`&^0Y2zO3?XKqmK^X|@(-0g29~=b>ruh&_ z{sSRApn5Led9!x_ErlqCEL8$o$ktkT28p;2!ZHoWmK;!(~ z{RqAk0+|Hf=ES;@11LxcgcAq)&1}5~2x#ZL0|TB_pRQuz$+3t^`}30D()f>Df8_ct1%3$ep6E%2Y+_5VvQ!GDb%j>K_uRE}*s(^R~dWBGXejm&HWI4xAbBZlJ+m(9?IHZ=Bp zjswwgARxMFs}n>nM1O{)JhrREnK!m(C>(m!!Po#jU)}X%cRE z8mATqZ+Ux6K}tiqGmHLhXfD6H-+6t^TUhmZ;Xj9CYT;1mYFzQRNMn{NL<>ypSuF`2 z0w6aHBW*In5}`)z_byGzMX1yqN=^~r(dmfTYaH*ZRw!u=yv_Ywbdsr6z~dNs8kBG% zZZBja>g3RVV7$CAgRg>Llx~MSD%_*ki`Dg9S1mDqSC4Ti5TBybu?yU_#CCHuD`k>l z7xhTC)_Pj9<#+*~GPj^&Asj0);hG4Jt~jEQz(xq}GumwGsVXk4$ZO1XILFU??qgx6 zno5y52miF^)7_Y}*9RRF>{(`=_C2EGQcqFM^>;OD2fv{&qX% zw3P;M+_ZrkPOtNv@0s7-Y-DOdG#Y?wMXD}GqGyacKNHvDq{###-n(LIxjUnt@5m66|M~B9B6y=2n+lX}h}GSDz5?KXRcRkFL!av0k`# zc0eie#b!lrA$?x|rAVcak~ylTcpgt~S7bc3Tg7oVWDcH2`4RRE)Ka0V)=W9bnDB_Q4K4ni zPNW`Rz1J$WD7|%I%{=eSqlQuq?Zn;a@K;E_;?55yc!e%1N?dp~XanuyO$T=Hn5#-f#W702 z=Muj#AUIQQ_i=f9<7ln|;Su+;OH8L4L^_&ZZ2pK`M+~TBv?ejlh^Iqn1(+z`Sfu?U zx+~K+$hQ7E$`&9RX8kf(&M#KHN2rP$#dTI>k>5Qk_Qj%sPVt2Eu?c4dTUQdmKykJH z?S9{zMNcm+hdwysy!9Dcp?NjlqT4LkFC~Wn(!E6Ax2!T@Y-GY*9G*1+Nr@W_Xd}l2-64@)m8czN?MR|q6fUZnOx{wyR2GZ+(76(#qPHNV0s-bzak!eX}FhC~X{|eZ|Q^61*oh`9;>F z=)rLNg*uH)=?YY8&7Ot6HG!U;XY&@D`65k|=mn>4-TJaZf%z(s0cQ=$Fb70~_l-XWl)3W(Rm z!x4)_;l#aio!=VolzreeVM-ZZ{Vu)Q>;Ge8H6CvNrVpjX!f16Kj-4dc{Kzg7Ep8fRMmLps29idHTiPh?h?UnsZdTUX}MDa4>7#jVZ|7(YYv&$U?x<5%i(ATVUj$2vj;#+p%ZdJ5L})ep(qSuyGp*tE z=atrT5qb>0Ybqc;vd>L{wfjmQCHb2p@wU8+OI#aY|B@NPqz*GmV01qTIR~fXUn`9o zH<@dWjx%?~4acqk2W865HJjPz+g@^SPA|OPvr=7O6agE1pfsl`ehj!d`$Z<^iudR0 zF_-q+lS7ip-@jI=!$ISkS?0$b+*;jO%*Gt-{BS+X%c z^I7em1Fk9(8>sK1in38>x{z}!S~Igun=P8#EU(g<^5u|^8vt@n=LQd2pq}=L!kXXO zX#Q$*)iPA~wua=}YzLi_jqiG24Sg*I;&)xKopDL2=-F`k(tE|}`_+ftU#y0|Uty0v z_Jf`xUJH%hT8@_>KDzPYV9Ph@TLJxC+F?7_-(4!-TKciS@vG5r5qQ6TZ%ab>kyiXc zc=UG;H)db9{#N2~?#61|U%j!NJ&xL8A^lt@(}V*UqjfK--CIlAr}RT21`-0`ls!pU z0VgSw)diLIpp7A5&YaA%Y5ROT2Uhy9w?OXZdhs*ohj$!VQm98>kS#jwZ_QVSCI!-k z)o)Z*K-CGu7n)LBjV+*u2hy#qL?(%g8b0$43VbQ{NKcC(&9aUM=E=K(H|mEqeJ_f4 zhCH4Sino4^goU?x48k%+m_0LHH*QSb3OrZW;2HBR@4Eh#0iBQCmllGcU&Mp^zG)1^ zWr{Xu`|~j|IcP+vg=@w=CgP*27ob>{nfK-^kmXWMr;%j#^X&qR$jK;;I-HL&)|9jbb+ZVFDB}w~LusaPovA zEq8n+B7g5SMh&`Ne(`)T`yuuArd#tYW9)~q0>(KO*($@(TC+HA5YwU zyJ_QfyN*LP?FV0c_Ci(6;XljpHBCB%SgU{u{zb!TA%0fy|s%PtMbg{+Wu`?v$Sj-0|V0wPZ!4! zkK=ErJuh42AkxsTCF`=``@P9M3-ZkJl04VXe*6Bv61NtUaL14A^EW5Izi)f)xh`M$ z0rvj_I{smtX16T^Ep|p~iFy0Z4ED2%oVH?8B~Q!c6)TmLFB`B#rMYaq#$i<0_&ljV z->^@-U`td}^Td+^`+j{jv+3COZdaw897p_(lS_(LteNm`gUT-hbKS~zwG_TW<0ki` zldtWV8Dz5gNW@{kT{9=UoO`$DkoHE~{hJPjzsW0hxfEO)b>-T9C$8Fw50f;zK3R!O z?JDfKA%0@(;*Xj6cTbuuIo;BhyZZpw*HtbnYOgI?FBG&j?!dOvB&onOjt@w1i ze9Qt*HwS#$>M~8!Z_VVw`@Co0?aE5Mm83s$^+e~rjgcl_6eCZoRwY?qzUBLbqhjI$ z)=Oz!%Pwcm^1I3GFrlXC?}BKj>$RKeZm3>t?B{bau(RyBe?s;_dn7 zbm%BL&#aNN{9`6yuBy1{8rRhGfjgHnvZ(G_!ysa}>kRW0ml8|H01vHIJ`)*Ov)!i7 zmZ)&HTouEXpLqDm`i?VfCz!q%?zO&K@V()VzoNsn39LVkGR8JUHQebq>~VzozxvUP zO&m=Dd;J$nYOQW8D&4%ur`U9F{JGj-BlUN$*0G)1#iQHzT;F{0#jBqeXV2dJv?jcB zO|!#{uLqB2oIQK$#@3G|Tko*)a6CKl>&>UcEvbgLQ=@z4r3`{Q)s`nPBDl-JMhIPA!=r}WF2 ziT>uc{m<>MKYV*s;;zyQv%=T=>h3HtTDGrUTTqK%)MfR-3#?k`D_b1?kKW>$?}At*6r72)oZ+-u%$3E|L3n~ Z{BKgG*`&IS1(;(PJYD@<);T3K0RZlsCe#1` literal 0 HcmV?d00001 diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 5c759a70a7..02886773dd 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -52,7 +52,7 @@ "dist/credentials/CopperApi.credentials.js", "dist/credentials/CalendlyApi.credentials.js", "dist/credentials/CustomerIoApi.credentials.js", - "dist/credentials/CustomS3Endpoint.credentials.js", + "dist/credentials/S3.credentials.js", "dist/credentials/CrateDb.credentials.js", "dist/credentials/DisqusApi.credentials.js", "dist/credentials/DriftApi.credentials.js", From 409b9abfbcce841ccdd13dd27edf680f354f11d2 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 2 Sep 2020 09:23:26 +0200 Subject: [PATCH 13/46] :books: Fix link to docker images --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7fa19c8cbd..24822bb784 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,7 @@ n8n is split up in different modules which are all in a single mono repository. The most important directories: - - [/docker/image](/docker/image) - Dockerfiles to create n8n containers + - [/docker/image](/docker/images) - Dockerfiles to create n8n containers - [/docker/compose](/docker/compose) - Examples Docker Setups - [/packages](/packages) - The different n8n modules - [/packages/cli](/packages/cli) - CLI code to run front- & backend From 89ed3c4a6d2e06caa3fe255d0a7bd1a8cfbf8610 Mon Sep 17 00:00:00 2001 From: Rupenieks <32895755+Rupenieks@users.noreply.github.com> Date: Wed, 2 Sep 2020 10:50:14 +0200 Subject: [PATCH 14/46] :sparkles: Recursive listing for FTP/SFTP (#903) * :zap: Add allowUnauthorizedCerts to Postgres-Node * :zap: Added recursive directory listing for SFTP * :zap: Added recursive listing for FTP * Removed unused imports * :zap: Fixed creating an instance of both ftp/sftp both regardless of which is used Co-authored-by: Jan Oberhauser --- packages/nodes-base/nodes/Ftp.node.ts | 79 ++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 8 deletions(-) diff --git a/packages/nodes-base/nodes/Ftp.node.ts b/packages/nodes-base/nodes/Ftp.node.ts index 49f828f2ed..84106640ac 100644 --- a/packages/nodes-base/nodes/Ftp.node.ts +++ b/packages/nodes-base/nodes/Ftp.node.ts @@ -220,6 +220,21 @@ export class Ftp implements INodeType { description: 'Path of directory to list contents of.', required: true, }, + { + displayName: 'Recursive', + displayOptions: { + show: { + operation: [ + 'list', + ], + }, + }, + name: 'recursive', + type: 'boolean', + default: false, + description: 'Return object representing all directories / objects recursively found within SFTP server', + required: true, + }, ], }; @@ -234,6 +249,7 @@ export class Ftp implements INodeType { let credentials: ICredentialDataDecryptedObject | undefined = undefined; const protocol = this.getNodeParameter('protocol', 0) as string; + if (protocol === 'sftp') { credentials = this.getCredentials('sftp'); } else { @@ -244,11 +260,11 @@ export class Ftp implements INodeType { throw new Error('Failed to get credentials!'); } - let ftp: ftpClient; - let sftp: sftpClient; + let ftp : ftpClient; + let sftp : sftpClient; + if (protocol === 'sftp') { sftp = new sftpClient(); - await sftp.connect({ host: credentials.host as string, port: credentials.port as number, @@ -258,7 +274,6 @@ export class Ftp implements INodeType { } else { ftp = new ftpClient(); - await ftp.connect({ host: credentials.host as string, port: credentials.port as number, @@ -286,8 +301,15 @@ export class Ftp implements INodeType { const path = this.getNodeParameter('path', i) as string; if (operation === 'list') { - responseData = await sftp!.list(path); - returnItems.push.apply(returnItems, this.helpers.returnJsonArray(responseData as unknown as IDataObject[])); + const recursive = this.getNodeParameter('recursive', i) as boolean; + + if (recursive) { + responseData = await callRecursiveList(path, sftp); + returnItems.push.apply(returnItems, this.helpers.returnJsonArray(responseData as unknown as IDataObject[])); + } else { + responseData = await sftp!.list(path); + returnItems.push.apply(returnItems, this.helpers.returnJsonArray(responseData as unknown as IDataObject[])); + } } if (operation === 'download') { @@ -347,8 +369,15 @@ export class Ftp implements INodeType { const path = this.getNodeParameter('path', i) as string; if (operation === 'list') { - responseData = await ftp!.list(path); - returnItems.push.apply(returnItems, this.helpers.returnJsonArray(responseData as unknown as IDataObject[])); + const recursive = this.getNodeParameter('recursive', i) as boolean; + + if (recursive) { + responseData = await callRecursiveList(path, ftp); + returnItems.push.apply(returnItems, this.helpers.returnJsonArray(responseData as unknown as IDataObject[])); + } else { + responseData = await ftp!.list(path); + returnItems.push.apply(returnItems, this.helpers.returnJsonArray(responseData as unknown as IDataObject[])); + } } if (operation === 'download') { @@ -432,3 +461,37 @@ export class Ftp implements INodeType { return [returnItems]; } } +async function callRecursiveList(path: string, client : sftpClient | ftpClient) { + const pathArray : string[] = [path]; + let currentPath = path; + const directoryItems : sftpClient.FileInfo[] = []; + let index = 0; + + do { + // tslint:disable-next-line: array-type + const returnData : sftpClient.FileInfo[] | (string | ftpClient.ListingElement)[] = await client.list(pathArray[index]); + + // @ts-ignore + returnData.map((item : sftpClient.FileInfo) => { + if ((pathArray[index] as string).endsWith('/')) { + currentPath = `${pathArray[index]}${item.name}`; + } else { + currentPath = `${pathArray[index]}/${item.name}`; + } + + // Is directory + if (item.type === 'd') { + pathArray.push(currentPath); + } + + //@ts-ignore + item.path = currentPath; + directoryItems.push(item); + }); + index++; + + } while (index <= pathArray.length - 1); + + + return directoryItems; +} From 0e1a4e53094bdffaa62358c09524a394b3342723 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 2 Sep 2020 10:50:55 +0200 Subject: [PATCH 15/46] :zap: Normalize FTP-Data --- packages/nodes-base/nodes/Ftp.node.ts | 47 ++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/nodes-base/nodes/Ftp.node.ts b/packages/nodes-base/nodes/Ftp.node.ts index 84106640ac..ae44dbc871 100644 --- a/packages/nodes-base/nodes/Ftp.node.ts +++ b/packages/nodes-base/nodes/Ftp.node.ts @@ -17,6 +17,24 @@ import { import * as ftpClient from 'promise-ftp'; import * as sftpClient from 'ssh2-sftp-client'; +interface ReturnFtpItem { + type: string; + name: string; + size: number; + accessTime: Date; + modifyTime: Date; + rights: { + user: string; + group: string; + other: string; + }; + owner: string | number; + group: string | number; + target: string; + sticky?: boolean; + path: string; +} + export class Ftp implements INodeType { description: INodeTypeDescription = { displayName: 'FTP', @@ -304,10 +322,11 @@ export class Ftp implements INodeType { const recursive = this.getNodeParameter('recursive', i) as boolean; if (recursive) { - responseData = await callRecursiveList(path, sftp); + responseData = await callRecursiveList(path, sftp!, normalizeSFtpItem); returnItems.push.apply(returnItems, this.helpers.returnJsonArray(responseData as unknown as IDataObject[])); } else { responseData = await sftp!.list(path); + responseData.forEach(item => normalizeSFtpItem(item as sftpClient.FileInfo, path)); returnItems.push.apply(returnItems, this.helpers.returnJsonArray(responseData as unknown as IDataObject[])); } } @@ -372,10 +391,11 @@ export class Ftp implements INodeType { const recursive = this.getNodeParameter('recursive', i) as boolean; if (recursive) { - responseData = await callRecursiveList(path, ftp); + responseData = await callRecursiveList(path, ftp!, normalizeFtpItem); returnItems.push.apply(returnItems, this.helpers.returnJsonArray(responseData as unknown as IDataObject[])); } else { responseData = await ftp!.list(path); + responseData.forEach(item => normalizeFtpItem(item as ftpClient.ListingElement, path)); returnItems.push.apply(returnItems, this.helpers.returnJsonArray(responseData as unknown as IDataObject[])); } } @@ -461,7 +481,25 @@ export class Ftp implements INodeType { return [returnItems]; } } -async function callRecursiveList(path: string, client : sftpClient | ftpClient) { + + +function normalizeFtpItem(input: ftpClient.ListingElement, path: string) { + const item = input as unknown as ReturnFtpItem; + item.modifyTime = input.date; + item.path = `${path}${path.endsWith('/') ? '' : '/'}${item.name}`; + // @ts-ignore + item.date = undefined; +} + + +function normalizeSFtpItem(input: sftpClient.FileInfo, path: string) { + const item = input as unknown as ReturnFtpItem; + item.accessTime = new Date(input.accessTime); + item.modifyTime = new Date(input.modifyTime); + item.path = `${path}${path.endsWith('/') ? '' : '/'}${item.name}`; +} + +async function callRecursiveList(path: string, client: sftpClient | ftpClient, normalizeFunction: (input: ftpClient.ListingElement & sftpClient.FileInfo, path: string) => void) { const pathArray : string[] = [path]; let currentPath = path; const directoryItems : sftpClient.FileInfo[] = []; @@ -484,8 +522,7 @@ async function callRecursiveList(path: string, client : sftpClient | ftpClient) pathArray.push(currentPath); } - //@ts-ignore - item.path = currentPath; + normalizeFunction(item as ftpClient.ListingElement & sftpClient.FileInfo, currentPath); directoryItems.push(item); }); index++; From 3ea9c860aa9db9492dc39c73cc4611dedaceecf0 Mon Sep 17 00:00:00 2001 From: Rupenieks Date: Wed, 2 Sep 2020 10:53:38 +0200 Subject: [PATCH 16/46] :zap: Trigger checkExists function --- .../nodes-base/nodes/Telegram/TelegramTrigger.node.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts b/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts index bc560c0d5e..ed18704de6 100644 --- a/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts +++ b/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts @@ -112,7 +112,16 @@ export class TelegramTrigger implements INodeType { webhookMethods = { default: { async checkExists(this: IHookFunctions): Promise { - return false; + const endpoint = 'getWebhookInfo'; + const webhookReturnData = await apiRequest.call(this, 'POST', endpoint, {}); + + //https://core.telegram.org/bots/api#webhookinfo + // IF Webhook URL is empty if not setup + if (webhookReturnData.result.url === '') { + return false; + } + + return true; }, async create(this: IHookFunctions): Promise { const webhookUrl = this.getNodeWebhookUrl('default'); From e5a5e1ed11a37022558b3553c9b2f8926c7e7433 Mon Sep 17 00:00:00 2001 From: Rupenieks <32895755+Rupenieks@users.noreply.github.com> Date: Wed, 2 Sep 2020 12:25:11 +0200 Subject: [PATCH 17/46] :sparkles: Add Customer.io-Node (#833) * :construction: Descriptions, node function, generic function changes * :white_check_mark: Finished functionality, added icon * :zap: Added new campaign operations, acommodated for 2 different authentications * :zap: Fixed number defaults not being numbers but empty strings --- .../credentials/CustomerIoApi.credentials.ts | 21 +- .../nodes/CustomerIo/CampaignDescription.ts | 199 ++++++++++ .../nodes/CustomerIo/CustomerDescription.ts | 326 ++++++++++++++++ .../nodes/CustomerIo/CustomerIo.node.ts | 359 ++++++++++++++++++ .../CustomerIo/CustomerIoTrigger.node.ts | 10 +- .../nodes/CustomerIo/EventDescription.ts | 296 +++++++++++++++ .../nodes/CustomerIo/GenericFunctions.ts | 30 +- .../nodes/CustomerIo/SegmentDescription.ts | 71 ++++ .../nodes/CustomerIo/customerio.png | Bin 0 -> 2662 bytes packages/nodes-base/package.json | 1 + 10 files changed, 1301 insertions(+), 12 deletions(-) create mode 100644 packages/nodes-base/nodes/CustomerIo/CampaignDescription.ts create mode 100644 packages/nodes-base/nodes/CustomerIo/CustomerDescription.ts create mode 100644 packages/nodes-base/nodes/CustomerIo/CustomerIo.node.ts create mode 100644 packages/nodes-base/nodes/CustomerIo/EventDescription.ts create mode 100644 packages/nodes-base/nodes/CustomerIo/SegmentDescription.ts create mode 100644 packages/nodes-base/nodes/CustomerIo/customerio.png diff --git a/packages/nodes-base/credentials/CustomerIoApi.credentials.ts b/packages/nodes-base/credentials/CustomerIoApi.credentials.ts index 44cfcefa1a..78a29dd2dc 100644 --- a/packages/nodes-base/credentials/CustomerIoApi.credentials.ts +++ b/packages/nodes-base/credentials/CustomerIoApi.credentials.ts @@ -10,11 +10,26 @@ export class CustomerIoApi implements ICredentialType { documentationUrl = 'customerIo'; properties = [ { - displayName: 'App API Key', - name: 'apiKey', + displayName: 'Tracking API Key', + name: 'trackingApiKey', type: 'string' as NodePropertyTypes, default: '', + description: 'Required for tracking API.', + required: true + }, + { + displayName: 'Tracking Site ID', + name: 'trackingSiteId', + type: 'string' as NodePropertyTypes, + default: '', + description: 'Required for tracking API.' + }, + { + displayName: 'App API Key', + name: 'appApiKey', + type: 'string' as NodePropertyTypes, + default: '', + description: 'Required for App API.' }, - ]; } diff --git a/packages/nodes-base/nodes/CustomerIo/CampaignDescription.ts b/packages/nodes-base/nodes/CustomerIo/CampaignDescription.ts new file mode 100644 index 0000000000..91cf22f737 --- /dev/null +++ b/packages/nodes-base/nodes/CustomerIo/CampaignDescription.ts @@ -0,0 +1,199 @@ +import { INodeProperties } from 'n8n-workflow'; + +export const campaignOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'campaign', + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + }, + { + name: 'Get All', + value: 'getAll', + }, + { + name: 'Get Metrics', + value: 'getMetrics', + }, + ], + default: 'get', + description: 'The operation to perform', + }, +] as INodeProperties[]; + +export const campaignFields = [ +/* -------------------------------------------------------------------------- */ +/* campaign:get */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Campaign ID', + name: 'campaignId', + type: 'number', + required: true, + default: 0, + displayOptions: { + show: { + resource: [ + 'campaign', + ], + operation: [ + 'get' + ] + }, + }, + description: 'The unique identifier for the campaign', + }, +/* -------------------------------------------------------------------------- */ +/* campaign:getMetrics */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Campaign ID', + name: 'campaignId', + type: 'number', + required: true, + default: 0, + displayOptions: { + show: { + resource: [ + 'campaign', + ], + operation: [ + 'getMetrics' + ] + }, + }, + description: 'The unique identifier for the campaign', + }, + { + displayName: 'Period', + name: 'period', + type: 'options', + default: 'days', + displayOptions: { + show: { + resource: [ + 'campaign', + ], + operation: [ + 'getMetrics' + ] + }, + }, + description: 'Specify metric period', + options: [ + { + name: 'Hours', + value: 'hours' + }, + { + name: 'Days', + value: 'days' + }, + { + name: 'Weeks', + value: 'weeks' + }, + { + name: 'Months', + value: 'months' + }, + ] + }, + { + displayName: 'JSON Parameters', + name: 'jsonParameters', + type: 'boolean', + default: false, + description: '', + displayOptions: { + show: { + resource: [ + 'campaign', + ], + operation: [ + 'getMetrics' + ], + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'campaign', + ], + operation: [ + 'getMetrics' + ], + jsonParameters: [ + false, + ], + }, + }, + options: [ + { + displayName: 'Steps', + name: 'steps', + type: 'number', + default: 0, + description: 'Integer specifying how many steps to return. Defaults to the maximum number of timeperiods available, or 12 when using the months period. Maximum timeperiods available are 24 hours, 45 days, 12 weeks and 120 months', + typeOptions: { + minValue: 0, + maxValue: 120 + } + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + default: 'empty', + description: 'Specify metric type', + options: [ + { + name: 'Empty', + value: 'empty' + }, + { + name: 'Email', + value: 'email' + }, + { + name: 'Webhook', + value: 'webhook' + }, + { + name: 'twilio', + value: 'twilio' + }, + { + name: 'Urban Airship', + value: 'urbanAirship' + }, + { + name: 'Slack', + value: 'slack' + }, + { + name: 'Push', + value: 'push' + }, + ] + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/CustomerIo/CustomerDescription.ts b/packages/nodes-base/nodes/CustomerIo/CustomerDescription.ts new file mode 100644 index 0000000000..47d030997f --- /dev/null +++ b/packages/nodes-base/nodes/CustomerIo/CustomerDescription.ts @@ -0,0 +1,326 @@ +import { INodeProperties } from 'n8n-workflow'; + +export const customerOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'customer', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a customer.', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a customer.', + }, + { + name: 'Update', + value: 'update', + description: 'Update a customer.', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const customerFields = [ + +/* -------------------------------------------------------------------------- */ +/* customer:create/delete */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'ID', + name: 'id', + type: 'number', + required: true, + default: 0, + displayOptions: { + show: { + resource: [ + 'customer', + ], + operation: [ + 'create', 'delete' + ] + }, + }, + description: 'The unique identifier for the customer.', + }, + { + displayName: 'Email', + name: 'email', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'customer', + ], + operation: [ + 'create' + ] + }, + }, + description: 'The email address of the user.', + }, + { + displayName: 'Created at', + name: 'createdAt', + type: 'dateTime', + default: '', + displayOptions: { + show: { + resource: [ + 'customer', + ], + operation: [ + 'create' + ] + }, + }, + description: 'The UNIX timestamp from when the user was created.', + }, + { + displayName: 'JSON Parameters', + name: 'jsonParameters', + type: 'boolean', + default: false, + description: '', + displayOptions: { + show: { + resource: [ + 'customer', + ], + operation: [ + 'create' + ], + }, + }, + }, + { + displayName: ' Additional Fields', + name: 'additionalFieldsJson', + type: 'json', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + displayOptions: { + show: { + resource: [ + 'customer', + ], + operation: [ + 'create' + ], + jsonParameters: [ + true, + ], + }, + }, + description: 'Object of values to set as described here.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'customer', + ], + operation: [ + 'create' + ], + jsonParameters: [ + false, + ], + }, + }, + options: [ + { + displayName: 'Custom Properties', + name: 'customProperties', + type: 'fixedCollection', + description: 'Custom Properties', + typeOptions: { + multipleValues: true, + }, + options: [ + { + displayName: 'Property', + name: 'customProperty', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + required: true, + default: '', + description: 'Property name.', + placeholder: 'Plan' + }, + + { + displayName: 'Value', + name: 'value', + type: 'string', + required: true, + default: '', + description: 'Property value.', + placeholder: 'Basic' + }, + ], + }, + ] + }, + ], + }, + +/* -------------------------------------------------------------------------- */ +/* customer:update */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'ID', + name: 'id', + type: 'number', + required: true, + default: 0, + displayOptions: { + show: { + resource: [ + 'customer', + ], + operation: [ + 'update' + ] + }, + }, + description: 'The unique identifier for the customer.', + }, + { + displayName: 'JSON Parameters', + name: 'jsonParameters', + type: 'boolean', + default: false, + description: '', + displayOptions: { + show: { + resource: [ + 'customer', + ], + operation: [ + 'update' + ], + }, + }, + }, + { + displayName: ' Additional Fields', + name: 'additionalFieldsJson', + type: 'json', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + displayOptions: { + show: { + resource: [ + 'customer', + ], + operation: [ + 'update' + ], + jsonParameters: [ + true, + ], + }, + }, + description: 'Object of values to set as described here.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'customer', + ], + operation: [ + 'update' + ], + jsonParameters: [ + false, + ], + }, + }, + options: [ + { + displayName: 'Custom Properties', + name: 'customProperties', + type: 'fixedCollection', + description: 'Custom Properties', + typeOptions: { + multipleValues: true, + }, + options: [ + { + displayName: 'Property', + name: 'customProperty', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + required: true, + default: '', + description: 'Property name.', + placeholder: 'Plan' + }, + + { + displayName: 'Value', + name: 'value', + type: 'string', + required: true, + default: '', + description: 'Property value.', + placeholder: 'Basic' + }, + ], + }, + ] + }, + { + displayName: 'Email', + name: 'email', + type: 'string', + default: '', + description: 'The email address of the user.', + }, + { + displayName: 'Created at', + name: 'createdAt', + type: 'dateTime', + default: '', + description: 'The UNIX timestamp from when the user was created.', + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/CustomerIo/CustomerIo.node.ts b/packages/nodes-base/nodes/CustomerIo/CustomerIo.node.ts new file mode 100644 index 0000000000..f3bd6364c9 --- /dev/null +++ b/packages/nodes-base/nodes/CustomerIo/CustomerIo.node.ts @@ -0,0 +1,359 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; +import { + IDataObject, + INodeTypeDescription, + INodeExecutionData, + INodeType, + ILoadOptionsFunctions, + INodePropertyOptions, +} from 'n8n-workflow'; +import { customerIoApiRequest, validateJSON } from './GenericFunctions'; +import { campaignOperations, campaignFields } from './CampaignDescription'; +import { customerOperations, customerFields } from './CustomerDescription'; +import { eventOperations, eventFields } from './EventDescription'; +import { segmentOperations, segmentFields } from './SegmentDescription'; +import { DateTime } from '../DateTime.node'; + + +export class CustomerIo implements INodeType { + description: INodeTypeDescription = { + displayName: 'Customer.io', + name: 'customerio', + icon: 'file:customerio.png', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Customer.io API', + defaults: { + name: 'CustomerIo', + color: '#ffcd00', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'customerIoApi', + required: true, + } + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Customer', + value: 'customer', + }, + { + name: 'Event', + value: 'event', + }, + { + name: 'Campaign', + value: 'campaign', + }, + { + name: 'Segment', + value: 'segment', + }, + ], + default: 'customer', + description: 'Resource to consume.', + }, + // CAMPAIGN + ...campaignOperations, + ...campaignFields, + // CUSTOMER + ...customerOperations, + ...customerFields, + // EVENT + ...eventOperations, + ...eventFields, + // SEGMENT + ...segmentOperations, + ...segmentFields + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const returnData: IDataObject[] = []; + const items = this.getInputData(); + let responseData; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + const body : IDataObject = {}; + + for (let i = 0; i < items.length; i++) { + + if (resource === 'campaign') { + if (operation === 'get') { + const campaignId = this.getNodeParameter('campaignId', i) as number; + const endpoint = `/campaigns/${campaignId}`; + + responseData = await customerIoApiRequest.call(this, 'GET', endpoint, body, 'beta'); + } + + if (operation === 'getAll') { + const endpoint = `/campaigns`; + + responseData = await customerIoApiRequest.call(this, 'GET', endpoint, body, 'beta'); + responseData = responseData.campaigns; + } + + if (operation === 'getMetrics') { + const campaignId = this.getNodeParameter('campaignId', i) as number; + const jsonParameters = this.getNodeParameter('jsonParameters', i) as boolean; + + if (jsonParameters) { + const additionalFieldsJson = this.getNodeParameter('additionalFieldsJson', i) as string; + + if (additionalFieldsJson !== '') { + + if (validateJSON(additionalFieldsJson) !== undefined) { + + Object.assign(body, JSON.parse(additionalFieldsJson)); + + } else { + throw new Error('Additional fields must be a valid JSON'); + } + } + } else { + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + const period = this.getNodeParameter('period', i) as string; + let endpoint = `/campaigns/${campaignId}/metrics`; + + if (period !== 'days') { + endpoint = `${endpoint}?period=${period}`; + } + if (additionalFields.steps) { + body.steps = additionalFields.steps as number; + } + if (additionalFields.type) { + if (additionalFields.type === 'urbanAirship') { + additionalFields.type = 'urban_airship'; + } else { + body.type = additionalFields.type as string; + } + } + + responseData = await customerIoApiRequest.call(this, 'GET', endpoint, body, 'beta'); + responseData = responseData.metric; + } + } + } + + if (resource === 'customer') { + if (operation === 'create') { + const id = this.getNodeParameter('id', i) as number; + const email = this.getNodeParameter('email', i) as string; + const createdAt = this.getNodeParameter('createdAt', i) as string; + const jsonParameters = this.getNodeParameter('jsonParameters', i) as boolean; + + body.email = email; + body.created_at = new Date(createdAt).getTime() / 1000; + + if (jsonParameters) { + const additionalFieldsJson = this.getNodeParameter('additionalFieldsJson', i) as string; + + if (additionalFieldsJson !== '') { + + if (validateJSON(additionalFieldsJson) !== undefined) { + + Object.assign(body, JSON.parse(additionalFieldsJson)); + + } else { + throw new Error('Additional fields must be a valid JSON'); + } + } + } else { + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (additionalFields.customProperties) { + const data : any = {}; + //@ts-ignore + additionalFields.customProperties.customProperty.map(property => { + data[property.key] = property.value; + }); + + body.data = data; + } + } + + const endpoint = `/customers/${id}`; + + responseData = await customerIoApiRequest.call(this, 'PUT', endpoint, body, 'tracking'); + } + + if (operation === 'update') { + const id = this.getNodeParameter('id', i) as number; + const jsonParameters = this.getNodeParameter('jsonParameters', i) as boolean; + + if (jsonParameters) { + const additionalFieldsJson = this.getNodeParameter('additionalFieldsJson', i) as string; + + if (additionalFieldsJson !== '') { + + if (validateJSON(additionalFieldsJson) !== undefined) { + + Object.assign(body, JSON.parse(additionalFieldsJson)); + + } else { + throw new Error('Additional fields must be a valid JSON'); + } + } + } else { + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (additionalFields.customProperties) { + const data : any = {}; + //@ts-ignore + additionalFields.customProperties.customProperty.map(property => { + data[property.key] = property.value; + }); + + body.data = data; + } + + if (additionalFields.email) { + body.email = additionalFields.email as string; + } + + if (additionalFields.createdAt) { + body.created_at = new Date(additionalFields.createdAt as string).getTime() / 1000; + } + } + + const endpoint = `/customers/${id}`; + + responseData = await customerIoApiRequest.call(this, 'PUT', endpoint, body, 'tracking'); + } + + if (operation === 'delete') { + const id = this.getNodeParameter('id', i) as number; + + body.id = id; + + const endpoint = `/customers/${id}`; + + responseData = await customerIoApiRequest.call(this, 'DELETE', endpoint, body, 'tracking'); + } + } + + if (resource === 'event') { + if (operation === 'track') { + const id = this.getNodeParameter('id', i) as number; + const name = this.getNodeParameter('name', i) as string; + const jsonParameters = this.getNodeParameter('jsonParameters', i) as boolean; + + body.name = name; + + if (jsonParameters) { + const additionalFieldsJson = this.getNodeParameter('additionalFieldsJson', i) as string; + + if (additionalFieldsJson !== '') { + + if (validateJSON(additionalFieldsJson) !== undefined) { + + Object.assign(body, JSON.parse(additionalFieldsJson)); + + } else { + throw new Error('Additional fields must be a valid JSON'); + } + } + } else { + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + const data : any = {}; + + if (additionalFields.customAttributes) { + //@ts-ignore + additionalFields.customAttributes.customAttribute.map(property => { + data[property.key] = property.value; + }); + } + + if (additionalFields.type) { + data.type = additionalFields.type as string; + } + + body.data = data; + } + + const endpoint = `/customers/${id}/events`; + + responseData = await customerIoApiRequest.call(this, 'POST', endpoint, body, 'tracking'); + } + + if (operation === 'trackAnonymous') { + const name = this.getNodeParameter('name', i) as string; + const jsonParameters = this.getNodeParameter('jsonParameters', i) as boolean; + + body.name = name; + + if (jsonParameters) { + const additionalFieldsJson = this.getNodeParameter('additionalFieldsJson', i) as string; + + if (additionalFieldsJson !== '') { + + if (validateJSON(additionalFieldsJson) !== undefined) { + + Object.assign(body, JSON.parse(additionalFieldsJson)); + + } else { + throw new Error('Additional fields must be a valid JSON'); + } + } + } else { + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + const data : any = {}; + + if (additionalFields.customAttributes) { + //@ts-ignore + additionalFields.customAttributes.customAttribute.map(property => { + data[property.key] = property.value; + }); + } + body.data = data; + } + + const endpoint = `/events`; + responseData = await customerIoApiRequest.call(this, 'POST', endpoint, body, 'tracking'); + } + } + + if (resource === 'segment') { + const id = this.getNodeParameter('id', i) as number; + const ids = this.getNodeParameter('ids', i) as string; + const idArray : string[] = []; + + ids.split(',').map(id => { + idArray.push(id); + }); + + body.id = id; + body.ids = idArray; + + let endpoint = ``; + + if (operation === 'add') { + endpoint = `/segments/${id}/add_customers`; + } else { + endpoint = `/segments/${id}/remove_customers`; + } + + responseData = await customerIoApiRequest.call(this, 'POST', endpoint, body, 'tracking'); + } + + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else { + returnData.push(responseData as unknown as IDataObject); + } + } + + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/CustomerIo/CustomerIoTrigger.node.ts b/packages/nodes-base/nodes/CustomerIo/CustomerIoTrigger.node.ts index 875e77bd3e..08990d17b8 100644 --- a/packages/nodes-base/nodes/CustomerIo/CustomerIoTrigger.node.ts +++ b/packages/nodes-base/nodes/CustomerIo/CustomerIoTrigger.node.ts @@ -11,7 +11,7 @@ import { } from 'n8n-workflow'; import { - apiRequest, + customerIoApiRequest, eventExists, } from './GenericFunctions'; @@ -34,7 +34,7 @@ export class CustomerIoTrigger implements INodeType { description: 'Starts the workflow on a Customer.io update. (Beta)', defaults: { name: 'Customer.io Trigger', - color: '#7131ff', + color: '#ffcd00', }, inputs: [], outputs: ['main'], @@ -237,7 +237,7 @@ export class CustomerIoTrigger implements INodeType { const endpoint = '/reporting_webhooks'; - let { reporting_webhooks: webhooks } = await apiRequest.call(this, 'GET', endpoint, {}); + let { reporting_webhooks: webhooks } = await customerIoApiRequest.call(this, 'GET', endpoint, {}, 'beta'); if (webhooks === null) { webhooks = []; @@ -295,7 +295,7 @@ export class CustomerIoTrigger implements INodeType { events: data, }; - webhook = await apiRequest.call(this, 'POST', endpoint, body); + webhook = await customerIoApiRequest.call(this, 'POST', endpoint, body, 'beta'); const webhookData = this.getWorkflowStaticData('node'); webhookData.webhookId = webhook.id as string; @@ -307,7 +307,7 @@ export class CustomerIoTrigger implements INodeType { if (webhookData.webhookId !== undefined) { const endpoint = `/reporting_webhooks/${webhookData.webhookId}`; try { - await apiRequest.call(this, 'DELETE', endpoint, {}); + await customerIoApiRequest.call(this, 'DELETE', endpoint, {}, 'beta'); } catch (e) { return false; } diff --git a/packages/nodes-base/nodes/CustomerIo/EventDescription.ts b/packages/nodes-base/nodes/CustomerIo/EventDescription.ts new file mode 100644 index 0000000000..7b57004a9e --- /dev/null +++ b/packages/nodes-base/nodes/CustomerIo/EventDescription.ts @@ -0,0 +1,296 @@ +import { INodeProperties } from 'n8n-workflow'; + +export const eventOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'event', + ], + }, + }, + options: [ + { + name: 'Track', + value: 'track', + description: 'Track a customer event.', + }, + { + name: 'Track Anonymous', + value: 'trackAnonymous', + description: 'Track an anonymous event.', + }, + ], + default: 'track', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const eventFields = [ + +/* -------------------------------------------------------------------------- */ +/* event:track */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'ID', + name: 'id', + type: 'number', + required: true, + default: 0, + displayOptions: { + show: { + resource: [ + 'event', + ], + operation: [ + 'track' + ] + }, + }, + description: 'The unique identifier for the customer.', + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'event', + ], + operation: [ + 'track' + ] + }, + }, + description: 'Name of the event to track.', + }, + { + displayName: 'JSON Parameters', + name: 'jsonParameters', + type: 'boolean', + default: false, + description: '', + displayOptions: { + show: { + resource: [ + 'event', + ], + operation: [ + 'track' + ], + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFieldsJson', + type: 'json', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + displayOptions: { + show: { + resource: [ + 'event', + ], + operation: [ + 'track' + ], + jsonParameters: [ + true, + ], + }, + }, + description: 'Object of values to set as described here.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'event', + ], + operation: [ + 'track' + ], + jsonParameters: [ + false + ] + }, + }, + options: [ + { + displayName: 'Custom Attributes', + name: 'customAttributes', + type: 'fixedCollection', + description: 'Custom Properties', + typeOptions: { + multipleValues: true, + }, + options: [ + { + displayName: 'Attribute', + name: 'customAttribute', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + required: true, + default: '', + description: 'Attribute name.', + placeholder: 'Price' + }, + + { + displayName: 'Value', + name: 'value', + type: 'string', + required: true, + default: '', + description: 'Attribute value.', + placeholder: '25.50' + }, + ], + }, + ] + }, + { + displayName: 'Type', + name: 'type', + type: 'string', + default: '', + description: 'Used to change event type. For Page View events set to "page".', + }, + ], + }, +/* -------------------------------------------------------------------------- */ +/* event:track anonymous */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Name', + name: 'name', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'event', + ], + operation: [ + 'trackAnonymous' + ] + }, + }, + description: 'The unique identifier for the customer.', + }, + { + displayName: 'JSON Parameters', + name: 'jsonParameters', + type: 'boolean', + default: false, + description: '', + displayOptions: { + show: { + resource: [ + 'event', + ], + operation: [ + 'trackAnonymous' + ], + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFieldsJson', + type: 'json', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + displayOptions: { + show: { + resource: [ + 'event', + ], + operation: [ + 'trackAnonymous' + ], + jsonParameters: [ + true, + ], + }, + }, + description: 'Object of values to set as described here.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'event', + ], + operation: [ + 'trackAnonymous' + ], + jsonParameters: [ + false + ] + }, + }, + options: [ + { + displayName: 'Custom Attributes', + name: 'customAttributes', + type: 'fixedCollection', + description: 'Custom Properties', + typeOptions: { + multipleValues: true, + }, + options: [ + { + displayName: 'Attribute', + name: 'customAttribute', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + required: true, + default: '', + description: 'Attribute name.', + placeholder: 'Price' + }, + + { + displayName: 'Value', + name: 'value', + type: 'string', + required: true, + default: '', + description: 'Attribute value.', + placeholder: '25.50' + }, + ], + }, + ] + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/CustomerIo/GenericFunctions.ts b/packages/nodes-base/nodes/CustomerIo/GenericFunctions.ts index ba6a388acb..9cea18a4c9 100644 --- a/packages/nodes-base/nodes/CustomerIo/GenericFunctions.ts +++ b/packages/nodes-base/nodes/CustomerIo/GenericFunctions.ts @@ -16,7 +16,7 @@ import { get, } from 'lodash'; -export async function apiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: object, query?: IDataObject): Promise { // tslint:disable-line:no-any +export async function customerIoApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: object, baseApi? : string, query?: IDataObject): Promise { // tslint:disable-line:no-any const credentials = this.getCredentials('customerIoApi'); if (credentials === undefined) { @@ -28,14 +28,26 @@ export async function apiRequest(this: IHookFunctions | IExecuteFunctions | ILoa const options: OptionsWithUri = { headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${credentials.apiKey}`, }, method, body, - qs: query, - uri: `https://beta-api.customer.io/v1/api${endpoint}`, + uri: '', json: true, }; + + if (baseApi === 'tracking') { + options.uri = `https://track.customer.io/api/v1${endpoint}`; + const basicAuthKey = Buffer.from(`${credentials.trackingSiteId}:${credentials.trackingApiKey}`).toString('base64'); + Object.assign(options.headers, {'Authorization': `Basic ${basicAuthKey}`}); + } else if (baseApi === 'api') { + options.uri = `https://api.customer.io/v1/api${endpoint}`; + const basicAuthKey = Buffer.from(`${credentials.trackingSiteId}:${credentials.trackingApiKey}`).toString('base64'); + Object.assign(options.headers, {'Authorization': `Basic ${basicAuthKey}`}); + } else if (baseApi === 'beta') { + options.uri = `https://beta-api.customer.io/v1/api${endpoint}`; + Object.assign(options.headers, {'Authorization': `Bearer ${credentials.appApiKey as string}`}); + } + try { return await this.helpers.request!(options); } catch (error) { @@ -63,3 +75,13 @@ export function eventExists(currentEvents: string[], webhookEvents: IDataObject) } return true; } + +export function validateJSON(json: string | undefined): any { // tslint:disable-line:no-any + let result; + try { + result = JSON.parse(json!); + } catch (exception) { + result = undefined; + } + return result; +} diff --git a/packages/nodes-base/nodes/CustomerIo/SegmentDescription.ts b/packages/nodes-base/nodes/CustomerIo/SegmentDescription.ts new file mode 100644 index 0000000000..a968790882 --- /dev/null +++ b/packages/nodes-base/nodes/CustomerIo/SegmentDescription.ts @@ -0,0 +1,71 @@ +import { INodeProperties } from 'n8n-workflow'; + +export const segmentOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'segment', + ], + }, + }, + options: [ + { + name: 'Add Customer', + value: 'add', + }, + { + name: 'Remove Customer', + value: 'remove', + }, + ], + default: 'add', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const segmentFields = [ + +/* -------------------------------------------------------------------------- */ +/* segment:add */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'ID', + name: 'id', + type: 'number', + required: true, + default: 0, + displayOptions: { + show: { + resource: [ + 'segment', + ], + operation: [ + 'add', 'remove' + ] + }, + }, + description: 'The unique identifier of the segment.', + }, + { + displayName: 'IDs', + name: 'ids', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'segment', + ], + operation: [ + 'add', 'remove' + ] + }, + }, + description: 'A list of customer ids to add to the segment.', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/CustomerIo/customerio.png b/packages/nodes-base/nodes/CustomerIo/customerio.png new file mode 100644 index 0000000000000000000000000000000000000000..8ff1d817bf69f36ef80b8a87b28236a90c55b910 GIT binary patch literal 2662 zcmV-s3YqnZP)004R>004l5008;`004mK004C`008P>0026e000+ooVrmw00006 zVoOIv0RI600RN!9r;`8x00(qQO+^Rf2nz`cE{~YuJ^%m-YDq*vRA}Dqnr)0-MH$C` z&&)aZb>F(XrQK~yx0Jq6p}ZJkp=rSYwHkyFNzs@nVgey*1VYjX(h!vZK@zA+qJbDA z3egll6{A3TX*GywEh)4>so<7&E8E?+d-vtub7sa5=iIyZ?w)P;?k*rnpPUbS&OK-5 zH~)F&nP+BJoO68AOnuw{r=b6z?G*G!W!{HSrlmR}iOje(GYf42P{{aZfXo>BOhG$v zv7)6z4DX`!0XY12aw|#AN1`SP;~MmFK|7p9G()@Sd4PdG5%dx{h=4bX#&yj79{F?2 zT`^s5nf8oW*0|@#9J~uNf)XTh<>*I;b*MC*o9MV>M)aOOv;{@?tsHs?Pa%S{P+g`V z3g-wdbH2mE&ETd9J-xx<3@Cc;;m||)9ypw-C1&)Y?{WAi9K06f9M5QT%jC#F;F)Sw-raUlcS5U6WZIERA47qKIgbE=a; zy9urYXrS0l={*#6R_qWc9iVhzw5ajLpAe!3T9PinjQJ>%Hhdp*d=f!pJdt1qDefOF zs`6W9#!TcWTg@~+=tApU%^QxaXit2ZacIox1>gIoiBe_FP}p|HP$>3_WhZizd~um8!c57;V{`* zH3g^&GLR7uM4_rKQKFUjcMt7$@#ohqGJkpqw(SK4a|#ET~YW^F(!fohWG5SX>0s$>d13>`sv$z$6& zvaK~wRJqRpmgt;_Hn{Q9X#{7V4f@K0DS+n7X;_POh@PCx8e_??rg>dlhy&<-i_+i( z*?>cPG5M4wCKS%-E`n7>$y zpDRO`YZz79L7PgQrE@r&yq*^QYIuA{&{w$yOVee{y_3))`h?tqc!ZWYx6`~HETW^9 z9_yy}Exb&Emx*9Qa_!{W`C~=s1sDYdp^= zm*6D^8r{!#_FXyPE?6hVpfxd#Gl(L)ob2+ko#ad6Z~Ip@h+WcPNbHP%$}U>z&!3@*flI1>vj znrPo9M0vdXFMfF&yWeKGgjNZ2iU_7i^8)6c8{a1Tbj1OHn=%U+FSElbr;znOIJ;wC zek9`_`?kEi1*Ji%(4!a<4@4bR;T+ZgD34vw@W_o6hY4Z#+ZclyKY#=iuy8G23TMX# zq_M9@>Ry|kv&w6>F02+?oRAMc`|z?Q&$kaXI$sHjU*ExR?quKyN+S}(S%bA$8~+`r zqMG3Wp1O}mze}lzmqH6i2iW`Xu|kSqN;G!RwI)Gk5;toMP9s~2J=YHGQsPDNTN$w} zr3-$$=!$!n7aN^eiHj44j?p%c)f>3*D%$5KJLl2E{NrhMJVDQXvdyEbVG)9m#q(Lb zfFO+dL{Tbm!3`|FEO|>Od50QX`Y)Q;X@GO%S3q!7y!Sa><<& zgu;oSElweOP2u8Wuk?qOylvl~zwYJ>GsjXU9m_nXL!%6{G=dF4;imz@BG#fk{7mef z6XkJAao#dong~O1tldc4(g}CYlJk^k!dtEz9>^>qUCJHzue@tz{zxj!BoI>`XG)W8 zAm2*91wW0SCf7nkE7?Z07dv-ybqZs6{T+%Sc7$bLJQ4APO2>LTlyK9)>+js!deM%K zfkvmy4D914a|jkPj?aIUwYNo&CVcrsJ5146n=>n}9+}%~@#|Wl)M-jn8l+(XpZ;cL z2{tuo!8y{teEnhRTEXB^^oMa62HIojIJ4(*<7VG`3l!;B%Q0{XrPYc3+Lj^{5?DP!~Jx> zK`M`*#<@DyzN@bl!FX>5&C zk;trxJ$uSF{S#Xt&V){Y5;ZTDcG?r`%s^Ms#iOuto?jV-GiWmn{gK!w^520@L7##? zf&C910w=n*-_gzh001R)MObuXVRU6WV{&C-bY%cCFfuSLFgPtSF;p-)Ix#RhG&U_T zG&(Ra)JeMs0000bbVXQnWMOn=I&E)cX=ZrsyIXW>gIy5#dFf=+a UFr^d&y8r+H07*qoM6N<$f-!mH*8l(j literal 0 HcmV?d00001 diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 02886773dd..4cb5b4a2cd 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -217,6 +217,7 @@ "dist/nodes/CrateDb/CrateDb.node.js", "dist/nodes/Cron.node.js", "dist/nodes/Crypto.node.js", + "dist/nodes/CustomerIo/CustomerIo.node.js", "dist/nodes/CustomerIo/CustomerIoTrigger.node.js", "dist/nodes/DateTime.node.js", "dist/nodes/Discord/Discord.node.js", From 512fe4ea70acdc2b74b96adf869b3ea3f28537be Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 2 Sep 2020 12:32:12 +0200 Subject: [PATCH 18/46] :zap: Some improvements to Customer.io Node --- .../credentials/CustomerIoApi.credentials.ts | 6 +- .../nodes/CustomerIo/CampaignDescription.ts | 128 ++++++------ .../nodes/CustomerIo/CustomerDescription.ts | 187 +++--------------- .../nodes/CustomerIo/CustomerIo.node.ts | 138 +++++-------- .../nodes/CustomerIo/EventDescription.ts | 47 +++-- .../nodes/CustomerIo/GenericFunctions.ts | 8 +- .../nodes/CustomerIo/SegmentDescription.ts | 14 +- 7 files changed, 180 insertions(+), 348 deletions(-) diff --git a/packages/nodes-base/credentials/CustomerIoApi.credentials.ts b/packages/nodes-base/credentials/CustomerIoApi.credentials.ts index 78a29dd2dc..70387fd066 100644 --- a/packages/nodes-base/credentials/CustomerIoApi.credentials.ts +++ b/packages/nodes-base/credentials/CustomerIoApi.credentials.ts @@ -15,21 +15,21 @@ export class CustomerIoApi implements ICredentialType { type: 'string' as NodePropertyTypes, default: '', description: 'Required for tracking API.', - required: true + required: true, }, { displayName: 'Tracking Site ID', name: 'trackingSiteId', type: 'string' as NodePropertyTypes, default: '', - description: 'Required for tracking API.' + description: 'Required for tracking API.', }, { displayName: 'App API Key', name: 'appApiKey', type: 'string' as NodePropertyTypes, default: '', - description: 'Required for App API.' + description: 'Required for App API.', }, ]; } diff --git a/packages/nodes-base/nodes/CustomerIo/CampaignDescription.ts b/packages/nodes-base/nodes/CustomerIo/CampaignDescription.ts index 91cf22f737..0893a9f219 100644 --- a/packages/nodes-base/nodes/CustomerIo/CampaignDescription.ts +++ b/packages/nodes-base/nodes/CustomerIo/CampaignDescription.ts @@ -32,9 +32,9 @@ export const campaignOperations = [ ] as INodeProperties[]; export const campaignFields = [ -/* -------------------------------------------------------------------------- */ -/* campaign:get */ -/* -------------------------------------------------------------------------- */ + /* -------------------------------------------------------------------------- */ + /* campaign:get */ + /* -------------------------------------------------------------------------- */ { displayName: 'Campaign ID', name: 'campaignId', @@ -47,15 +47,15 @@ export const campaignFields = [ 'campaign', ], operation: [ - 'get' + 'get', ] }, }, description: 'The unique identifier for the campaign', }, -/* -------------------------------------------------------------------------- */ -/* campaign:getMetrics */ -/* -------------------------------------------------------------------------- */ + /* -------------------------------------------------------------------------- */ + /* campaign:getMetrics */ + /* -------------------------------------------------------------------------- */ { displayName: 'Campaign ID', name: 'campaignId', @@ -68,7 +68,7 @@ export const campaignFields = [ 'campaign', ], operation: [ - 'getMetrics' + 'getMetrics', ] }, }, @@ -85,7 +85,7 @@ export const campaignFields = [ 'campaign', ], operation: [ - 'getMetrics' + 'getMetrics', ] }, }, @@ -93,19 +93,19 @@ export const campaignFields = [ options: [ { name: 'Hours', - value: 'hours' + value: 'hours', }, { name: 'Days', - value: 'days' + value: 'days', }, { name: 'Weeks', - value: 'weeks' + value: 'weeks', }, { name: 'Months', - value: 'months' + value: 'months', }, ] }, @@ -121,7 +121,7 @@ export const campaignFields = [ 'campaign', ], operation: [ - 'getMetrics' + 'getMetrics', ], }, }, @@ -146,54 +146,54 @@ export const campaignFields = [ }, }, options: [ - { - displayName: 'Steps', - name: 'steps', - type: 'number', - default: 0, - description: 'Integer specifying how many steps to return. Defaults to the maximum number of timeperiods available, or 12 when using the months period. Maximum timeperiods available are 24 hours, 45 days, 12 weeks and 120 months', - typeOptions: { - minValue: 0, - maxValue: 120 - } - }, - { - displayName: 'Type', - name: 'type', - type: 'options', - default: 'empty', - description: 'Specify metric type', - options: [ - { - name: 'Empty', - value: 'empty' - }, - { - name: 'Email', - value: 'email' - }, - { - name: 'Webhook', - value: 'webhook' - }, - { - name: 'twilio', - value: 'twilio' - }, - { - name: 'Urban Airship', - value: 'urbanAirship' - }, - { - name: 'Slack', - value: 'slack' - }, - { - name: 'Push', - value: 'push' - }, - ] - }, - ], - }, + { + displayName: 'Steps', + name: 'steps', + type: 'number', + default: 0, + description: 'Integer specifying how many steps to return. Defaults to the maximum number of timeperiods available, or 12 when using the months period. Maximum timeperiods available are 24 hours, 45 days, 12 weeks and 120 months', + typeOptions: { + minValue: 0, + maxValue: 120, + } + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + default: 'empty', + description: 'Specify metric type', + options: [ + { + name: 'Empty', + value: 'empty', + }, + { + name: 'Email', + value: 'email', + }, + { + name: 'Push', + value: 'push', + }, + { + name: 'Slack', + value: 'slack', + }, + { + name: 'twilio', + value: 'twilio', + }, + { + name: 'Urban Airship', + value: 'urbanAirship', + }, + { + name: 'Webhook', + value: 'webhook', + }, + ] + }, + ], + }, ] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/CustomerIo/CustomerDescription.ts b/packages/nodes-base/nodes/CustomerIo/CustomerDescription.ts index 47d030997f..a6a9576a92 100644 --- a/packages/nodes-base/nodes/CustomerIo/CustomerDescription.ts +++ b/packages/nodes-base/nodes/CustomerIo/CustomerDescription.ts @@ -14,53 +14,31 @@ export const customerOperations = [ }, options: [ { - name: 'Create', - value: 'create', - description: 'Create a customer.', + name: 'Create/Update', + value: 'upsert', + description: 'Create/Update a customer.', }, { name: 'Delete', value: 'delete', description: 'Delete a customer.', - }, - { - name: 'Update', - value: 'update', - description: 'Update a customer.', }, ], - default: 'create', + default: 'upsert', description: 'The operation to perform.', }, ] as INodeProperties[]; export const customerFields = [ -/* -------------------------------------------------------------------------- */ -/* customer:create/delete */ -/* -------------------------------------------------------------------------- */ + /* -------------------------------------------------------------------------- */ + /* customer:delete */ + /* -------------------------------------------------------------------------- */ { displayName: 'ID', name: 'id', - type: 'number', - required: true, - default: 0, - displayOptions: { - show: { - resource: [ - 'customer', - ], - operation: [ - 'create', 'delete' - ] - }, - }, - description: 'The unique identifier for the customer.', - }, - { - displayName: 'Email', - name: 'email', type: 'string', + required: true, default: '', displayOptions: { show: { @@ -68,144 +46,29 @@ export const customerFields = [ 'customer', ], operation: [ - 'create' + 'delete', ] }, }, - description: 'The email address of the user.', - }, - { - displayName: 'Created at', - name: 'createdAt', - type: 'dateTime', - default: '', - displayOptions: { - show: { - resource: [ - 'customer', - ], - operation: [ - 'create' - ] - }, - }, - description: 'The UNIX timestamp from when the user was created.', - }, - { - displayName: 'JSON Parameters', - name: 'jsonParameters', - type: 'boolean', - default: false, - description: '', - displayOptions: { - show: { - resource: [ - 'customer', - ], - operation: [ - 'create' - ], - }, - }, - }, - { - displayName: ' Additional Fields', - name: 'additionalFieldsJson', - type: 'json', - typeOptions: { - alwaysOpenEditWindow: true, - }, - default: '', - displayOptions: { - show: { - resource: [ - 'customer', - ], - operation: [ - 'create' - ], - jsonParameters: [ - true, - ], - }, - }, - description: 'Object of values to set as described here.', - }, - { - displayName: 'Additional Fields', - name: 'additionalFields', - type: 'collection', - placeholder: 'Add Field', - default: {}, - displayOptions: { - show: { - resource: [ - 'customer', - ], - operation: [ - 'create' - ], - jsonParameters: [ - false, - ], - }, - }, - options: [ - { - displayName: 'Custom Properties', - name: 'customProperties', - type: 'fixedCollection', - description: 'Custom Properties', - typeOptions: { - multipleValues: true, - }, - options: [ - { - displayName: 'Property', - name: 'customProperty', - values: [ - { - displayName: 'Key', - name: 'key', - type: 'string', - required: true, - default: '', - description: 'Property name.', - placeholder: 'Plan' - }, - - { - displayName: 'Value', - name: 'value', - type: 'string', - required: true, - default: '', - description: 'Property value.', - placeholder: 'Basic' - }, - ], - }, - ] - }, - ], + description: 'The unique identifier for the customer.', }, -/* -------------------------------------------------------------------------- */ -/* customer:update */ -/* -------------------------------------------------------------------------- */ + /* -------------------------------------------------------------------------- */ + /* customer:upsert */ + /* -------------------------------------------------------------------------- */ { displayName: 'ID', name: 'id', - type: 'number', + type: 'string', required: true, - default: 0, + default: '', displayOptions: { show: { resource: [ 'customer', ], operation: [ - 'update' + 'upsert', ] }, }, @@ -223,7 +86,7 @@ export const customerFields = [ 'customer', ], operation: [ - 'update' + 'upsert', ], }, }, @@ -242,7 +105,7 @@ export const customerFields = [ 'customer', ], operation: [ - 'update' + 'upsert', ], jsonParameters: [ true, @@ -263,7 +126,7 @@ export const customerFields = [ 'customer', ], operation: [ - 'update' + 'upsert', ], jsonParameters: [ false, @@ -290,18 +153,18 @@ export const customerFields = [ type: 'string', required: true, default: '', - description: 'Property name.', - placeholder: 'Plan' + description: 'Property name.', + placeholder: 'Plan', }, { displayName: 'Value', name: 'value', - type: 'string', - required: true, + type: 'string', + required: true, default: '', - description: 'Property value.', - placeholder: 'Basic' + description: 'Property value.', + placeholder: 'Basic', }, ], }, diff --git a/packages/nodes-base/nodes/CustomerIo/CustomerIo.node.ts b/packages/nodes-base/nodes/CustomerIo/CustomerIo.node.ts index f3bd6364c9..f664820bbc 100644 --- a/packages/nodes-base/nodes/CustomerIo/CustomerIo.node.ts +++ b/packages/nodes-base/nodes/CustomerIo/CustomerIo.node.ts @@ -6,21 +6,18 @@ import { INodeTypeDescription, INodeExecutionData, INodeType, - ILoadOptionsFunctions, - INodePropertyOptions, } from 'n8n-workflow'; import { customerIoApiRequest, validateJSON } from './GenericFunctions'; import { campaignOperations, campaignFields } from './CampaignDescription'; import { customerOperations, customerFields } from './CustomerDescription'; import { eventOperations, eventFields } from './EventDescription'; import { segmentOperations, segmentFields } from './SegmentDescription'; -import { DateTime } from '../DateTime.node'; export class CustomerIo implements INodeType { description: INodeTypeDescription = { displayName: 'Customer.io', - name: 'customerio', + name: 'customerIo', icon: 'file:customerio.png', group: ['output'], version: 1, @@ -64,29 +61,29 @@ export class CustomerIo implements INodeType { default: 'customer', description: 'Resource to consume.', }, - // CAMPAIGN - ...campaignOperations, - ...campaignFields, - // CUSTOMER - ...customerOperations, - ...customerFields, - // EVENT - ...eventOperations, - ...eventFields, - // SEGMENT - ...segmentOperations, - ...segmentFields + // CAMPAIGN + ...campaignOperations, + ...campaignFields, + // CUSTOMER + ...customerOperations, + ...customerFields, + // EVENT + ...eventOperations, + ...eventFields, + // SEGMENT + ...segmentOperations, + ...segmentFields ], }; async execute(this: IExecuteFunctions): Promise { const returnData: IDataObject[] = []; const items = this.getInputData(); - let responseData; const resource = this.getNodeParameter('resource', 0) as string; const operation = this.getNodeParameter('operation', 0) as string; - const body : IDataObject = {}; + const body: IDataObject = {}; + let responseData; for (let i = 0; i < items.length; i++) { if (resource === 'campaign') { @@ -95,6 +92,7 @@ export class CustomerIo implements INodeType { const endpoint = `/campaigns/${campaignId}`; responseData = await customerIoApiRequest.call(this, 'GET', endpoint, body, 'beta'); + responseData = responseData.campaign; } if (operation === 'getAll') { @@ -147,48 +145,8 @@ export class CustomerIo implements INodeType { } if (resource === 'customer') { - if (operation === 'create') { - const id = this.getNodeParameter('id', i) as number; - const email = this.getNodeParameter('email', i) as string; - const createdAt = this.getNodeParameter('createdAt', i) as string; - const jsonParameters = this.getNodeParameter('jsonParameters', i) as boolean; - body.email = email; - body.created_at = new Date(createdAt).getTime() / 1000; - - if (jsonParameters) { - const additionalFieldsJson = this.getNodeParameter('additionalFieldsJson', i) as string; - - if (additionalFieldsJson !== '') { - - if (validateJSON(additionalFieldsJson) !== undefined) { - - Object.assign(body, JSON.parse(additionalFieldsJson)); - - } else { - throw new Error('Additional fields must be a valid JSON'); - } - } - } else { - const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; - - if (additionalFields.customProperties) { - const data : any = {}; - //@ts-ignore - additionalFields.customProperties.customProperty.map(property => { - data[property.key] = property.value; - }); - - body.data = data; - } - } - - const endpoint = `/customers/${id}`; - - responseData = await customerIoApiRequest.call(this, 'PUT', endpoint, body, 'tracking'); - } - - if (operation === 'update') { + if (operation === 'upsert') { const id = this.getNodeParameter('id', i) as number; const jsonParameters = this.getNodeParameter('jsonParameters', i) as boolean; @@ -209,7 +167,7 @@ export class CustomerIo implements INodeType { const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; if (additionalFields.customProperties) { - const data : any = {}; + const data: any = {}; // tslint:disable-line:no-any //@ts-ignore additionalFields.customProperties.customProperty.map(property => { data[property.key] = property.value; @@ -230,6 +188,8 @@ export class CustomerIo implements INodeType { const endpoint = `/customers/${id}`; responseData = await customerIoApiRequest.call(this, 'PUT', endpoint, body, 'tracking'); + + responseData = Object.assign({ id }, body); } if (operation === 'delete') { @@ -239,17 +199,21 @@ export class CustomerIo implements INodeType { const endpoint = `/customers/${id}`; - responseData = await customerIoApiRequest.call(this, 'DELETE', endpoint, body, 'tracking'); + await customerIoApiRequest.call(this, 'DELETE', endpoint, body, 'tracking'); + + responseData = { + success: true, + }; } } if (resource === 'event') { if (operation === 'track') { - const id = this.getNodeParameter('id', i) as number; - const name = this.getNodeParameter('name', i) as string; + const customerId = this.getNodeParameter('customerId', i) as number; + const eventName = this.getNodeParameter('eventName', i) as string; const jsonParameters = this.getNodeParameter('jsonParameters', i) as boolean; - body.name = name; + body.name = eventName; if (jsonParameters) { const additionalFieldsJson = this.getNodeParameter('additionalFieldsJson', i) as string; @@ -257,16 +221,14 @@ export class CustomerIo implements INodeType { if (additionalFieldsJson !== '') { if (validateJSON(additionalFieldsJson) !== undefined) { - Object.assign(body, JSON.parse(additionalFieldsJson)); - } else { throw new Error('Additional fields must be a valid JSON'); } } } else { const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; - const data : any = {}; + const data: any = {}; // tslint:disable-line:no-any if (additionalFields.customAttributes) { //@ts-ignore @@ -282,16 +244,19 @@ export class CustomerIo implements INodeType { body.data = data; } - const endpoint = `/customers/${id}/events`; + const endpoint = `/customers/${customerId}/events`; - responseData = await customerIoApiRequest.call(this, 'POST', endpoint, body, 'tracking'); + await customerIoApiRequest.call(this, 'POST', endpoint, body, 'tracking'); + responseData = { + success: true, + }; } if (operation === 'trackAnonymous') { - const name = this.getNodeParameter('name', i) as string; + const eventName = this.getNodeParameter('eventName', i) as string; const jsonParameters = this.getNodeParameter('jsonParameters', i) as boolean; - body.name = name; + body.name = eventName; if (jsonParameters) { const additionalFieldsJson = this.getNodeParameter('additionalFieldsJson', i) as string; @@ -308,7 +273,7 @@ export class CustomerIo implements INodeType { } } else { const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; - const data : any = {}; + const data: any = {}; // tslint:disable-line:no-any if (additionalFields.customAttributes) { //@ts-ignore @@ -320,31 +285,34 @@ export class CustomerIo implements INodeType { } const endpoint = `/events`; - responseData = await customerIoApiRequest.call(this, 'POST', endpoint, body, 'tracking'); + await customerIoApiRequest.call(this, 'POST', endpoint, body, 'tracking'); + + responseData = { + success: true, + }; } } if (resource === 'segment') { - const id = this.getNodeParameter('id', i) as number; - const ids = this.getNodeParameter('ids', i) as string; - const idArray : string[] = []; + const segmentId = this.getNodeParameter('segmentId', i) as number; + const customerIds = this.getNodeParameter('customerIds', i) as string; - ids.split(',').map(id => { - idArray.push(id); - }); + body.id = segmentId; + body.ids = customerIds.split(','); - body.id = id; - body.ids = idArray; - - let endpoint = ``; + let endpoint = ''; if (operation === 'add') { - endpoint = `/segments/${id}/add_customers`; + endpoint = `/segments/${segmentId}/add_customers`; } else { - endpoint = `/segments/${id}/remove_customers`; + endpoint = `/segments/${segmentId}/remove_customers`; } responseData = await customerIoApiRequest.call(this, 'POST', endpoint, body, 'tracking'); + + responseData = { + success: true, + }; } if (Array.isArray(responseData)) { diff --git a/packages/nodes-base/nodes/CustomerIo/EventDescription.ts b/packages/nodes-base/nodes/CustomerIo/EventDescription.ts index 7b57004a9e..ac73dde9f9 100644 --- a/packages/nodes-base/nodes/CustomerIo/EventDescription.ts +++ b/packages/nodes-base/nodes/CustomerIo/EventDescription.ts @@ -35,26 +35,26 @@ export const eventFields = [ /* event:track */ /* -------------------------------------------------------------------------- */ { - displayName: 'ID', - name: 'id', - type: 'number', + displayName: 'Customer ID', + name: 'customerId', + type: 'string', required: true, - default: 0, + default: '', displayOptions: { show: { resource: [ 'event', ], operation: [ - 'track' + 'track', ] }, }, description: 'The unique identifier for the customer.', }, { - displayName: 'Name', - name: 'name', + displayName: 'Event Name', + name: 'eventName', type: 'string', default: '', displayOptions: { @@ -63,7 +63,7 @@ export const eventFields = [ 'event', ], operation: [ - 'track' + 'track', ] }, }, @@ -81,7 +81,7 @@ export const eventFields = [ 'event', ], operation: [ - 'track' + 'track', ], }, }, @@ -100,7 +100,7 @@ export const eventFields = [ 'event', ], operation: [ - 'track' + 'track', ], jsonParameters: [ true, @@ -121,10 +121,10 @@ export const eventFields = [ 'event', ], operation: [ - 'track' + 'track', ], jsonParameters: [ - false + false, ] }, }, @@ -149,7 +149,7 @@ export const eventFields = [ required: true, default: '', description: 'Attribute name.', - placeholder: 'Price' + placeholder: 'Price', }, { @@ -159,7 +159,7 @@ export const eventFields = [ required: true, default: '', description: 'Attribute value.', - placeholder: '25.50' + placeholder: '25.50', }, ], }, @@ -178,8 +178,8 @@ export const eventFields = [ /* event:track anonymous */ /* -------------------------------------------------------------------------- */ { - displayName: 'Name', - name: 'name', + displayName: 'Event Name', + name: 'eventName', type: 'string', required: true, default: '', @@ -189,7 +189,7 @@ export const eventFields = [ 'event', ], operation: [ - 'trackAnonymous' + 'trackAnonymous', ] }, }, @@ -207,7 +207,7 @@ export const eventFields = [ 'event', ], operation: [ - 'trackAnonymous' + 'trackAnonymous', ], }, }, @@ -226,7 +226,7 @@ export const eventFields = [ 'event', ], operation: [ - 'trackAnonymous' + 'trackAnonymous', ], jsonParameters: [ true, @@ -247,10 +247,10 @@ export const eventFields = [ 'event', ], operation: [ - 'trackAnonymous' + 'trackAnonymous', ], jsonParameters: [ - false + false, ] }, }, @@ -275,9 +275,8 @@ export const eventFields = [ required: true, default: '', description: 'Attribute name.', - placeholder: 'Price' + placeholder: 'Price', }, - { displayName: 'Value', name: 'value', @@ -285,7 +284,7 @@ export const eventFields = [ required: true, default: '', description: 'Attribute value.', - placeholder: '25.50' + placeholder: '25.50', }, ], }, diff --git a/packages/nodes-base/nodes/CustomerIo/GenericFunctions.ts b/packages/nodes-base/nodes/CustomerIo/GenericFunctions.ts index 9cea18a4c9..c58bea8883 100644 --- a/packages/nodes-base/nodes/CustomerIo/GenericFunctions.ts +++ b/packages/nodes-base/nodes/CustomerIo/GenericFunctions.ts @@ -16,7 +16,7 @@ import { get, } from 'lodash'; -export async function customerIoApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: object, baseApi? : string, query?: IDataObject): Promise { // tslint:disable-line:no-any +export async function customerIoApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: object, baseApi?: string, query?: IDataObject): Promise { // tslint:disable-line:no-any const credentials = this.getCredentials('customerIoApi'); if (credentials === undefined) { @@ -38,14 +38,14 @@ export async function customerIoApiRequest(this: IHookFunctions | IExecuteFuncti if (baseApi === 'tracking') { options.uri = `https://track.customer.io/api/v1${endpoint}`; const basicAuthKey = Buffer.from(`${credentials.trackingSiteId}:${credentials.trackingApiKey}`).toString('base64'); - Object.assign(options.headers, {'Authorization': `Basic ${basicAuthKey}`}); + Object.assign(options.headers, { 'Authorization': `Basic ${basicAuthKey}` }); } else if (baseApi === 'api') { options.uri = `https://api.customer.io/v1/api${endpoint}`; const basicAuthKey = Buffer.from(`${credentials.trackingSiteId}:${credentials.trackingApiKey}`).toString('base64'); - Object.assign(options.headers, {'Authorization': `Basic ${basicAuthKey}`}); + Object.assign(options.headers, { 'Authorization': `Basic ${basicAuthKey}` }); } else if (baseApi === 'beta') { options.uri = `https://beta-api.customer.io/v1/api${endpoint}`; - Object.assign(options.headers, {'Authorization': `Bearer ${credentials.appApiKey as string}`}); + Object.assign(options.headers, { 'Authorization': `Bearer ${credentials.appApiKey as string}` }); } try { diff --git a/packages/nodes-base/nodes/CustomerIo/SegmentDescription.ts b/packages/nodes-base/nodes/CustomerIo/SegmentDescription.ts index a968790882..9586c13730 100644 --- a/packages/nodes-base/nodes/CustomerIo/SegmentDescription.ts +++ b/packages/nodes-base/nodes/CustomerIo/SegmentDescription.ts @@ -33,8 +33,8 @@ export const segmentFields = [ /* segment:add */ /* -------------------------------------------------------------------------- */ { - displayName: 'ID', - name: 'id', + displayName: 'Segment ID', + name: 'segmentId', type: 'number', required: true, default: 0, @@ -44,15 +44,16 @@ export const segmentFields = [ 'segment', ], operation: [ - 'add', 'remove' + 'add', + 'remove', ] }, }, description: 'The unique identifier of the segment.', }, { - displayName: 'IDs', - name: 'ids', + displayName: 'Customer IDs', + name: 'customerIds', type: 'string', required: true, default: '', @@ -62,7 +63,8 @@ export const segmentFields = [ 'segment', ], operation: [ - 'add', 'remove' + 'add', + 'remove', ] }, }, From b01621bc8093c9f92f98608f1a8faad454743a25 Mon Sep 17 00:00:00 2001 From: Rupenieks <32895755+Rupenieks@users.noreply.github.com> Date: Wed, 2 Sep 2020 13:04:43 +0200 Subject: [PATCH 19/46] :bug: Fixed multiple labelIds not working on message -> getAll (#916) --- packages/nodes-base/nodes/Google/Gmail/Gmail.node.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Google/Gmail/Gmail.node.ts b/packages/nodes-base/nodes/Google/Gmail/Gmail.node.ts index 81645908bc..924ad85c7b 100644 --- a/packages/nodes-base/nodes/Google/Gmail/Gmail.node.ts +++ b/packages/nodes-base/nodes/Google/Gmail/Gmail.node.ts @@ -476,7 +476,7 @@ export class Gmail implements INodeType { if (qs.labelIds == '') { delete qs.labelIds; } else { - qs.labelIds = (qs.labelIds as string[]).join(','); + qs.labelIds = qs.labelIds as string[]; } } From ebc52acdbc8e1346d60f9481bd2926f2dcfe3732 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 2 Sep 2020 13:05:43 +0200 Subject: [PATCH 20/46] :bug: Fix Label-ID field --- packages/nodes-base/nodes/Google/Gmail/MessageDescription.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Google/Gmail/MessageDescription.ts b/packages/nodes-base/nodes/Google/Gmail/MessageDescription.ts index cd23aa0fb3..10ea8e37d4 100644 --- a/packages/nodes-base/nodes/Google/Gmail/MessageDescription.ts +++ b/packages/nodes-base/nodes/Google/Gmail/MessageDescription.ts @@ -442,7 +442,7 @@ export const messageFields = [ typeOptions: { loadOptionsMethod: 'getLabels', }, - default: '', + default: [], description: 'Only return messages with labels that match all of the specified label IDs.', }, { From ccc78e08c97ea2ca37f1788d213dea57754b5be1 Mon Sep 17 00:00:00 2001 From: Rupenieks Date: Wed, 2 Sep 2020 14:36:30 +0200 Subject: [PATCH 21/46] :zap: Fixed checkExists logic to ensure API registered webhook URL matches local --- .../nodes-base/nodes/Telegram/TelegramTrigger.node.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts b/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts index ed18704de6..30b2bc01d4 100644 --- a/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts +++ b/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts @@ -114,14 +114,13 @@ export class TelegramTrigger implements INodeType { async checkExists(this: IHookFunctions): Promise { const endpoint = 'getWebhookInfo'; const webhookReturnData = await apiRequest.call(this, 'POST', endpoint, {}); + const webhookUrl = this.getNodeWebhookUrl('default'); - //https://core.telegram.org/bots/api#webhookinfo - // IF Webhook URL is empty if not setup - if (webhookReturnData.result.url === '') { - return false; + if (webhookReturnData.result.url === webhookUrl) { + return true; } - return true; + return false; }, async create(this: IHookFunctions): Promise { const webhookUrl = this.getNodeWebhookUrl('default'); From f2913153590fc349abe8766cc72663245df3f6da Mon Sep 17 00:00:00 2001 From: Rupenieks <32895755+Rupenieks@users.noreply.github.com> Date: Wed, 2 Sep 2020 15:19:24 +0200 Subject: [PATCH 22/46] :zap: Telegram Trigger node function for checking if webhook exists (#915) * :zap: Trigger checkExists function * :zap: Fixed checkExists logic to ensure API registered webhook URL matches local --- .../nodes-base/nodes/Telegram/TelegramTrigger.node.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts b/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts index bc560c0d5e..30b2bc01d4 100644 --- a/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts +++ b/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts @@ -112,6 +112,14 @@ export class TelegramTrigger implements INodeType { webhookMethods = { default: { async checkExists(this: IHookFunctions): Promise { + const endpoint = 'getWebhookInfo'; + const webhookReturnData = await apiRequest.call(this, 'POST', endpoint, {}); + const webhookUrl = this.getNodeWebhookUrl('default'); + + if (webhookReturnData.result.url === webhookUrl) { + return true; + } + return false; }, async create(this: IHookFunctions): Promise { From 21a4a568a4dedf4a2e9279cea1878a57aae30074 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 2 Sep 2020 15:28:51 +0200 Subject: [PATCH 23/46] :bookmark: Release n8n-core@0.44.0 --- packages/core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index 57677c1a2f..1c8a6b67dd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "0.43.0", + "version": "0.44.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 6b01c56b49beae9789111de488dd942bea1a3bf4 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 2 Sep 2020 15:29:52 +0200 Subject: [PATCH 24/46] :arrow_up: Set n8n-core@0.44.0 on n8n-nodes-base --- packages/nodes-base/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 4cb5b4a2cd..25feb32a4b 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -418,7 +418,7 @@ "mqtt": "^4.2.0", "mssql": "^6.2.0", "mysql2": "^2.0.1", - "n8n-core": "~0.43.0", + "n8n-core": "~0.44.0", "nodemailer": "^6.4.6", "pdf-parse": "^1.1.1", "pg": "^8.3.0", From fc1e76efbad42575774960fba33b7ef6459826e4 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 2 Sep 2020 15:30:28 +0200 Subject: [PATCH 25/46] :bookmark: Release n8n-nodes-base@0.75.0 --- packages/nodes-base/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 25feb32a4b..ea057e4591 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "0.74.1", + "version": "0.75.0", "description": "Base nodes of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From c5b12f3452d260ae93ec3cdb795e00e7a3c50507 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 2 Sep 2020 15:31:15 +0200 Subject: [PATCH 26/46] :arrow_up: Set n8n-core@0.44.0 and n8n-nodes-base@0.75.0 on n8n --- packages/cli/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 225d4998fe..a4c421919d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -100,9 +100,9 @@ "lodash.get": "^4.4.2", "mongodb": "^3.5.5", "mysql2": "^2.0.1", - "n8n-core": "~0.43.0", + "n8n-core": "~0.44.0", "n8n-editor-ui": "~0.55.0", - "n8n-nodes-base": "~0.74.1", + "n8n-nodes-base": "~0.75.0", "n8n-workflow": "~0.39.0", "oauth-1.0a": "^2.2.6", "open": "^7.0.0", From 922b4181d40f28871cce44c049d7a066758a558d Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Wed, 2 Sep 2020 15:32:16 +0200 Subject: [PATCH 27/46] :bookmark: Release n8n@0.80.0 --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index a4c421919d..049af1d771 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.79.3", + "version": "0.80.0", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From 8a93bdbdcc8662c21aabddb46d718799b8678006 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Thu, 3 Sep 2020 05:21:23 -0400 Subject: [PATCH 28/46] :zap: Add update operation (#905) * :zap: Add update operation * :zap: Small improvement --- .../Google/Contacts/ContactDescription.ts | 641 ++++++++++++++++++ .../Google/Contacts/GoogleContacts.node.ts | 186 +++++ 2 files changed, 827 insertions(+) diff --git a/packages/nodes-base/nodes/Google/Contacts/ContactDescription.ts b/packages/nodes-base/nodes/Google/Contacts/ContactDescription.ts index 74fff8d064..a2c438c90d 100644 --- a/packages/nodes-base/nodes/Google/Contacts/ContactDescription.ts +++ b/packages/nodes-base/nodes/Google/Contacts/ContactDescription.ts @@ -35,6 +35,11 @@ export const contactOperations = [ value: 'getAll', description: 'Retrieve all contacts', }, + { + name: 'Update', + value: 'update', + description: 'Update a contact', + }, ], default: 'create', description: 'The operation to perform.' @@ -347,6 +352,18 @@ export const contactFields = [ }, default: [], }, + { + displayName: 'Honorific Prefix', + name: 'honorificPrefix', + type: 'string', + default: '', + }, + { + displayName: 'Honorific Suffix', + name: 'honorificSuffix', + type: 'string', + default: '', + }, { displayName: 'Middle Name', name: 'middleName', @@ -935,4 +952,628 @@ export const contactFields = [ }, ], }, + /* -------------------------------------------------------------------------- */ + /* contact:update */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Contact ID', + name: 'contactId', + type: 'string', + required: true, + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'contact', + ], + }, + }, + default: '', + }, + { + displayName: 'Fields', + name: 'fields', + type: 'multiOptions', + options: [ + { + name: '*', + value: '*', + }, + { + name: 'Addresses', + value: 'addresses', + }, + { + name: 'Biographies', + value: 'biographies', + }, + { + name: 'Birthdays', + value: 'birthdays', + }, + { + name: 'Cover Photos', + value: 'coverPhotos', + }, + { + name: 'Email Addresses', + value: 'emailAddresses', + }, + { + name: 'Events', + value: 'events', + }, + { + name: 'Genders', + value: 'genders', + }, + { + name: 'IM Clients', + value: 'imClients', + }, + { + name: 'Interests', + value: 'interests', + }, + { + name: 'Locales', + value: 'locales', + }, + { + name: 'Memberships', + value: 'memberships', + }, + { + name: 'Metadata', + value: 'metadata', + }, + { + name: 'Names', + value: 'names', + }, + { + name: 'Nicknames', + value: 'nicknames', + }, + { + name: 'Occupations', + value: 'occupations', + }, + { + name: 'Organizations', + value: 'organizations', + }, + { + name: 'Phone Numbers', + value: 'phoneNumbers', + }, + { + name: 'Photos', + value: 'photos', + }, + { + name: 'Relations', + value: 'relations', + }, + { + name: 'Residences', + value: 'residences', + }, + { + name: 'Sip Addresses', + value: 'sipAddresses', + }, + { + name: 'Skills', + value: 'skills', + }, + { + name: 'URLs', + value: 'urls', + }, + { + name: 'User Defined', + value: 'userDefined', + }, + ], + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'contact', + ], + }, + }, + default: '', + description: 'A field mask to restrict which fields on each person are returned. Multiple fields can be specified by separating them with commas.', + }, + { + displayName: 'Update Fields', + name: 'updateFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + operation: [ + 'update', + ], + resource: [ + 'contact', + ], + }, + }, + options: [ + { + displayName: 'Etag', + name: 'etag', + type: 'string', + default: '', + description: 'The etag field in the person is nedded to make sure the contact has not changed since your last read', + }, + { + displayName: 'Family Name', + name: 'familyName', + type: 'string', + default: '', + }, + { + displayName: 'Given Name', + name: 'givenName', + type: 'string', + default: '', + }, + { + displayName: 'Addresses', + name: 'addressesUi', + placeholder: 'Add Address', + type: 'fixedCollection', + default: {}, + options: [ + { + displayName: 'Address', + name: 'addressesValues', + values: [ + { + displayName: 'Street Address', + name: 'streetAddress', + type: 'string', + default: '', + }, + { + displayName: 'City', + name: 'city', + type: 'string', + default: '', + description: 'City', + }, + { + displayName: 'Region', + name: 'region', + type: 'string', + default: '', + description: 'Region', + }, + { + displayName: 'Country Code', + name: 'countryCode', + type: 'string', + default: '', + }, + { + displayName: 'Postal Code', + name: 'postalCode', + type: 'string', + default: '', + description: 'Postal code', + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Home', + value: 'home', + }, + { + name: 'Work', + value: 'work', + }, + { + name: 'Other', + value: 'other', + }, + ], + default: '', + }, + ], + }, + ], + }, + { + displayName: 'Birthday', + name: 'birthday', + type: 'dateTime', + default: '', + }, + { + displayName: 'Company', + name: 'companyUi', + type: 'fixedCollection', + default: '', + placeholder: 'Add Company', + typeOptions: { + multipleValues: true + }, + options: [ + { + name: 'companyValues', + displayName: 'Company', + values: [ + { + displayName: 'Current', + name: 'current', + type: 'boolean', + default: false, + }, + { + displayName: 'Domain', + name: 'domain', + type: 'string', + default: '', + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + }, + { + displayName: 'Title', + name: 'title', + type: 'string', + default: '', + }, + ], + }, + ], + }, + { + displayName: 'Custom Fields', + name: 'customFieldsUi', + type: 'fixedCollection', + default: '', + placeholder: 'Add Custom Field', + typeOptions: { + multipleValues: true + }, + options: [ + { + name: 'customFieldsValues', + displayName: 'Custom Field', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + description: 'The end user specified key of the user defined data.', + default: '', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + description: 'The end user specified value of the user defined data.', + default: '', + }, + ], + }, + ], + }, + { + displayName: 'Emails', + name: 'emailsUi', + type: 'fixedCollection', + default: '', + placeholder: 'Add Email', + typeOptions: { + multipleValues: true + }, + options: [ + { + name: 'emailsValues', + displayName: 'Email', + values: [ + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Home', + value: 'home', + }, + { + name: 'Work', + value: 'work', + }, + { + name: 'Other', + value: 'other', + }, + ], + default: '', + description: `The type of the email address. The type can be custom or one of these predefined values`, + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: 'The email address.', + }, + ], + }, + ], + }, + { + displayName: 'Events', + name: 'eventsUi', + type: 'fixedCollection', + default: '', + placeholder: 'Add Event', + description: 'An event related to the person.', + typeOptions: { + multipleValues: true + }, + options: [ + { + name: 'eventsValues', + displayName: 'Event', + values: [ + { + displayName: 'Date', + name: 'date', + type: 'dateTime', + default: '', + description: 'The date of the event.', + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Anniversary', + value: 'anniversary', + }, + { + name: 'Other', + value: 'other', + }, + ], + default: '', + description: `The type of the event. The type can be custom or one of these predefined values`, + }, + ], + }, + ], + }, + { + displayName: 'File As', + name: 'fileAs', + type: 'string', + default: '', + description: 'The name that should be used to sort the person in a list.', + }, + { + displayName: 'Group', + name: 'group', + type: 'multiOptions', + typeOptions: { + loadOptionsMethod: 'getGroups', + }, + default: [], + }, + { + displayName: 'Honorific Prefix', + name: 'honorificPrefix', + type: 'string', + default: '', + }, + { + displayName: 'Honorific Suffix', + name: 'honorificSuffix', + type: 'string', + default: '', + }, + { + displayName: 'Middle Name', + name: 'middleName', + type: 'string', + default: '', + }, + { + displayName: 'Notes', + name: 'biographies', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + }, + { + displayName: 'Phone', + name: 'phoneUi', + type: 'fixedCollection', + default: '', + placeholder: 'Add Phone', + typeOptions: { + multipleValues: true + }, + options: [ + { + name: 'phoneValues', + displayName: 'Phone', + values: [ + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Home', + value: 'home', + }, + { + name: 'Work', + value: 'work', + }, + { + name: 'Mobile', + value: 'mobile', + }, + { + name: 'Home Fax', + value: 'homeFax', + }, + { + name: 'Work Fax', + value: 'workFax', + }, + { + name: 'Other Fax', + value: 'otherFax', + }, + { + name: 'Pager', + value: 'pager', + }, + { + name: 'Work Mobile', + value: 'workMobile', + }, + { + name: 'Work Pager', + value: 'workPager', + }, + { + name: 'Main', + value: 'main', + }, + { + name: 'Google Voice', + value: 'googleVoice', + }, + { + name: 'Other', + value: 'other', + }, + ], + default: '', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: 'The phone number.', + }, + ], + }, + ], + }, + { + displayName: 'Relations', + name: 'relationsUi', + type: 'fixedCollection', + default: '', + placeholder: 'Add Relation', + typeOptions: { + multipleValues: true + }, + options: [ + { + name: 'relationsValues', + displayName: 'Relation', + values: [ + { + displayName: 'Person', + name: 'person', + type: 'string', + default: '', + description: 'The name of the other person this relation refers to.', + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + options: [ + { + name: 'Assistant', + value: 'assistant', + }, + { + name: 'Brother', + value: 'brother', + }, + { + name: 'Child', + value: 'child', + }, + { + name: 'Domestic Partner', + value: 'domesticPartner', + }, + { + name: 'Father', + value: 'father', + }, + { + name: 'Friend', + value: 'friend', + }, + { + name: 'Manager', + value: 'manager', + }, + { + name: 'Mother', + value: 'mother', + }, + { + name: 'Parent', + value: 'parent', + }, + { + name: 'Referred By', + value: 'referredBy', + }, + { + name: 'Relative', + value: 'relative', + }, + { + name: 'Sister', + value: 'sister', + }, + { + name: 'Spouse', + value: 'spouse', + }, + ], + default: '', + description: `The person's relation to the other person. The type can be custom or one of these predefined values`, + }, + ], + }, + ], + }, + ], + }, ] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Google/Contacts/GoogleContacts.node.ts b/packages/nodes-base/nodes/Google/Contacts/GoogleContacts.node.ts index 10eee8be68..b7749662a2 100644 --- a/packages/nodes-base/nodes/Google/Contacts/GoogleContacts.node.ts +++ b/packages/nodes-base/nodes/Google/Contacts/GoogleContacts.node.ts @@ -123,6 +123,16 @@ export class GoogleContacts implements INodeType { body.names[0].middleName = additionalFields.middleName as string; } + if (additionalFields.honorificPrefix) { + //@ts-ignore + body.names[0].honorificPrefix = additionalFields.honorificPrefix as string; + } + + if (additionalFields.honorificSuffix) { + //@ts-ignore + body.names[0].honorificSuffix = additionalFields.honorificSuffix as string; + } + if (additionalFields.companyUi) { const companyValues = (additionalFields.companyUi as IDataObject).companyValues as IDataObject[]; body.organizations = companyValues; @@ -298,6 +308,182 @@ export class GoogleContacts implements INodeType { responseData[i].contactId = responseData[i].resourceName.split('/')[1]; } } + //https://developers.google.com/people/api/rest/v1/people/updateContact + if (operation === 'update') { + const updatePersonFields = []; + + const contactId = this.getNodeParameter('contactId', i) as string; + + const fields = this.getNodeParameter('fields', i) as string[]; + + const updateFields = this.getNodeParameter('updateFields', i) as IDataObject; + + let etag; + + if (updateFields.etag) { + + etag = updateFields.etag as string; + + } else { + + const data = await googleApiRequest.call( + this, + 'GET', + `/people/${contactId}`, + {}, + { personFields: 'Names' }, + ); + + etag = data.etag; + + } + + if (fields.includes('*')) { + qs.personFields = allFields.join(','); + } else { + qs.personFields = (fields as string[]).join(','); + } + + const body: IDataObject = { + etag, + names: [ + {}, + ], + }; + + if (updateFields.givenName) { + //@ts-ignore + body.names[0].givenName = updateFields.givenName as string; + } + + if (updateFields.familyName) { + //@ts-ignore + body.names[0].familyName = updateFields.familyName as string; + } + + if (updateFields.middleName) { + //@ts-ignore + body.names[0].middleName = updateFields.middleName as string; + } + + if (updateFields.honorificPrefix) { + //@ts-ignore + body.names[0].honorificPrefix = updateFields.honorificPrefix as string; + } + + if (updateFields.honorificSuffix) { + //@ts-ignore + body.names[0].honorificSuffix = updateFields.honorificSuffix as string; + } + + if (updateFields.companyUi) { + const companyValues = (updateFields.companyUi as IDataObject).companyValues as IDataObject[]; + body.organizations = companyValues; + updatePersonFields.push('organizations'); + } + + if (updateFields.phoneUi) { + const phoneValues = (updateFields.phoneUi as IDataObject).phoneValues as IDataObject[]; + body.phoneNumbers = phoneValues; + updatePersonFields.push('phoneNumbers'); + } + + if (updateFields.addressesUi) { + const addressesValues = (updateFields.addressesUi as IDataObject).addressesValues as IDataObject[]; + body.addresses = addressesValues; + updatePersonFields.push('addresses'); + } + + if (updateFields.relationsUi) { + const relationsValues = (updateFields.relationsUi as IDataObject).relationsValues as IDataObject[]; + body.relations = relationsValues; + updatePersonFields.push('relations'); + } + + if (updateFields.eventsUi) { + const eventsValues = (updateFields.eventsUi as IDataObject).eventsValues as IDataObject[]; + for (let i = 0; i < eventsValues.length; i++) { + const [month, day, year] = moment(eventsValues[i].date as string).format('MM/DD/YYYY').split('/'); + eventsValues[i] = { + date: { + day, + month, + year, + }, + type: eventsValues[i].type, + }; + } + body.events = eventsValues; + updatePersonFields.push('events'); + } + + if (updateFields.birthday) { + const [month, day, year] = moment(updateFields.birthday as string).format('MM/DD/YYYY').split('/'); + + body.birthdays = [ + { + date: { + day, + month, + year + } + } + ]; + + updatePersonFields.push('birthdays'); + } + + if (updateFields.emailsUi) { + const emailsValues = (updateFields.emailsUi as IDataObject).emailsValues as IDataObject[]; + body.emailAddresses = emailsValues; + updatePersonFields.push('emailAddresses'); + } + + if (updateFields.biographies) { + body.biographies = [ + { + value: updateFields.biographies, + contentType: 'TEXT_PLAIN', + }, + ]; + updatePersonFields.push('biographies'); + } + + if (updateFields.customFieldsUi) { + const customFieldsValues = (updateFields.customFieldsUi as IDataObject).customFieldsValues as IDataObject[]; + body.userDefined = customFieldsValues; + updatePersonFields.push('userDefined'); + } + + if (updateFields.group) { + const memberships = (updateFields.group as string[]).map((groupId: string) => { + return { + contactGroupMembership: { + contactGroupResourceName: groupId + } + }; + }); + + body.memberships = memberships; + updatePersonFields.push('memberships'); + } + + if ((body.names as IDataObject[]).length > 0) { + updatePersonFields.push('names'); + } + + qs.updatePersonFields = updatePersonFields.join(','); + + responseData = await googleApiRequest.call( + this, + 'PATCH', + `/people/${contactId}:updateContact`, + body, + qs + ); + + responseData.contactId = responseData.resourceName.split('/')[1]; + } } } if (Array.isArray(responseData)) { From dcded3b96b9a50bb7c22fbb9197d35a57e938f23 Mon Sep 17 00:00:00 2001 From: YErii Date: Thu, 3 Sep 2020 18:45:54 +0800 Subject: [PATCH 29/46] :books: Improve install instructions (#924) Add precise build tool installation description. --- CONTRIBUTING.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 24822bb784..60516633a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,11 +57,16 @@ dependencies are installed and the packages get linked correctly. Here a short g The packages which n8n uses depend on a few build tools: -Linux: +Debian/Ubuntu: ``` apt-get install -y build-essential python ``` +CentOS: +``` +yum install gcc gcc-c++ make +``` + Windows: ``` npm install -g windows-build-tools From a032c5448d5b8a0afb1efde121f49a683cb83aed Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Fri, 4 Sep 2020 04:28:04 -0400 Subject: [PATCH 30/46] :zap: Download images and files with Telegram Trigger (#919) * :zap: Download images and files. * :zap: Improvements * :zap: Small improvements --- .../nodes/Telegram/GenericFunctions.ts | 38 +++++- packages/nodes-base/nodes/Telegram/IEvent.ts | 12 ++ .../nodes/Telegram/TelegramTrigger.node.ts | 116 +++++++++++++++++- 3 files changed, 156 insertions(+), 10 deletions(-) create mode 100644 packages/nodes-base/nodes/Telegram/IEvent.ts diff --git a/packages/nodes-base/nodes/Telegram/GenericFunctions.ts b/packages/nodes-base/nodes/Telegram/GenericFunctions.ts index ee6d9c47b6..f59c8826bc 100644 --- a/packages/nodes-base/nodes/Telegram/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Telegram/GenericFunctions.ts @@ -2,12 +2,16 @@ import { IExecuteFunctions, IHookFunctions, ILoadOptionsFunctions, + IWebhookFunctions, } from 'n8n-core'; -import { OptionsWithUri } from 'request'; -import { IDataObject } from 'n8n-workflow'; - +import { + OptionsWithUri, +} from 'request'; +import { + IDataObject, +} from 'n8n-workflow'; // Interface in n8n export interface IMarkupKeyboard { @@ -138,7 +142,7 @@ export function addAdditionalFields(this: IExecuteFunctions, body: IDataObject, * @param {object} body * @returns {Promise} */ -export async function apiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: object, query?: IDataObject): Promise { // tslint:disable-line:no-any +export async function apiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, method: string, endpoint: string, body: object, query?: IDataObject, option: IDataObject = {}): Promise { // tslint:disable-line:no-any const credentials = this.getCredentials('telegramApi'); if (credentials === undefined) { @@ -157,9 +161,22 @@ export async function apiRequest(this: IHookFunctions | IExecuteFunctions | ILoa json: true, }; + if (Object.keys(option).length > 0) { + Object.assign(options, option); + } + + if (Object.keys(body).length === 0) { + delete options.body; + } + + if (Object.keys(query).length === 0) { + delete options.qs; + } + try { return await this.helpers.request!(options); } catch (error) { + if (error.statusCode === 401) { // Return a clear error throw new Error('The Telegram credentials are not valid!'); @@ -175,3 +192,16 @@ export async function apiRequest(this: IHookFunctions | IExecuteFunctions | ILoa throw error; } } + +export function getImageBySize(photos: IDataObject[], size: string): IDataObject | undefined { + + const sizes = { + 'small': 0, + 'medium': 1, + 'large': 2, + } as IDataObject; + + const index = sizes[size] as number; + + return photos[index]; +} diff --git a/packages/nodes-base/nodes/Telegram/IEvent.ts b/packages/nodes-base/nodes/Telegram/IEvent.ts new file mode 100644 index 0000000000..53f6f7e919 --- /dev/null +++ b/packages/nodes-base/nodes/Telegram/IEvent.ts @@ -0,0 +1,12 @@ +export interface IEvent { + message?: { + photo?: [ + { + file_id: string, + }, + ], + document?: { + file_id: string; + }, + }; +} diff --git a/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts b/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts index 30b2bc01d4..6472c8083c 100644 --- a/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts +++ b/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts @@ -4,15 +4,20 @@ import { } from 'n8n-core'; import { - INodeTypeDescription, + IDataObject, INodeType, + INodeTypeDescription, IWebhookResponseData, } from 'n8n-workflow'; import { apiRequest, + getImageBySize, } from './GenericFunctions'; +import { + IEvent, +} from './IEvent'; export class TelegramTrigger implements INodeType { description: INodeTypeDescription = { @@ -33,7 +38,7 @@ export class TelegramTrigger implements INodeType { { name: 'telegramApi', required: true, - } + }, ], webhooks: [ { @@ -105,6 +110,45 @@ export class TelegramTrigger implements INodeType { default: [], description: 'The update types to listen to.', }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + options: [ + { + displayName: 'Download Images/Files', + name: 'donwload', + type: 'boolean', + default: false, + description: `Telegram develiers the image in 3 sizes.
+ By default, just the larger image would be downloaded.
+ if you want to change the size set the field 'Image Size'`, + }, + { + displayName: 'Image Size', + name: 'imageSize', + type: 'options', + options: [ + { + name: 'Small', + value: 'small', + }, + { + name: 'Medium', + value: 'medium', + }, + { + name: 'Large', + value: 'large', + }, + ], + default: 'large', + description: 'The size of the image to be downloaded', + }, + ], + }, ], }; @@ -157,14 +201,74 @@ export class TelegramTrigger implements INodeType { }, }; - - async webhook(this: IWebhookFunctions): Promise { - const bodyData = this.getBodyData(); + + const credentials = this.getCredentials('telegramApi') as IDataObject; + + const bodyData = this.getBodyData() as IEvent; + + const additionalFields = this.getNodeParameter('additionalFields') as IDataObject; + + if (additionalFields.donwload === true) { + + let imageSize = 'large'; + + if ((bodyData.message && bodyData.message.photo && Array.isArray(bodyData.message.photo) || bodyData.message?.document)) { + + if (additionalFields.imageSize) { + + imageSize = additionalFields.imageSize as string; + + } + + let fileId; + + if (bodyData.message.photo) { + + let image = getImageBySize(bodyData.message.photo as IDataObject[], imageSize) as IDataObject; + + // When the image is sent from the desktop app telegram does not resize the image + // So return the only image avaiable + // Basically the Image Size parameter would work just when the images comes from the mobile app + if (image === undefined) { + image = bodyData.message.photo[0]; + } + + fileId = image.file_id; + + } else { + + fileId = bodyData.message?.document?.file_id; + } + + const { result: { file_path } } = await apiRequest.call(this, 'GET', `getFile?file_id=${fileId}`, {}); + + const file = await apiRequest.call(this, 'GET', '', {}, {}, { json: false, encoding: null, uri: `https://api.telegram.org/file/bot${credentials.accessToken}/${file_path}`, resolveWithFullResponse: true }); + + const data = Buffer.from(file.body as string); + + const fileName = file_path.split('/').pop(); + + const binaryData = await this.helpers.prepareBinaryData(data as unknown as Buffer, fileName); + + return { + workflowData: [ + [ + { + json: bodyData as unknown as IDataObject, + binary: { + data: binaryData, + }, + } + ] + ], + }; + } + } return { workflowData: [ - this.helpers.returnJsonArray([bodyData]) + this.helpers.returnJsonArray([bodyData as unknown as IDataObject]) ], }; } From 5e0ff8fce7d36b0026e856aca3db0f625c273cfc Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Fri, 4 Sep 2020 10:30:22 +0200 Subject: [PATCH 31/46] :bug: Fix typo --- packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts b/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts index 6472c8083c..92f88920e9 100644 --- a/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts +++ b/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts @@ -119,7 +119,7 @@ export class TelegramTrigger implements INodeType { options: [ { displayName: 'Download Images/Files', - name: 'donwload', + name: 'download', type: 'boolean', default: false, description: `Telegram develiers the image in 3 sizes.
@@ -209,7 +209,7 @@ export class TelegramTrigger implements INodeType { const additionalFields = this.getNodeParameter('additionalFields') as IDataObject; - if (additionalFields.donwload === true) { + if (additionalFields.download === true) { let imageSize = 'large'; From c42ae57dc13e0e61d20c8aa7861dff875ac2b365 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Fri, 4 Sep 2020 10:30:49 +0200 Subject: [PATCH 32/46] :zap: Add sendAnimation functionality to Telegram --- .../nodes/Telegram/Telegram.node.ts | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/nodes-base/nodes/Telegram/Telegram.node.ts b/packages/nodes-base/nodes/Telegram/Telegram.node.ts index bb40230298..2597b0a0c9 100644 --- a/packages/nodes-base/nodes/Telegram/Telegram.node.ts +++ b/packages/nodes-base/nodes/Telegram/Telegram.node.ts @@ -146,6 +146,11 @@ export class Telegram implements INodeType { value: 'editMessageText', description: 'Edit a text message', }, + { + name: 'Send Animation', + value: 'sendAnimation', + description: 'Send an animated file', + }, { name: 'Send Audio', value: 'sendAudio', @@ -209,6 +214,7 @@ export class Telegram implements INodeType { 'member', 'setDescription', 'setTitle', + 'sendAnimation', 'sendAudio', 'sendChatAction', 'sendDocument', @@ -513,6 +519,29 @@ export class Telegram implements INodeType { + // ---------------------------------- + // message:sendAnimation + // ---------------------------------- + { + displayName: 'Animation', + name: 'file', + type: 'string', + default: '', + displayOptions: { + show: { + operation: [ + 'sendAnimation' + ], + resource: [ + 'message', + ], + }, + }, + description: 'Animation to send. Pass a file_id to send an animation that exists on the Telegram servers (recommended)
or pass an HTTP URL for Telegram to get an animation from the Internet.', + }, + + + // ---------------------------------- // message:sendAudio // ---------------------------------- @@ -811,7 +840,7 @@ export class Telegram implements INodeType { // ---------------------------------- - // message:editMessageText/sendAudio/sendMessage/sendPhoto/sendSticker/sendVideo + // message:editMessageText/sendAnimation/sendAudio/sendMessage/sendPhoto/sendSticker/sendVideo // ---------------------------------- { @@ -820,6 +849,7 @@ export class Telegram implements INodeType { displayOptions: { show: { operation: [ + 'sendAnimation', 'sendDocument', 'sendMessage', 'sendPhoto', @@ -1147,6 +1177,7 @@ export class Telegram implements INodeType { show: { operation: [ 'editMessageText', + 'sendAnimation', 'sendDocument', 'sendMessage', 'sendMediaGroup', @@ -1171,6 +1202,7 @@ export class Telegram implements INodeType { displayOptions: { show: { '/operation': [ + 'sendAnimation', 'sendAudio', 'sendDocument', 'sendPhoto', @@ -1220,6 +1252,7 @@ export class Telegram implements INodeType { displayOptions: { show: { '/operation': [ + 'sendAnimation', 'sendAudio', 'sendVideo', ], @@ -1238,6 +1271,7 @@ export class Telegram implements INodeType { displayOptions: { show: { '/operation': [ + 'sendAnimation', 'sendVideo', ], }, @@ -1263,6 +1297,7 @@ export class Telegram implements INodeType { show: { '/operation': [ 'editMessageText', + 'sendAnimation', 'sendAudio', 'sendMessage', 'sendPhoto', @@ -1325,6 +1360,7 @@ export class Telegram implements INodeType { displayOptions: { show: { '/operation': [ + 'sendAnimation', 'sendAudio', 'sendDocument', 'sendVideo', @@ -1344,6 +1380,7 @@ export class Telegram implements INodeType { displayOptions: { show: { '/operation': [ + 'sendAnimation', 'sendVideo', ], }, @@ -1469,6 +1506,21 @@ export class Telegram implements INodeType { // Add additional fields and replyMarkup addAdditionalFields.call(this, body, i); + + } else if (operation === 'sendAnimation') { + // ---------------------------------- + // message:sendAnimation + // ---------------------------------- + + endpoint = 'sendAnimation'; + + body.chat_id = this.getNodeParameter('chatId', i) as string; + body.animation = this.getNodeParameter('file', i) as string; + + // Add additional fields and replyMarkup + addAdditionalFields.call(this, body, i); + + } else if (operation === 'sendAudio') { // ---------------------------------- // message:sendAudio From 5a0e356ab6a59e8317c97e34bc831252e11c42dd Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Sun, 6 Sep 2020 15:27:24 -0400 Subject: [PATCH 33/46] Sfdc (#933) * add sfdc user node interface * add sfdc user node description for get method * sfdc user node getall * sfdc user node support limit * sfdc user node support options * sfdc user node update user operations * sfdc add usr node query logic * :zap: Small improvements Co-authored-by: YErii --- .../nodes/Salesforce/Salesforce.node.ts | 45 ++++++- .../nodes/Salesforce/UserDescription.ts | 126 ++++++++++++++++++ .../nodes/Salesforce/UserInterface.ts | 10 ++ 3 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 packages/nodes-base/nodes/Salesforce/UserDescription.ts create mode 100644 packages/nodes-base/nodes/Salesforce/UserInterface.ts diff --git a/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts b/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts index 1e01664012..6ccb8676d2 100644 --- a/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts +++ b/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts @@ -71,7 +71,13 @@ import { import { ITask, } from './TaskInterface'; - +import { + userFields, + userOperations, +} from './UserDescription'; +import { + IUser, +} from './UserInterface'; export class Salesforce implements INodeType { description: INodeTypeDescription = { @@ -135,7 +141,11 @@ export class Salesforce implements INodeType { value: 'task', description: 'Represents a business activity such as making a phone call or other to-do items. In the user interface, and records are collectively referred to as activities.', }, - + { + name: 'User', + value: 'user', + description: 'Represents a person, which is one user in system.', + }, ], default: 'lead', description: 'Resource to consume.', @@ -154,6 +164,8 @@ export class Salesforce implements INodeType { ...taskFields, ...attachmentOperations, ...attachmentFields, + ...userOperations, + ...userFields, ], }; @@ -1885,6 +1897,35 @@ export class Salesforce implements INodeType { responseData = await salesforceApiRequest.call(this, 'GET', '/sobjects/attachment'); } } + if (resource === 'user') { + //https://developer.salesforce.com/docs/api-explorer/sobject/User/get-user-id + if (operation === 'get') { + const userId = this.getNodeParameter('userId', i) as string; + responseData = await salesforceApiRequest.call(this, 'GET', `/sobjects/user/${userId}`); + } + //https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_query.htm + if (operation === 'getAll') { + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + const options = this.getNodeParameter('options', i) as IDataObject; + const fields = ['id,name,email']; + if (options.fields) { + // @ts-ignore + fields.push(...options.fields.split(',')); + } + try { + if (returnAll) { + qs.q = `SELECT ${fields.join(',')} FROM User`; + responseData = await salesforceApiRequestAllItems.call(this, 'records', 'GET', '/query', {}, qs); + } else { + const limit = this.getNodeParameter('limit', i) as number; + qs.q = `SELECT ${fields.join(',')} FROM User Limit ${limit}`; + responseData = await salesforceApiRequestAllItems.call(this, 'records', 'GET', '/query', {}, qs); + } + } catch(err) { + throw new Error(`Salesforce Error: ${err}`); + } + } + } if (Array.isArray(responseData)) { returnData.push.apply(returnData, responseData as IDataObject[]); } else { diff --git a/packages/nodes-base/nodes/Salesforce/UserDescription.ts b/packages/nodes-base/nodes/Salesforce/UserDescription.ts new file mode 100644 index 0000000000..2bc9089f9a --- /dev/null +++ b/packages/nodes-base/nodes/Salesforce/UserDescription.ts @@ -0,0 +1,126 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const userOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'user', + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get a user', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all users', + }, + ], + default: 'get', + description: 'The operation to perform.' + } +] as INodeProperties[]; + +export const userFields = [ + /* -------------------------------------------------------------------------- */ + /* user:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'User ID', + name: 'userId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'get', + ], + }, + }, + description: 'Id of user that needs to be fetched' + }, + /* -------------------------------------------------------------------------- */ + /* user:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'getAll', + ], + } + }, + default: false, + description: 'If all results should be returned or only up to a given limit.' + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100 + }, + default: 50, + description: 'How many results to return.' + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Fields', + name: 'fields', + type: 'string', + default: '', + description: 'Fields to include separated by ,' + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Salesforce/UserInterface.ts b/packages/nodes-base/nodes/Salesforce/UserInterface.ts new file mode 100644 index 0000000000..035a1b6c9f --- /dev/null +++ b/packages/nodes-base/nodes/Salesforce/UserInterface.ts @@ -0,0 +1,10 @@ +export interface IUser { + Alias?: string; + Department?: string; + Division?: string; + Email?: string; + IsActive?: boolean; + MobilePhone?: string; + Title?: string; + Username?: string; +} From d64c767ebd0ac1c301791b785a8fc7f07ccd206c Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Sun, 6 Sep 2020 15:31:55 -0400 Subject: [PATCH 34/46] Salesforce (#932) * fix salesforce error while get all using nextRecordsUrl The uri for using nextRecordsUrl to get all data in salesforce node is error. The right usage is fixed in this pr. * :zap: Small changes * :zap Small fix Co-authored-by: YErii Co-authored-by: Jan --- .../PagerDutyOAuth2Api.credentials.ts | 12 ++++++------ .../SalesforceOAuth2Api.credentials.ts | 7 +++++++ .../nodes/Salesforce/GenericFunctions.ts | 12 ++++++++---- .../nodes/Salesforce/Salesforce.node.ts | 18 ++++++++++++++++++ 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/packages/nodes-base/credentials/PagerDutyOAuth2Api.credentials.ts b/packages/nodes-base/credentials/PagerDutyOAuth2Api.credentials.ts index d8e29cf2f7..edc5f8245b 100644 --- a/packages/nodes-base/credentials/PagerDutyOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/PagerDutyOAuth2Api.credentials.ts @@ -36,11 +36,11 @@ export class PagerDutyOAuth2Api implements ICredentialType { default: '', }, { - displayName: 'Authentication', - name: 'authentication', - type: 'hidden' as NodePropertyTypes, - default: 'header', - description: 'Method of authentication.', - }, + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'header', + description: 'Method of authentication.', + }, ]; } diff --git a/packages/nodes-base/credentials/SalesforceOAuth2Api.credentials.ts b/packages/nodes-base/credentials/SalesforceOAuth2Api.credentials.ts index e02489256a..eea91f8078 100644 --- a/packages/nodes-base/credentials/SalesforceOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/SalesforceOAuth2Api.credentials.ts @@ -37,5 +37,12 @@ export class SalesforceOAuth2Api implements ICredentialType { type: 'hidden' as NodePropertyTypes, default: '', }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'header', + description: 'Method of authentication.', + }, ]; } diff --git a/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts b/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts index d7bdc7ddb3..3c76dfc03a 100644 --- a/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Salesforce/GenericFunctions.ts @@ -1,21 +1,25 @@ -import { OptionsWithUri } from 'request'; +import { + OptionsWithUri, + } from 'request'; + import { IExecuteFunctions, IExecuteSingleFunctions, ILoadOptionsFunctions, } from 'n8n-core'; + import { IDataObject } from 'n8n-workflow'; -export async function salesforceApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any +export async function salesforceApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any const credentials = this.getCredentials('salesforceOAuth2Api'); const subdomain = ((credentials!.accessTokenUrl as string).match(/https:\/\/(.+).salesforce\.com/) || [])[1]; const options: OptionsWithUri = { method, body: method === "GET" ? undefined : body, qs, - uri: uri || `https://${subdomain}.salesforce.com/services/data/v39.0${resource}`, + uri: `https://${subdomain}.salesforce.com/services/data/v39.0${uri || endpoint}`, json: true }; try { @@ -39,7 +43,7 @@ export async function salesforceApiRequestAllItems(this: IExecuteFunctions | ILo do { responseData = await salesforceApiRequest.call(this, method, endpoint, body, query, uri); - uri = responseData.nextRecordsUrl; + uri = `${endpoint}/${responseData.nextRecordsUrl?.split('/')?.pop()}`; returnData.push.apply(returnData, responseData[propertyName]); } while ( responseData.nextRecordsUrl !== undefined && diff --git a/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts b/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts index 6ccb8676d2..4f6ddecc85 100644 --- a/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts +++ b/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts @@ -15,66 +15,84 @@ import { accountFields, accountOperations, } from './AccountDescription'; + import { IAccount, } from './AccountInterface'; + import { attachmentFields, attachmentOperations, } from './AttachmentDescription'; + import { IAttachment, } from './AttachmentInterface'; + import { ICampaignMember, } from './CampaignMemberInterface'; + import { caseFields, caseOperations, } from './CaseDescription'; + import { ICase, ICaseComment, } from './CaseInterface'; + import { contactFields, contactOperations, } from './ContactDescription'; + import { IContact, } from './ContactInterface'; + import { salesforceApiRequest, salesforceApiRequestAllItems, } from './GenericFunctions'; + import { leadFields, leadOperations, } from './LeadDescription'; + import { ILead, } from './LeadInterface'; + import { INote, } from './NoteInterface'; + import { opportunityFields, opportunityOperations, } from './OpportunityDescription'; + import { IOpportunity, } from './OpportunityInterface'; + import { taskFields, taskOperations, } from './TaskDescription'; + import { ITask, } from './TaskInterface'; + import { userFields, userOperations, } from './UserDescription'; + import { IUser, } from './UserInterface'; From f24afcd86f39fb73f91c3912d139a7e46f94df89 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Sun, 6 Sep 2020 15:38:18 -0400 Subject: [PATCH 35/46] :zap: Improvements to ClickUp-Node (#921) --- .../nodes-base/nodes/ClickUp/ClickUp.node.ts | 63 ++++++- .../nodes/ClickUp/ListDescription.ts | 67 +++++++ .../nodes/ClickUp/TaskDescription.ts | 175 +++++++++++++++++- 3 files changed, 297 insertions(+), 8 deletions(-) diff --git a/packages/nodes-base/nodes/ClickUp/ClickUp.node.ts b/packages/nodes-base/nodes/ClickUp/ClickUp.node.ts index 280f9dedee..ad1fcd1197 100644 --- a/packages/nodes-base/nodes/ClickUp/ClickUp.node.ts +++ b/packages/nodes-base/nodes/ClickUp/ClickUp.node.ts @@ -99,7 +99,7 @@ export class ClickUp implements INodeType { { name: 'clickUpApi', required: true, - } + }, ], properties: [ { @@ -296,7 +296,7 @@ export class ClickUp implements INodeType { const { tags } = await clickupApiRequest.call(this, 'GET', `/space/${spaceId}/tag`); for (const tag of tags) { const tagName = tag.name; - const tagId = tag.id; + const tagId = tag.name; returnData.push({ name: tagName, value: tagId, @@ -320,6 +320,23 @@ export class ClickUp implements INodeType { } return returnData; }, + + // Get all the custom fields to display them to user so that he can + // select them easily + async getCustomFields(this: ILoadOptionsFunctions): Promise { + const listId = this.getCurrentNodeParameter('list') as string; + const returnData: INodePropertyOptions[] = []; + const { fields } = await clickupApiRequest.call(this, 'GET', `/list/${listId}/field`); + for (const field of fields) { + const fieldName = field.name; + const fieldId = field.id; + returnData.push({ + name: fieldName, + value: fieldId, + }); + } + return returnData; + }, }, }; @@ -846,6 +863,22 @@ export class ClickUp implements INodeType { if (filters.dateUpdatedLt) { qs.date_updated_lt = new Date(filters.dateUpdatedLt as string).getTime(); } + if (filters.customFieldsUi) { + const customFieldsValues = (filters.customFieldsUi as IDataObject).customFieldsValues as IDataObject[]; + if (customFieldsValues) { + const customFields: IDataObject[] = []; + for (const customFieldValue of customFieldsValues) { + customFields.push({ + field_id: customFieldValue.fieldId, + operator: (customFieldValue.operator === 'equal') ? '=' : customFieldValue.operator, + value: customFieldValue.value as string, + }); + } + + qs.custom_fields = JSON.stringify(customFields); + } + } + const listId = this.getNodeParameter('list', i) as string; if (returnAll === true) { responseData = await clickupApiRequestAllItems.call(this, 'tasks', 'GET', `/list/${listId}/task`, {}, qs); @@ -855,6 +888,19 @@ export class ClickUp implements INodeType { responseData = responseData.splice(0, qs.limit); } } + if (operation === 'member') { + const taskId = this.getNodeParameter('id', i) as string; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + if (returnAll === true) { + responseData = await clickupApiRequest.call(this, 'GET', `/task/${taskId}/member`, {}, qs); + responseData = responseData.members; + } else { + qs.limit = this.getNodeParameter('limit', i) as number; + responseData = await clickupApiRequest.call(this, 'GET', `/task/${taskId}/member`, {}, qs); + responseData = responseData.members; + responseData = responseData.splice(0, qs.limit); + } + } if (operation === 'setCustomField') { const taskId = this.getNodeParameter('task', i) as string; const fieldId = this.getNodeParameter('field', i) as string; @@ -984,6 +1030,19 @@ export class ClickUp implements INodeType { responseData = await clickupApiRequest.call(this, 'POST', `/folder/${folderId}/list`, body); } } + if (operation === 'member') { + const listId = this.getNodeParameter('id', i) as string; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + if (returnAll === true) { + responseData = await clickupApiRequest.call(this, 'GET', `/list/${listId}/member`, {}, qs); + responseData = responseData.members; + } else { + qs.limit = this.getNodeParameter('limit', i) as number; + responseData = await clickupApiRequest.call(this, 'GET', `/list/${listId}/member`, {}, qs); + responseData = responseData.members; + responseData = responseData.splice(0, qs.limit); + } + } if (operation === 'customFields') { const listId = this.getNodeParameter('list', i) as string; responseData = await clickupApiRequest.call(this, 'GET', `/list/${listId}/field`); diff --git a/packages/nodes-base/nodes/ClickUp/ListDescription.ts b/packages/nodes-base/nodes/ClickUp/ListDescription.ts index 8fd4a8a13f..ed1de017c7 100644 --- a/packages/nodes-base/nodes/ClickUp/ListDescription.ts +++ b/packages/nodes-base/nodes/ClickUp/ListDescription.ts @@ -40,6 +40,11 @@ export const listOperations = [ value: 'getAll', description: 'Get all lists', }, + { + name: 'Member', + value: 'member', + description: 'Get list members', + }, { name: 'Update', value: 'update', @@ -229,6 +234,68 @@ export const listFields = [ ], }, /* -------------------------------------------------------------------------- */ +/* list:member */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'List ID', + name: 'id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'list', + ], + operation: [ + 'member', + ], + }, + }, + description: 'Task ID', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'list', + ], + operation: [ + 'member', + ], + }, + }, + default: true, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: [ + 'list', + ], + operation: [ + 'member', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 50, + description: 'How many results to return.', + }, +/* -------------------------------------------------------------------------- */ /* list:customFields */ /* -------------------------------------------------------------------------- */ { diff --git a/packages/nodes-base/nodes/ClickUp/TaskDescription.ts b/packages/nodes-base/nodes/ClickUp/TaskDescription.ts index 5d53390c1c..0d0d9e265b 100644 --- a/packages/nodes-base/nodes/ClickUp/TaskDescription.ts +++ b/packages/nodes-base/nodes/ClickUp/TaskDescription.ts @@ -31,12 +31,17 @@ export const taskOperations = [ description: 'Get a task', }, { - name: 'Get all', + name: 'Get All', value: 'getAll', description: 'Get all tasks', }, { - name: 'Set custom field', + name: 'Member', + value: 'member', + description: 'Get task members', + }, + { + name: 'Set Custom Field', value: 'setCustomField', description: 'Set a custom field', }, @@ -95,7 +100,7 @@ export const taskFields = [ loadOptionsMethod: 'getSpaces', loadOptionsDependsOn: [ 'team', - ] + ], }, required: true, }, @@ -190,7 +195,7 @@ export const taskFields = [ loadOptionsMethod: 'getLists', loadOptionsDependsOn: [ 'folder', - ] + ], }, required: true, }, @@ -239,7 +244,6 @@ export const taskFields = [ typeOptions: { loadOptionsMethod: 'getAssignees', }, - default: [], }, { @@ -302,6 +306,12 @@ export const taskFields = [ description: 'Integer mapping as 1 : Urgent, 2 : High, 3 : Normal, 4 : Low', default: 3, }, + { + displayName: 'Start Date', + name: 'startDate', + type: 'dateTime', + default: '', + }, { displayName: 'Start Date Time', name: 'startDateTime', @@ -457,6 +467,12 @@ export const taskFields = [ default: '', description: 'status' }, + { + displayName: 'Start Date', + name: 'startDate', + type: 'dateTime', + default: '', + }, { displayName: 'Start Date Time', name: 'startDateTime', @@ -631,7 +647,7 @@ export const taskFields = [ loadOptionsMethod: 'getLists', loadOptionsDependsOn: [ 'folder', - ] + ], }, required: true, }, @@ -712,6 +728,91 @@ export const taskFields = [ default: [], }, + { + displayName: 'Custom Fields', + name: 'customFieldsUi', + placeholder: 'Add Custom Field', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + description: 'Filter by custom fields ', + default: {}, + options: [ + { + name: 'customFieldsValues', + displayName: 'Custom Field', + values: [ + { + displayName: 'Field ID', + name: 'fieldId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getCustomFields', + }, + default: '', + description: 'The ID of the field to add custom field to.', + }, + { + displayName: 'Operator', + name: 'operator', + type: 'options', + options: [ + { + name: 'Equal', + value: 'equal', + }, + { + name: '<', + value: '<', + }, + { + name: '<=', + value: '<=', + }, + { + name: '>', + value: '>', + }, + { + name: '>=', + value: '>=', + }, + { + name: '!=', + value: '!=', + }, + { + name: 'Is Null', + value: 'IS NULL', + }, + { + name: 'Is Not Null', + value: 'IS NOT NULL', + }, + ], + default: 'equal', + description: 'The value to set on custom field.', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + displayOptions: { + hide: { + operator: [ + 'IS NULL', + 'IS NOT NULL', + ], + }, + }, + default: '', + description: 'The value to set on custom field.', + }, + ], + }, + ], + }, { displayName: 'Date Created Greater Than', name: 'dateCreatedGt', @@ -841,6 +942,68 @@ export const taskFields = [ description: 'task ID', }, /* -------------------------------------------------------------------------- */ +/* task:member */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Task ID', + name: 'id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'task', + ], + operation: [ + 'member', + ], + }, + }, + description: 'Task ID', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'task', + ], + operation: [ + 'member', + ], + }, + }, + default: true, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: [ + 'task', + ], + operation: [ + 'member', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 50, + description: 'How many results to return.', + }, +/* -------------------------------------------------------------------------- */ /* task:setCustomField */ /* -------------------------------------------------------------------------- */ { From 6aaae1ef6c994eb18d3f6807dd3524451908e63f Mon Sep 17 00:00:00 2001 From: Rupenieks <32895755+Rupenieks@users.noreply.github.com> Date: Sun, 6 Sep 2020 21:48:14 +0200 Subject: [PATCH 36/46] :bug: Typescript tsc could not be located when building node in Node-Dev (#918) * :zap: Added variation of Typescript executable path based on OS of user * :zap: indentation fix (removed spaces) --- packages/node-dev/src/Build.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/node-dev/src/Build.ts b/packages/node-dev/src/Build.ts index b631f46e45..ce2f16a263 100644 --- a/packages/node-dev/src/Build.ts +++ b/packages/node-dev/src/Build.ts @@ -63,8 +63,15 @@ export async function createCustomTsconfig () { export async function buildFiles (options?: IBuildOptions): Promise { options = options || {}; - // Get the path of the TypeScript cli of this project - const tscPath = join(__dirname, '../../node_modules/.bin/tsc'); + let typescriptPath; + + // Check for OS to designate correct tsc path + if (process.platform === 'win32') { + typescriptPath = '../../node_modules/TypeScript/lib/tsc'; + } else { + typescriptPath = '../../node_modules/.bin/tsc'; + } + const tscPath = join(__dirname, typescriptPath); const tsconfigData = await createCustomTsconfig(); From 313c8c14dc34888e746ebaaa79a258c2762ef642 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Sun, 6 Sep 2020 22:16:42 +0200 Subject: [PATCH 37/46] :zap: Overwrite credential data only if there is no data set yet --- packages/cli/src/CredentialsOverwrites.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/CredentialsOverwrites.ts b/packages/cli/src/CredentialsOverwrites.ts index ca09b87626..40b6419dab 100644 --- a/packages/cli/src/CredentialsOverwrites.ts +++ b/packages/cli/src/CredentialsOverwrites.ts @@ -38,7 +38,12 @@ class CredentialsOverwritesClass { } const returnData = JSON.parse(JSON.stringify(data)); - Object.assign(returnData, overwrites); + // Overwrite only if there is currently no data set + for (const key of Object.keys(overwrites)) { + if ([null, undefined, ''].includes(returnData[key])) { + returnData[key] = overwrites[key]; + } + } return returnData; } From 1479ce47e6299fa66d9efc6281e8273119f69fc5 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Mon, 7 Sep 2020 03:04:27 -0400 Subject: [PATCH 38/46] :zap: Add group resource to Segment-Node (#934) --- .../nodes/Segment/GroupDescription.ts | 528 ++++++++++++++++++ .../nodes/Segment/IdentifyDescription.ts | 4 +- .../nodes-base/nodes/Segment/Segment.node.ts | 244 +++++++- .../nodes/Segment/TrackDescription.ts | 4 +- .../nodes/Segment/TrackInterface.ts | 8 +- 5 files changed, 782 insertions(+), 6 deletions(-) create mode 100644 packages/nodes-base/nodes/Segment/GroupDescription.ts diff --git a/packages/nodes-base/nodes/Segment/GroupDescription.ts b/packages/nodes-base/nodes/Segment/GroupDescription.ts new file mode 100644 index 0000000000..89907526bb --- /dev/null +++ b/packages/nodes-base/nodes/Segment/GroupDescription.ts @@ -0,0 +1,528 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const groupOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'group', + ], + }, + }, + options: [ + { + name: 'Add', + value: 'add', + description: 'Add a user to a group', + }, + ], + default: 'add', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const groupFields = [ + +/* -------------------------------------------------------------------------- */ +/* group:add */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'User ID', + name: 'userId', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'group', + ], + operation: [ + 'add', + ], + }, + }, + required: false, + }, + { + displayName: 'Group ID', + name: 'groupId', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'group', + ], + operation: [ + 'add', + ], + }, + }, + description: 'A Group ID is the unique identifier which you recognize a group by in your own database', + required: true, + }, + { + displayName: 'Traits', + name: 'traits', + placeholder: 'Add Trait', + type: 'fixedCollection', + typeOptions: { + multipleValues: false, + }, + displayOptions: { + show: { + resource: [ + 'group', + ], + operation: [ + 'add', + ], + }, + }, + default: {}, + options: [ + { + name: 'traitsUi', + displayName: 'Trait', + values: [ + { + displayName: 'Email', + name: 'email', + type: 'string', + default: '', + description: 'Email address of a user', + }, + { + displayName: 'First Name', + name: 'firstname', + type: 'string', + default: '', + description: 'First name of a user', + }, + { + displayName: 'Last Name', + name: 'lastname', + type: 'string', + default: '', + description: 'Last name of a user', + }, + { + displayName: 'Gender', + name: 'gender', + type: 'string', + default: '', + description: 'Gender of a user', + }, + { + displayName: 'Phone', + name: 'phone', + type: 'string', + default: '', + description: 'Phone number of a user', + }, + { + displayName: 'Username', + name: 'username', + type: 'string', + default: '', + description: 'User’s username', + }, + { + displayName: 'Website', + name: 'website', + type: 'string', + default: '', + description: 'Website of a user', + }, + { + displayName: 'Age', + name: 'age', + type: 'number', + default: 1, + description: 'Age of a user', + }, + { + displayName: 'Avatar', + name: 'avatar', + type: 'string', + default: '', + description: 'URL to an avatar image for the user', + }, + { + displayName: 'Birthday', + name: 'birthday', + type: 'dateTime', + default: '', + description: 'User’s birthday', + }, + { + displayName: 'Created At', + name: 'createdAt', + type: 'dateTime', + default: '', + description: 'Date the user’s account was first created', + }, + { + displayName: 'Description', + name: 'description', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + description: 'Description of the user', + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + default: '', + description: 'Unique ID in your database for a user', + }, + { + displayName: 'Company', + name: 'company', + placeholder: 'Add Company', + type: 'fixedCollection', + typeOptions: { + multipleValues: false, + }, + default: {}, + options: [ + { + name: 'companyUi', + displayName: 'Company', + values: [ + { + displayName: 'ID', + name: 'id', + type: 'string', + default: '', + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + }, + { + displayName: 'Industry', + name: 'industry', + type: 'string', + default: '', + }, + { + displayName: 'Employee Count', + name: 'employeeCount', + type: 'number', + default: 1, + }, + { + displayName: 'Plan', + name: 'plan', + type: 'string', + default: '', + }, + ] + }, + ], + }, + { + displayName: 'Address', + name: 'address', + placeholder: 'Add Address', + type: 'fixedCollection', + typeOptions: { + multipleValues: false, + }, + default: {}, + options: [ + { + name: 'addressUi', + displayName: 'Address', + values: [ + { + displayName: 'Street', + name: 'street', + type: 'string', + default: '', + }, + { + displayName: 'City', + name: 'city', + type: 'string', + default: '', + }, + { + displayName: 'State', + name: 'state', + type: 'string', + default: '', + }, + { + displayName: 'Postal Code', + name: 'postalCode', + type: 'string', + default: '', + }, + { + displayName: 'Country', + name: 'country', + type: 'string', + default: '', + }, + ] + }, + ], + }, + ] + }, + ], + }, + { + displayName: 'Context', + name: 'context', + placeholder: 'Add Context', + type: 'fixedCollection', + typeOptions: { + multipleValues: false, + }, + displayOptions: { + show: { + resource: [ + 'group', + ], + operation: [ + 'add', + ], + }, + }, + default: {}, + options: [ + { + name: 'contextUi', + displayName: 'Context', + values: [ + { + displayName: 'Active', + name: 'active', + type: 'boolean', + default: '', + description: 'Whether a user is active', + }, + { + displayName: 'IP', + name: 'ip', + type: 'string', + default: '', + description: 'Current user’s IP address.', + }, + { + displayName: 'Locale', + name: 'locate', + type: 'string', + default: '', + description: 'Locale string for the current user, for example en-US.', + }, + { + displayName: 'Page', + name: 'page', + type: 'string', + default: '', + description: 'Dictionary of information about the current page in the browser, containing hash, path, referrer, search, title and url', + }, + { + displayName: 'Timezone', + name: 'timezone', + type: 'string', + default: '', + description: 'Timezones are sent as tzdata strings to add user timezone information which might be stripped from the timestamp, for example America/New_York', + }, + { + displayName: 'App', + name: 'app', + placeholder: 'Add App', + type: 'fixedCollection', + typeOptions: { + multipleValues: false, + }, + default: {}, + options: [ + { + name: 'appUi', + displayName: 'App', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + }, + { + displayName: 'Version', + name: 'version', + type: 'string', + default: '', + }, + { + displayName: 'Build', + name: 'build', + type: 'string', + default: '', + }, + ] + }, + ], + }, + { + displayName: 'Campaign', + name: 'campaign', + placeholder: 'Campaign App', + type: 'fixedCollection', + typeOptions: { + multipleValues: false, + }, + default: {}, + options: [ + { + name: 'campaignUi', + displayName: 'Campaign', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + }, + { + displayName: 'Source', + name: 'source', + type: 'string', + default: '', + }, + { + displayName: 'Medium', + name: 'medium', + type: 'string', + default: '', + }, + { + displayName: 'Term', + name: 'term', + type: 'string', + default: '', + }, + { + displayName: 'Content', + name: 'content', + type: 'string', + default: '', + }, + ] + }, + ], + }, + { + displayName: 'Device', + name: 'device', + placeholder: 'Add Device', + type: 'fixedCollection', + typeOptions: { + multipleValues: false, + }, + default: {}, + options: [ + { + name: 'deviceUi', + displayName: 'Device', + values: [ + { + displayName: 'ID', + name: 'id', + type: 'string', + default: '', + }, + { + displayName: 'Manufacturer', + name: 'manufacturer', + type: 'string', + default: '', + }, + { + displayName: 'Model', + name: 'model', + type: 'string', + default: '', + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + }, + { + displayName: 'Type', + name: 'type', + type: 'string', + default: '', + }, + { + displayName: 'Version', + name: 'version', + type: 'string', + default: '', + }, + ], + }, + ], + }, + ] + }, + ], + }, + { + displayName: 'Integration', + name: 'integrations', + placeholder: 'Add Integration', + type: 'fixedCollection', + typeOptions: { + multipleValues: false, + }, + displayOptions: { + show: { + resource: [ + 'group', + ], + operation: [ + 'add', + ], + }, + }, + default: {}, + options: [ + { + name: 'integrationsUi', + displayName: 'Integration', + values: [ + { + displayName: 'All', + name: 'all', + type: 'boolean', + default: true, + }, + { + displayName: 'Salesforce', + name: 'salesforce', + type: 'boolean', + default: false, + }, + ], + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Segment/IdentifyDescription.ts b/packages/nodes-base/nodes/Segment/IdentifyDescription.ts index f80fb2bc39..c80ffeeb69 100644 --- a/packages/nodes-base/nodes/Segment/IdentifyDescription.ts +++ b/packages/nodes-base/nodes/Segment/IdentifyDescription.ts @@ -1,4 +1,6 @@ -import { INodeProperties } from 'n8n-workflow'; +import { + INodeProperties, +} from 'n8n-workflow'; export const identifyOperations = [ { diff --git a/packages/nodes-base/nodes/Segment/Segment.node.ts b/packages/nodes-base/nodes/Segment/Segment.node.ts index bd281366b0..e8c11613a1 100644 --- a/packages/nodes-base/nodes/Segment/Segment.node.ts +++ b/packages/nodes-base/nodes/Segment/Segment.node.ts @@ -1,27 +1,41 @@ import { IExecuteFunctions, } from 'n8n-core'; + import { IDataObject, INodeExecutionData, INodeType, INodeTypeDescription, } from 'n8n-workflow'; + import { segmentApiRequest, } from './GenericFunctions'; + +import { + groupOperations, + groupFields, +} from './GroupDescription'; + import { identifyFields, identifyOperations, } from './IdentifyDescription'; + import { IIdentify, } from './IdentifyInterface'; + import { trackOperations, trackFields, } from './TrackDescription'; -import { ITrack } from './TrackInterface'; + +import { + ITrack, IGroup, +} from './TrackInterface'; + import * as uuid from 'uuid/v4'; export class Segment implements INodeType { @@ -43,7 +57,7 @@ export class Segment implements INodeType { { name: 'segmentApi', required: true, - } + }, ], properties: [ { @@ -51,10 +65,15 @@ export class Segment implements INodeType { name: 'resource', type: 'options', options: [ + { + name: 'Group', + value: 'group', + description: 'Group lets you associate an identified user with a group', + }, { name: 'Identify', value: 'identify', - description: 'Identify lets you tie a user to their actions.' + description: 'Identify lets you tie a user to their actions' }, { name: 'Track', @@ -65,6 +84,8 @@ export class Segment implements INodeType { default: 'identify', description: 'Resource to consume.', }, + ...groupOperations, + ...groupFields, ...identifyOperations, ...trackOperations, ...identifyFields, @@ -80,7 +101,224 @@ export class Segment implements INodeType { let responseData; const resource = this.getNodeParameter('resource', 0) as string; const operation = this.getNodeParameter('operation', 0) as string; + for (let i = 0; i < length; i++) { + if (resource === 'group') { + //https://segment.com/docs/connections/sources/catalog/libraries/server/http-api/#group + if (operation === 'add') { + const userId = this.getNodeParameter('userId', i) as string; + const groupId = this.getNodeParameter('groupId', i) as string; + const traits = (this.getNodeParameter('traits', i) as IDataObject).traitsUi as IDataObject; + const context = (this.getNodeParameter('context', i) as IDataObject).contextUi as IDataObject; + const integrations = (this.getNodeParameter('integrations', i) as IDataObject).integrationsUi as IDataObject; + const body: IGroup = { + groupId, + traits: { + company: {}, + address: {}, + }, + context: { + app: {}, + campaign: {}, + device: {}, + }, + integrations: {}, + }; + if (userId) { + body.userId = userId as string; + } else { + body.anonymousId = uuid(); + } + if (traits) { + if (traits.email) { + body.traits!.email = traits.email as string; + } + if (traits.firstname) { + body.traits!.firstname = traits.firstname as string; + } + if (traits.lastname) { + body.traits!.lastname = traits.lastname as string; + } + if (traits.gender) { + body.traits!.gender = traits.gender as string; + } + if (traits.phone) { + body.traits!.phone = traits.phone as string; + } + if (traits.username) { + body.traits!.username = traits.username as string; + } + if (traits.website) { + body.traits!.website = traits.website as string; + } + if (traits.age) { + body.traits!.age = traits.age as number; + } + if (traits.avatar) { + body.traits!.avatar = traits.avatar as string; + } + if (traits.birthday) { + body.traits!.birthday = traits.birthday as string; + } + if (traits.createdAt) { + body.traits!.createdAt = traits.createdAt as string; + } + if (traits.description) { + body.traits!.description = traits.description as string; + } + if (traits.id) { + body.traits!.id = traits.id as string; + } + if (traits.company) { + const company = (traits.company as IDataObject).companyUi as IDataObject; + if (company) { + if (company.id) { + //@ts-ignore + body.traits.company.id = company.id as string; + } + if (company.name) { + //@ts-ignore + body.traits.company.name = company.name as string; + } + if (company.industry) { + //@ts-ignore + body.traits.company.industry = company.industry as string; + } + if (company.employeeCount) { + //@ts-ignore + body.traits.company.employeeCount = company.employeeCount as number; + } + if (company.plan) { + //@ts-ignore + body.traits.company.plan = company.plan as string; + } + } + } + if (traits.address) { + const address = (traits.address as IDataObject).addressUi as IDataObject; + if (address) { + if (address.street) { + //@ts-ignore + body.traits.address.street = address.street as string; + } + if (address.city) { + //@ts-ignore + body.traits.address.city = address.city as string; + } + if (address.state) { + //@ts-ignore + body.traits.address.state = address.state as string; + } + if (address.postalCode) { + //@ts-ignore + body.traits.address.postalCode = address.postalCode as string; + } + if (address.country) { + //@ts-ignore + body.traits.address.country = address.country as string; + } + } + } + } + if (context) { + if (context.active) { + body.context!.active = context.active as boolean; + } + if (context.ip) { + body.context!.ip = context.ip as string; + } + if (context.locate) { + body.context!.locate = context.locate as string; + } + if (context.page) { + body.context!.page = context.page as string; + } + if (context.timezone) { + body.context!.timezone = context.timezone as string; + } + if (context.timezone) { + body.context!.timezone = context.timezone as string; + } + if (context.app) { + const app = (context.app as IDataObject).appUi as IDataObject; + if (app) { + if (app.name) { + //@ts-ignore + body.context.app.name = app.name as string; + } + if (app.version) { + //@ts-ignore + body.context.app.version = app.version as string; + } + if (app.build) { + //@ts-ignore + body.context.app.build = app.build as string; + } + } + } + if (context.campaign) { + const campaign = (context.campaign as IDataObject).campaignUi as IDataObject; + if (campaign) { + if (campaign.name) { + //@ts-ignore + body.context.campaign.name = campaign.name as string; + } + if (campaign.source) { + //@ts-ignore + body.context.campaign.source = campaign.source as string; + } + if (campaign.medium) { + //@ts-ignore + body.context.campaign.medium = campaign.medium as string; + } + if (campaign.term) { + //@ts-ignore + body.context.campaign.term = campaign.term as string; + } + if (campaign.content) { + //@ts-ignore + body.context.campaign.content = campaign.content as string; + } + } + } + + if (context.device) { + const device = (context.device as IDataObject).deviceUi as IDataObject; + if (device) { + if (device.id) { + //@ts-ignore + body.context.device.id = device.id as string; + } + if (device.manufacturer) { + //@ts-ignore + body.context.device.manufacturer = device.manufacturer as string; + } + if (device.model) { + //@ts-ignore + body.context.device.model = device.model as string; + } + if (device.type) { + //@ts-ignore + body.context.device.type = device.type as string; + } + if (device.version) { + //@ts-ignore + body.context.device.version = device.version as string; + } + } + } + } + if (integrations) { + if (integrations.all) { + body.integrations!.all = integrations.all as boolean; + } + if (integrations.salesforce) { + body.integrations!.salesforce = integrations.salesforce as boolean; + } + } + responseData = await segmentApiRequest.call(this, 'POST', '/group', body); + } + } if (resource === 'identify') { //https://segment.com/docs/connections/sources/catalog/libraries/server/http-api/#identify if (operation === 'create') { diff --git a/packages/nodes-base/nodes/Segment/TrackDescription.ts b/packages/nodes-base/nodes/Segment/TrackDescription.ts index 9eb029b517..ec67c25fcb 100644 --- a/packages/nodes-base/nodes/Segment/TrackDescription.ts +++ b/packages/nodes-base/nodes/Segment/TrackDescription.ts @@ -1,4 +1,6 @@ -import { INodeProperties } from 'n8n-workflow'; +import { + INodeProperties, +} from 'n8n-workflow'; export const trackOperations = [ { diff --git a/packages/nodes-base/nodes/Segment/TrackInterface.ts b/packages/nodes-base/nodes/Segment/TrackInterface.ts index 85ce0924e5..6fe9270012 100644 --- a/packages/nodes-base/nodes/Segment/TrackInterface.ts +++ b/packages/nodes-base/nodes/Segment/TrackInterface.ts @@ -1,4 +1,6 @@ -import { IDataObject } from "n8n-workflow"; +import { + IDataObject, +} from 'n8n-workflow'; export interface ITrack { event?: string; @@ -11,3 +13,7 @@ export interface ITrack { properties?: IDataObject; integrations?: IDataObject; } + +export interface IGroup extends ITrack{ + groupId: string; +} From 42bbe3006a618b16fa793669e3431d3143758950 Mon Sep 17 00:00:00 2001 From: Rupenieks <32895755+Rupenieks@users.noreply.github.com> Date: Mon, 7 Sep 2020 17:56:14 +0200 Subject: [PATCH 39/46] :sparkles: Sentry.io integration (#728) * :construction: setup - Added everything, need to test and add icon * Add icon * :construction: Node colour change * :construction: Fixed Descriptions * :heavy_check_mark: Tested, fixed up properties * :white_check_mark: Fixed issue of issue * :white_check_mark: Added create option for team & organization * :zap: Improvements * :zap: Fixed OAuth2 credentials scope * :zap: Adjusted descriptions, added loadOptions for organizations/projects, small fixes * :zap: Added Create Release, interfaces * :zap: Improvements to SentryIO-Node Co-authored-by: ricardo Co-authored-by: Jan Oberhauser --- .../credentials/SentryIoApi.credentials.ts | 17 + .../SentryIoOAuth2Api.credentials.ts | 46 ++ .../nodes-base/nodes/Github/Github.node.ts | 1 - .../nodes/SentryIo/EventDescription.ts | 204 +++++++ .../nodes/SentryIo/GenericFunctions.ts | 108 ++++ .../nodes-base/nodes/SentryIo/Interface.ts | 20 + .../nodes/SentryIo/IssueDescription.ts | 300 ++++++++++ .../nodes/SentryIo/OrganizationDescription.ts | 205 +++++++ .../nodes/SentryIo/ProjectDescription.ts | 194 ++++++ .../nodes/SentryIo/ReleaseDescription.ts | 429 ++++++++++++++ .../nodes/SentryIo/SentryIo.node.ts | 558 ++++++++++++++++++ .../nodes/SentryIo/TeamDescription.ts | 290 +++++++++ .../nodes-base/nodes/SentryIo/sentryio.png | Bin 0 -> 3804 bytes packages/nodes-base/package.json | 3 + 14 files changed, 2374 insertions(+), 1 deletion(-) create mode 100644 packages/nodes-base/credentials/SentryIoApi.credentials.ts create mode 100644 packages/nodes-base/credentials/SentryIoOAuth2Api.credentials.ts create mode 100644 packages/nodes-base/nodes/SentryIo/EventDescription.ts create mode 100644 packages/nodes-base/nodes/SentryIo/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/SentryIo/Interface.ts create mode 100644 packages/nodes-base/nodes/SentryIo/IssueDescription.ts create mode 100644 packages/nodes-base/nodes/SentryIo/OrganizationDescription.ts create mode 100644 packages/nodes-base/nodes/SentryIo/ProjectDescription.ts create mode 100644 packages/nodes-base/nodes/SentryIo/ReleaseDescription.ts create mode 100644 packages/nodes-base/nodes/SentryIo/SentryIo.node.ts create mode 100644 packages/nodes-base/nodes/SentryIo/TeamDescription.ts create mode 100644 packages/nodes-base/nodes/SentryIo/sentryio.png diff --git a/packages/nodes-base/credentials/SentryIoApi.credentials.ts b/packages/nodes-base/credentials/SentryIoApi.credentials.ts new file mode 100644 index 0000000000..576f27f2f0 --- /dev/null +++ b/packages/nodes-base/credentials/SentryIoApi.credentials.ts @@ -0,0 +1,17 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class SentryIoApi implements ICredentialType { + name = 'sentryIoApi'; + displayName = 'Sentry.io API'; + properties = [ + { + displayName: 'Token', + name: 'token', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/credentials/SentryIoOAuth2Api.credentials.ts b/packages/nodes-base/credentials/SentryIoOAuth2Api.credentials.ts new file mode 100644 index 0000000000..3948be6c66 --- /dev/null +++ b/packages/nodes-base/credentials/SentryIoOAuth2Api.credentials.ts @@ -0,0 +1,46 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class SentryIoOAuth2Api implements ICredentialType { + name = 'sentryIoOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'Sentry.io OAuth2 API'; + properties = [ + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://sentry.io/oauth/authorize/', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://sentry.io/oauth/token/', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: 'event:admin event:read org:read project:read project:releases team:read event:write org:admin project:write team:write project:admin team:admin', + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'body', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Github/Github.node.ts b/packages/nodes-base/nodes/Github/Github.node.ts index 408c0a4360..22bc7c0e70 100644 --- a/packages/nodes-base/nodes/Github/Github.node.ts +++ b/packages/nodes-base/nodes/Github/Github.node.ts @@ -14,7 +14,6 @@ import { getFileSha, } from './GenericFunctions'; - export class Github implements INodeType { description: INodeTypeDescription = { displayName: 'GitHub', diff --git a/packages/nodes-base/nodes/SentryIo/EventDescription.ts b/packages/nodes-base/nodes/SentryIo/EventDescription.ts new file mode 100644 index 0000000000..64aa81b8c6 --- /dev/null +++ b/packages/nodes-base/nodes/SentryIo/EventDescription.ts @@ -0,0 +1,204 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const eventOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'event', + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get event by ID', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all events', + } + ], + default: 'get', + description: 'The operation to perform', + }, +] as INodeProperties[]; + +export const eventFields = [ + /* -------------------------------------------------------------------------- */ + /* event:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Organization Slug', + name: 'organizationSlug', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getOrganizations', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'event', + ], + operation: [ + 'getAll', + ], + }, + }, + required: true, + description: 'The slug of the organization the events belong to', + }, + { + displayName: 'Project Slug', + name: 'projectSlug', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getProjects', + loadOptionsDependsOn: [ + 'organizationSlug', + ], + }, + default: '', + displayOptions: { + show: { + resource: [ + 'event', + ], + operation: [ + 'getAll', + ], + }, + }, + required: true, + description: 'The slug of the project the events belong to', + }, + { + displayName: 'Full', + name: 'full', + type: 'boolean', + default: true, + displayOptions: { + show: { + resource: [ + 'event', + ], + operation: [ + 'getAll', + ], + }, + }, + description: 'If this is set to true, then the event payload will include the full event body, including the stack trace', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'event', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'event', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return', + }, + /* -------------------------------------------------------------------------- */ + /* event:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Organization Slug', + name: 'organizationSlug', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getOrganizations', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'event', + ], + operation: [ + 'get', + ], + }, + }, + required: true, + description: 'The slug of the organization the events belong to', + }, + { + displayName: 'Project Slug', + name: 'projectSlug', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getProjects', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'event', + ], + operation: [ + 'get', + ], + }, + }, + required: true, + description: 'The slug of the project the events belong to', + }, + { + displayName: 'Event ID', + name: 'eventId', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'event', + ], + operation: [ + 'get', + ], + }, + }, + required: true, + description: 'The id of the event to retrieve (either the numeric primary-key or the hexadecimal id as reported by the raven client).', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/SentryIo/GenericFunctions.ts b/packages/nodes-base/nodes/SentryIo/GenericFunctions.ts new file mode 100644 index 0000000000..f578f35cad --- /dev/null +++ b/packages/nodes-base/nodes/SentryIo/GenericFunctions.ts @@ -0,0 +1,108 @@ +import { + OptionsWithUri + } from 'request'; + +import { + IExecuteFunctions, + IExecuteSingleFunctions, + IHookFunctions, + ILoadOptionsFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function sentryIoApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IWebhookFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + const authentication = this.getNodeParameter('authentication', 0); + + const options: OptionsWithUri = { + headers: {}, + method, + qs, + body, + uri: uri ||`https://sentry.io${resource}`, + json: true + }; + if (!Object.keys(body).length) { + delete options.body; + } + + if (Object.keys(option).length !== 0) { + Object.assign(options, option); + } + + if (options.qs.limit) { + delete options.qs.limit; + } + + try { + if (authentication === 'accessToken') { + + const credentials = this.getCredentials('sentryIoApi'); + + options.headers = { + Authorization: `Bearer ${credentials?.token}`, + }; + + console.log('options'); + console.log(options); + + + //@ts-ignore + return this.helpers.request(options); + + } else { + return await this.helpers.requestOAuth2!.call(this, 'sentryIoOAuth2Api', options); + } + + } catch (error) { + throw new Error(`Sentry.io Error: ${error}`); + } +} + +export async function sentryApiRequestAllItems(this: IHookFunctions | IExecuteFunctions| ILoadOptionsFunctions, method: string, resource: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + + let link; + + let uri: string | undefined; + + do { + responseData = await sentryIoApiRequest.call(this, method, resource, body, query, uri, { resolveWithFullResponse: true }); + link = responseData.headers.link; + uri = getNext(link); + returnData.push.apply(returnData, responseData.body); + if (query.limit && (query.limit >= returnData.length)) { + return; + } + } while ( + hasMore(link) + ); + + return returnData; +} + +function getNext(link: string) { + if (link === undefined) { + return; + } + const next = link.split(',')[1]; + if (next.includes('rel="next"')) { + return next.split(';')[0].replace('<', '').replace('>','').trim(); + } +} + +function hasMore(link: string) { + if (link === undefined) { + return; + } + const next = link.split(',')[1]; + if (next.includes('rel="next"')) { + return next.includes('results="true"'); + } +} diff --git a/packages/nodes-base/nodes/SentryIo/Interface.ts b/packages/nodes-base/nodes/SentryIo/Interface.ts new file mode 100644 index 0000000000..8e9b7f46cd --- /dev/null +++ b/packages/nodes-base/nodes/SentryIo/Interface.ts @@ -0,0 +1,20 @@ +export interface ICommit { + id: string; + repository?: string; + message?: string; + patch_set?: IPatchSet[]; + author_name?: string; + author_email?: string; + timestamp?: Date; +} + +export interface IPatchSet { + path: string; + type: string; +} + +export interface IRef { + commit: string; + repository: string; + previousCommit?: string; +} diff --git a/packages/nodes-base/nodes/SentryIo/IssueDescription.ts b/packages/nodes-base/nodes/SentryIo/IssueDescription.ts new file mode 100644 index 0000000000..cc2cb08932 --- /dev/null +++ b/packages/nodes-base/nodes/SentryIo/IssueDescription.ts @@ -0,0 +1,300 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const issueOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'issue', + ], + }, + }, + options: [ + { + name: 'Delete', + value: 'delete', + description: 'Delete an issue', + }, + { + name: 'Get', + value: 'get', + description: 'Get issue by ID', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all issues', + }, + { + name: 'Update', + value: 'update', + description: 'Update an issue', + }, + ], + default: 'get', + description: 'The operation to perform', + }, +] as INodeProperties[]; + +export const issueFields = [ + /* -------------------------------------------------------------------------- */ + /* issue:get/delete */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Issue ID', + name: 'issueId', + type: 'string', + default: '', + placeholder: '1234', + displayOptions: { + show: { + resource: [ + 'issue', + ], + operation: [ + 'get', + 'delete', + ], + }, + }, + required: true, + description: 'ID of issue to get', + }, + /* -------------------------------------------------------------------------- */ + /* issue:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Organization Slug', + name: 'organizationSlug', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getOrganizations', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'issue', + ], + operation: [ + 'getAll', + ], + }, + }, + required: true, + description: 'The slug of the organization the issues belong to', + }, + { + displayName: 'Project Slug', + name: 'projectSlug', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getProjects', + loadOptionsDependsOn: [ + 'organizationSlug', + ], + }, + default: '', + displayOptions: { + show: { + resource: [ + 'issue', + ], + operation: [ + 'getAll', + ], + }, + }, + required: true, + description: 'The slug of the project the issues belong to', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'issue', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'issue', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'issue', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Stats Period', + name: 'statsPeriod', + type: 'options', + default: '', + description: 'Time period of stats', + options: [ + { + name: '14 Days', + value: '14d' + }, + { + name: '24 Hours', + value: '24h' + }, + ] + }, + { + displayName: 'Short ID lookup', + name: 'shortIdLookUp', + type: 'boolean', + default: true, + description: 'If this is set to true then short IDs are looked up by this function as well. This can cause the return value of the function to return an event issue of a different project which is why this is an opt-in', + }, + ] + }, + /* -------------------------------------------------------------------------- */ + /* issue:update */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Issue ID', + name: 'issueId', + type: 'string', + default: '', + placeholder: '1234', + displayOptions: { + show: { + resource: [ + 'issue', + ], + operation: [ + 'update', + ], + }, + }, + required: true, + description: 'ID of issue to get', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'issue', + ], + operation: [ + 'update', + ], + }, + }, + options: [ + { + displayName: 'Assigned to', + name: 'assignedTo', + type: 'string', + default: '', + description: 'The actor id (or username) of the user or team that should be assigned to this issue', + }, + { + displayName: 'Has Seen', + name: 'hasSeen', + type: 'boolean', + default: true, + description: 'In case this API call is invoked with a user context this allows changing of the flag that indicates if the user has seen the event', + }, + { + displayName: 'Is Bookmarked', + name: 'isBookmarked', + type: 'boolean', + default: true, + description: 'In case this API call is invoked with a user context this allows changing of the bookmark flag', + }, + { + displayName: 'Is Public', + name: 'isPublic', + type: 'boolean', + default: true, + description: 'Sets the issue to public or private', + }, + { + displayName: 'Is Subscribed', + name: 'isSubscribed', + type: 'boolean', + default: true, + }, + { + displayName: 'Status', + name: 'status', + type: 'options', + default: '', + description: 'The new status for the issue', + options: [ + { + name: 'Ignored', + value: 'ignored' + }, + { + name: 'Resolved', + value: 'resolved' + }, + { + name: 'Resolved Next Release', + value: 'resolvedInNextRelease' + }, + { + name: 'Unresolved', + value: 'unresolved' + }, + ] + }, + ] + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/SentryIo/OrganizationDescription.ts b/packages/nodes-base/nodes/SentryIo/OrganizationDescription.ts new file mode 100644 index 0000000000..d579479088 --- /dev/null +++ b/packages/nodes-base/nodes/SentryIo/OrganizationDescription.ts @@ -0,0 +1,205 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const organizationOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'organization', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create an organization', + }, + { + name: 'Get', + value: 'get', + description: 'Get organization by slug', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all organizations', + } + ], + default: 'get', + description: 'The operation to perform', + }, +] as INodeProperties[]; + +export const organizationFields = [ + /* -------------------------------------------------------------------------- */ + /* organization:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'organization', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'organization', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'organization', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Member', + name: 'member', + type: 'boolean', + default: true, + description: 'Restrict results to organizations which you have membership', + }, + { + displayName: 'Owner', + name: 'owner', + type: 'boolean', + default: true, + description: 'Restrict results to organizations which you are the owner', + }, + ] + }, + /* -------------------------------------------------------------------------- */ + /* organization:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Organization Slug', + name: 'organizationSlug', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getOrganizations', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'organization', + ], + operation: [ + 'get', + ], + }, + }, + required: true, + description: 'The slug of the organization the team should be created for', + }, + /* -------------------------------------------------------------------------- */ + /* organization:create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'organization', + ], + operation: [ + 'create', + ], + }, + }, + required: true, + description: 'The slug of the organization the team should be created for', + }, + { + displayName: 'Agree to Terms', + name: 'agreeTerms', + type: 'boolean', + default: false, + displayOptions: { + show: { + resource: [ + 'organization', + ], + operation: [ + 'create', + ], + }, + }, + description: 'Signaling you agree to the applicable terms of service and privacy policy of Sentry.io', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'organization', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Slug', + name: 'slug', + type: 'string', + default: '', + description: 'The unique URL slug for this organization. If this is not provided a slug is automatically generated based on the name', + }, + ] + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/SentryIo/ProjectDescription.ts b/packages/nodes-base/nodes/SentryIo/ProjectDescription.ts new file mode 100644 index 0000000000..44c2726935 --- /dev/null +++ b/packages/nodes-base/nodes/SentryIo/ProjectDescription.ts @@ -0,0 +1,194 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const projectOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'project', + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get project by ID', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all projects', + } + ], + default: 'get', + description: 'The operation to perform', + }, +] as INodeProperties[]; + +export const projectFields = [ + /* -------------------------------------------------------------------------- */ + /* project:create/get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Organization Slug', + name: 'organizationSlug', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getOrganizations', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'project', + ], + operation: [ + 'create', + 'get', + 'update', + 'delete', + ], + }, + }, + required: true, + description: 'The slug of the organization the events belong to', + }, + { + displayName: 'Project Slug', + name: 'projectSlug', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getProjects', + loadOptionsDependsOn: [ + 'organizationSlug', + ], + }, + default: '', + displayOptions: { + show: { + resource: [ + 'project', + ], + operation: [ + 'get', + ], + }, + }, + required: true, + description: 'The slug of the project to retrieve', + }, + { + displayName: 'Team Slug', + name: 'teamSlug', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'project', + ], + operation: [ + 'create', + 'update', + 'delete', + ], + }, + }, + required: true, + description: 'The slug of the team to create a new project for', + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'project', + ], + operation: [ + 'create', + ], + }, + }, + required: true, + description: 'The name for the new project', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'project', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Slug', + name: 'slug', + type: 'string', + default: '', + description: 'Optionally a slug for the new project. If it’s not provided a slug is generated from the name', + }, + ] + }, + /* -------------------------------------------------------------------------- */ + /* project:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'project', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'project', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/SentryIo/ReleaseDescription.ts b/packages/nodes-base/nodes/SentryIo/ReleaseDescription.ts new file mode 100644 index 0000000000..5c5686f9af --- /dev/null +++ b/packages/nodes-base/nodes/SentryIo/ReleaseDescription.ts @@ -0,0 +1,429 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const releaseOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'release', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a release', + }, + { + name: 'Get', + value: 'get', + description: 'Get release by version identifier', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all releases', + }, + ], + default: 'get', + description: 'The operation to perform', + }, +] as INodeProperties[]; + +export const releaseFields = [ + /* -------------------------------------------------------------------------- */ + /* release:getAll */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Organization Slug', + name: 'organizationSlug', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getOrganizations', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'release', + ], + operation: [ + 'getAll', + ], + }, + }, + required: true, + description: 'The slug of the organization the releases belong to', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'release', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'release', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'release', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Query', + name: 'query', + type: 'string', + default: '', + description: 'This parameter can be used to create a “starts with” filter for the version', + }, + ] + }, + /* -------------------------------------------------------------------------- */ + /* release:get */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Organization Slug', + name: 'organizationSlug', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getOrganizations', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'release', + ], + operation: [ + 'get', + ], + }, + }, + required: true, + description: 'The slug of the organization the release belongs to', + }, + { + displayName: 'Version', + name: 'version', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'release', + ], + operation: [ + 'get', + ], + }, + }, + required: true, + description: 'The version identifier of the release', + }, + /* -------------------------------------------------------------------------- */ + /* release:create */ + /* -------------------------------------------------------------------------- */ + { + displayName: 'Organization Slug', + name: 'organizationSlug', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getOrganizations', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'release', + ], + operation: [ + 'create', + ], + }, + }, + required: true, + description: 'The slug of the organization the release belongs to', + }, + { + displayName: 'Version', + name: 'version', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'release', + ], + operation: [ + 'create', + ], + }, + }, + required: true, + description: ' a version identifier for this release. Can be a version number, a commit hash etc', + }, + { + displayName: 'URL', + name: 'url', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'release', + ], + operation: [ + 'create', + ], + }, + }, + required: true, + description: 'A URL that points to the release. This can be the path to an online interface to the sourcecode for instance', + }, + { + displayName: 'Projects', + name: 'projects', + type: 'multiOptions', + typeOptions: { + loadOptionsMethod: 'getProjects', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'release', + ], + operation: [ + 'create', + ], + }, + }, + required: true, + description: 'A list of project slugs that are involved in this release', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'release', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Date released', + name: 'dateReleased', + type: 'dateTime', + default: '', + description: 'an optional date that indicates when the release went live. If not provided the current time is assumed', + }, + { + displayName: 'Commits', + name: 'commits', + description: 'an optional list of commit data to be associated with the release', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + name: 'commitProperties', + displayName: 'Commit Properties', + values: [ + { + displayName: 'Id', + name: 'id', + type: 'string', + default: '', + description: 'the sha of the commit', + required: true + }, + { + displayName: 'Author Email', + name: 'authorEmail', + type: 'string', + default: '', + description: 'Authors email', + }, + { + displayName: 'Author Name', + name: 'authorName', + type: 'string', + default: '', + description: 'Name of author', + }, + { + displayName: 'Message', + name: 'message', + type: 'string', + default: '', + description: 'Message of commit', + }, + { + displayName: 'Patch Set', + name: 'patchSet', + description: 'A list of the files that have been changed in the commit. Specifying the patch_set is necessary to power suspect commits and suggested assignees', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + name: 'patchSetProperties', + displayName: 'Patch Set Properties', + values: [ + { + displayName: 'Path', + name: 'path', + type: 'string', + default: '', + description: 'he path to the file. Both forward and backward slashes are supported', + required: true + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + default: '', + description: 'he types of changes that happend in that commit', + options: [ + { + name: 'Add', + value: 'add' + }, + { + name: 'Modify', + value: 'modify' + }, + { + name: 'Delete', + value: 'delete' + }, + ] + }, + ] + }, + ], + }, + { + displayName: 'Repository', + name: 'repository', + type: 'string', + default: '', + description: 'Repository name', + }, + { + displayName: 'Timestamp', + name: 'timestamp', + type: 'dateTime', + default: '', + description: 'Timestamp of commit', + }, + ] + }, + ], + }, + { + displayName: 'Refs', + name: 'refs', + description: 'an optional way to indicate the start and end commits for each repository included in a release', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + name: 'refProperties', + displayName: 'Ref Properties', + values: [ + { + displayName: 'Commit', + name: 'commit', + type: 'string', + default: '', + description: 'the head sha of the commit', + required: true + }, + { + displayName: 'Repository', + name: 'repository', + type: 'string', + default: '', + description: 'Repository name', + required: true + }, + { + displayName: 'Previous Commit', + name: 'previousCommit', + type: 'string', + default: '', + description: 'the sha of the HEAD of the previous release', + }, + ] + }, + ], + }, + ] + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/SentryIo/SentryIo.node.ts b/packages/nodes-base/nodes/SentryIo/SentryIo.node.ts new file mode 100644 index 0000000000..74213e5435 --- /dev/null +++ b/packages/nodes-base/nodes/SentryIo/SentryIo.node.ts @@ -0,0 +1,558 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, + ILoadOptionsFunctions, + INodePropertyOptions, +} from 'n8n-workflow'; + +import { + eventOperations, + eventFields, +} from './EventDescription'; + +import { + issueOperations, + issueFields, +} from './IssueDescription'; + +import { + organizationFields, + organizationOperations, +} from './OrganizationDescription'; + +import { + projectOperations, + projectFields, +} from './ProjectDescription'; + +import { + releaseOperations, + releaseFields, +} from './ReleaseDescription'; + +import { + teamOperations, + teamFields, +} from './TeamDescription'; + +import { + sentryIoApiRequest, + sentryApiRequestAllItems, +} from './GenericFunctions'; +import { ICommit, IPatchSet, IRef } from './Interface'; + +export class SentryIo implements INodeType { + description: INodeTypeDescription = { + displayName: 'Sentry.io', + name: 'sentryIo', + icon: 'file:sentryio.png', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Sentry.io API', + defaults: { + name: 'Sentry.io', + color: '#000000', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'sentryIoOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, + { + name: 'sentryIoApi', + required: true, + displayOptions: { + show: { + authentication: [ + 'accessToken', + ], + }, + }, + }, + ], + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + description: 'The resource to operate on.', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Event', + value: 'event', + }, + { + name: 'Issue', + value: 'issue', + }, + { + name: 'Project', + value: 'project', + }, + { + name: 'Release', + value: 'release', + }, + { + name: 'Organization', + value: 'organization', + }, + { + name: 'Team', + value: 'team', + }, + ], + default: 'event', + description: 'Resource to consume.', + }, + + // EVENT + ...eventOperations, + ...eventFields, + + // ISSUE + ...issueOperations, + ...issueFields, + + // ORGANIZATION + ...organizationOperations, + ...organizationFields, + + // PROJECT + ...projectOperations, + ...projectFields, + + // RELEASE + ...releaseOperations, + ...releaseFields, + + // TEAM + ...teamOperations, + ...teamFields + ], + }; + + methods = { + loadOptions: { + // Get all organizations so they can be displayed easily + async getOrganizations(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const organizations = await sentryApiRequestAllItems.call(this, 'GET', `/api/0/organizations/`, {}); + + for (const organization of organizations) { + returnData.push({ + name: organization.slug, + value: organization.slug, + }); + } + + returnData.sort((a, b) => { + if (a.name < b.name) { return -1; } + if (a.name > b.name) { return 1; } + return 0; + }); + + return returnData; + }, + // Get all projects so can be displayed easily + async getProjects(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const projects = await sentryApiRequestAllItems.call(this, 'GET', `/api/0/projects/`, {}); + + const organizationSlug = this.getNodeParameter('organizationSlug') as string; + + for (const project of projects) { + + if (organizationSlug !== project.organization.slug) { + continue; + } + + returnData.push({ + name: project.slug, + value: project.slug, + }); + } + + returnData.sort((a, b) => { + if (a.name < b.name) { return -1; } + if (a.name > b.name) { return 1; } + return 0; + }); + + return returnData; + }, + }, + }; + + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const length = items.length as unknown as number; + let responseData; + const qs: IDataObject = {}; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + for (let i = 0; i < length; i++) { + if (resource === 'event') { + if (operation === 'getAll') { + const organizationSlug = this.getNodeParameter('organizationSlug', i) as string; + const projectSlug = this.getNodeParameter('projectSlug', i) as string; + const full = this.getNodeParameter('full', i) as boolean; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + const endpoint = `/api/0/projects/${organizationSlug}/${projectSlug}/events/`; + + if (returnAll === false) { + const limit = this.getNodeParameter('limit', i) as number; + qs.limit = limit; + } + + qs.full = full; + + responseData = await sentryApiRequestAllItems.call(this, 'GET', endpoint, {}, qs); + + if (returnAll === false) { + const limit = this.getNodeParameter('limit', i) as number; + responseData = responseData.splice(0, limit); + } + } + if (operation === 'get') { + const organizationSlug = this.getNodeParameter('organizationSlug', i) as string; + const projectSlug = this.getNodeParameter('projectSlug', i) as string; + const eventId = this.getNodeParameter('eventId', i) as string; + + const endpoint = `/api/0/projects/${organizationSlug}/${projectSlug}/events/${eventId}/`; + + responseData = await sentryIoApiRequest.call(this, 'GET', endpoint, qs); + } + } + if (resource === 'issue') { + if (operation === 'getAll') { + const organizationSlug = this.getNodeParameter('organizationSlug', i) as string; + const projectSlug = this.getNodeParameter('projectSlug', i) as string; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + const endpoint = `/api/0/projects/${organizationSlug}/${projectSlug}/issues/`; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (additionalFields.statsPeriod) { + qs.statsPeriod = additionalFields.statsPeriod as string; + } + if (additionalFields.shortIdLookup) { + qs.shortIdLookup = additionalFields.shortIdLookup as boolean; + } + if (additionalFields.query) { + qs.query = additionalFields.query as string; + } + + if (returnAll === false) { + const limit = this.getNodeParameter('limit', i) as number; + qs.limit = limit; + } + + responseData = await sentryApiRequestAllItems.call(this, 'GET', endpoint, {}, qs); + + if (returnAll === false) { + const limit = this.getNodeParameter('limit', i) as number; + responseData = responseData.splice(0, limit); + } + + } + if (operation === 'get') { + const issueId = this.getNodeParameter('issueId', i) as string; + const endpoint = `/api/0/issues/${issueId}/`; + + responseData = await sentryIoApiRequest.call(this, 'GET', endpoint, qs); + } + if (operation === 'delete') { + const issueId = this.getNodeParameter('issueId', i) as string; + const endpoint = `/api/0/issues/${issueId}/`; + + responseData = await sentryIoApiRequest.call(this, 'DELETE', endpoint, qs); + + responseData = { success: true }; + } + if (operation === 'update') { + const issueId = this.getNodeParameter('issueId', i) as string; + const endpoint = `/api/0/issues/${issueId}/`; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (additionalFields.status) { + qs.status = additionalFields.status as string; + } + if (additionalFields.assignedTo) { + qs.assignedTo = additionalFields.assignedTo as string; + } + if (additionalFields.hasSeen) { + qs.hasSeen = additionalFields.hasSeen as boolean; + } + if (additionalFields.isBookmarked) { + qs.isBookmarked = additionalFields.isBookmarked as boolean; + } + if (additionalFields.isSubscribed) { + qs.isSubscribed = additionalFields.isSubscribed as boolean; + } + if (additionalFields.isPublic) { + qs.isPublic = additionalFields.isPublic as boolean; + } + + responseData = await sentryIoApiRequest.call(this, 'PUT', endpoint, qs); + } + } + if (resource === 'organization') { + if (operation === 'get') { + const organizationSlug = this.getNodeParameter('organizationSlug', i) as string; + const endpoint = `/api/0/organizations/${organizationSlug}/`; + + responseData = await sentryIoApiRequest.call(this, 'GET', endpoint, qs); + } + if (operation === 'getAll') { + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + const endpoint = `/api/0/organizations/`; + + if (additionalFields.member) { + qs.member = additionalFields.member as boolean; + } + if (additionalFields.owner) { + qs.owner = additionalFields.owner as boolean; + } + + if (returnAll === false) { + const limit = this.getNodeParameter('limit', i) as number; + qs.limit = limit; + } + + responseData = await sentryApiRequestAllItems.call(this, 'GET', endpoint, {}, qs); + + if (responseData === undefined) { + responseData = []; + } + + if (returnAll === false) { + const limit = this.getNodeParameter('limit', i) as number; + responseData = responseData.splice(0, limit); + } + } + if (operation === 'create') { + const name = this.getNodeParameter('name', i) as string; + const agreeTerms = this.getNodeParameter('agreeTerms', i) as boolean; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + const endpoint = `/api/0/organizations/`; + + qs.name = name; + qs.agreeTerms = agreeTerms; + + if (additionalFields.slug) { + qs.slug = additionalFields.slug as string; + } + + responseData = await sentryIoApiRequest.call(this, 'POST', endpoint, qs); + } + } + if (resource === 'project') { + if (operation === 'get') { + const organizationSlug = this.getNodeParameter('organizationSlug', i) as string; + const projectSlug = this.getNodeParameter('projectSlug', i) as string; + const endpoint = `/api/0/projects/${organizationSlug}/${projectSlug}/`; + + responseData = await sentryIoApiRequest.call(this, 'GET', endpoint, qs); + } + if (operation === 'getAll') { + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + const endpoint = `/api/0/projects/`; + + if (returnAll === false) { + const limit = this.getNodeParameter('limit', i) as number; + qs.limit = limit; + } + + responseData = await sentryApiRequestAllItems.call(this, 'GET', endpoint, {}, qs); + + if (returnAll === false) { + const limit = this.getNodeParameter('limit', i) as number; + responseData = responseData.splice(0, limit); + } + } + } + if (resource === 'release') { + if (operation === 'get') { + const organizationSlug = this.getNodeParameter('organizationSlug', i) as string; + const version = this.getNodeParameter('version', i) as string; + const endpoint = `/api/0/organizations/${organizationSlug}/releases/${version}/`; + + responseData = await sentryIoApiRequest.call(this, 'GET', endpoint, qs); + } + if (operation === 'getAll') { + const organizationSlug = this.getNodeParameter('organizationSlug', i) as string; + const endpoint = `/api/0/organizations/${organizationSlug}/releases/`; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + if (additionalFields.query) { + qs.query = additionalFields.query as string; + } + + if (returnAll === false) { + const limit = this.getNodeParameter('limit', i) as number; + qs.limit = limit; + } + + responseData = await sentryApiRequestAllItems.call(this, 'GET', endpoint, {}, qs); + + if (returnAll === false) { + const limit = this.getNodeParameter('limit', i) as number; + responseData = responseData.splice(0, limit); + } + } + + if (operation === 'create') { + const organizationSlug = this.getNodeParameter('organizationSlug', i) as string; + const endpoint = `/api/0/organizations/${organizationSlug}/releases/`; + const version = this.getNodeParameter('version', i) as string; + const url = this.getNodeParameter('url', i) as string; + const projects = this.getNodeParameter('projects', i) as string[]; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (additionalFields.dateReleased) { + qs.dateReleased = additionalFields.dateReleased as string; + } + + qs.version = version; + qs.url = url; + qs.projects = projects; + + if (additionalFields.commits) { + const commits: ICommit[] = []; + //@ts-ignore + // tslint:disable-next-line: no-any + additionalFields.commits.commitProperties.map((commit: any) => { + const commitObject: ICommit = { id: commit.id }; + + if (commit.repository) { + commitObject.repository = commit.repository; + } + if (commit.message) { + commitObject.message = commit.message; + } + if (commit.patchSet && Array.isArray(commit.patchSet)) { + commit.patchSet.patchSetProperties.map((patchSet: IPatchSet) => { + commitObject.patch_set?.push(patchSet); + }); + } + if (commit.authorName) { + commitObject.author_name = commit.authorName; + } + if (commit.authorEmail) { + commitObject.author_email = commit.authorEmail; + } + if (commit.timestamp) { + commitObject.timestamp = commit.timestamp; + } + + commits.push(commitObject); + }); + + qs.commits = commits; + } + if (additionalFields.refs) { + const refs: IRef[] = []; + //@ts-ignore + additionalFields.refs.refProperties.map((ref: IRef) => { + refs.push(ref); + }); + + qs.refs = refs; + } + + responseData = await sentryIoApiRequest.call(this, 'POST', endpoint, qs); + } + } + if (resource === 'team') { + if (operation === 'get') { + const organizationSlug = this.getNodeParameter('organizationSlug', i) as string; + const teamSlug = this.getNodeParameter('teamSlug', i) as string; + const endpoint = `/api/0/teams/${organizationSlug}/${teamSlug}/`; + + responseData = await sentryIoApiRequest.call(this, 'GET', endpoint, qs); + } + if (operation === 'getAll') { + const organizationSlug = this.getNodeParameter('organizationSlug', i) as string; + const endpoint = `/api/0/organizations/${organizationSlug}/teams/`; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + if (returnAll === false) { + const limit = this.getNodeParameter('limit', i) as number; + qs.limit = limit; + } + + responseData = await sentryApiRequestAllItems.call(this, 'GET', endpoint, {}, qs); + + if (returnAll === false) { + const limit = this.getNodeParameter('limit', i) as number; + responseData = responseData.splice(0, limit); + } + } + + if (operation === 'create') { + const organizationSlug = this.getNodeParameter('organizationSlug', i) as string; + const name = this.getNodeParameter('name', i) as string; + const endpoint = `/api/0/organizations/${organizationSlug}/teams/`; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + qs.name = name; + + if (additionalFields.slug) { + qs.slug = additionalFields.slug; + } + + responseData = await sentryIoApiRequest.call(this, 'POST', endpoint, qs); + } + } + + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else { + returnData.push(responseData as IDataObject); + } + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/SentryIo/TeamDescription.ts b/packages/nodes-base/nodes/SentryIo/TeamDescription.ts new file mode 100644 index 0000000000..f44e6853d2 --- /dev/null +++ b/packages/nodes-base/nodes/SentryIo/TeamDescription.ts @@ -0,0 +1,290 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const teamOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'team', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a new team', + }, + { + name: 'Get', + value: 'get', + description: 'Get team by slug', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all teams', + } + ], + default: 'get', + description: 'The operation to perform', + }, +] as INodeProperties[]; + +export const teamFields = [ +/* -------------------------------------------------------------------------- */ +/* team:getAll */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Organization Slug', + name: 'organizationSlug', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getOrganizations', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'team', + ], + operation: [ + 'getAll', + ], + }, + }, + required: true, + description: 'The slug of the organization for which the teams should be listed', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'team', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'team', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return', + }, +/* -------------------------------------------------------------------------- */ +/* team:get */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Organization Slug', + name: 'organizationSlug', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getOrganizations', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'team', + ], + operation: [ + 'get', + ], + }, + }, + required: true, + description: 'The slug of the organization the team belongs to', + }, + { + displayName: 'Team Slug', + name: 'teamSlug', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'team', + ], + operation: [ + 'get', + ], + }, + }, + required: true, + description: 'The slug of the team to get', + }, +/* -------------------------------------------------------------------------- */ +/* team:create */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Organization Slug', + name: 'organizationSlug', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getOrganizations', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'team', + ], + operation: [ + 'create', + ], + }, + }, + required: true, + description: 'The slug of the organization the team belongs to', + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'team', + ], + operation: [ + 'create', + ], + }, + }, + required: true, + description: 'The name of the team', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'team', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Slug', + name: 'slug', + type: 'string', + default: '', + description: 'The optional slug for this team. If not provided it will be auto generated from the name', + }, + ] + }, +/* -------------------------------------------------------------------------- */ +/* team:update */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Organization Slug', + name: 'organizationSlug', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getOrganizations', + }, + default: '', + displayOptions: { + show: { + resource: [ + 'team', + ], + operation: [ + 'update', 'delete' + ], + }, + }, + required: true, + description: 'The slug of the organization the team belongs to', + }, + { + displayName: 'Team Slug', + name: 'teamSlug', + type: 'string', + default: '', + displayOptions: { + show: { + resource: [ + 'team', + ], + operation: [ + 'update', 'delete' + ], + }, + }, + required: true, + description: 'The slug of the team to get', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'team', + ], + operation: [ + 'update', + ], + }, + }, + options: [ + { + displayName: 'Slug', + name: 'slug', + type: 'string', + default: '', + description: 'The new slug of the team. Must be unique and available', + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: 'The new name of the team', + }, + ] + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/SentryIo/sentryio.png b/packages/nodes-base/nodes/SentryIo/sentryio.png new file mode 100644 index 0000000000000000000000000000000000000000..31fd69c1b4294f6d4a564aba9652d3fef3e892a5 GIT binary patch literal 3804 zcmZ`+c{r5a-@h}mHFhD{(xk~YOJbUkWo%_n_I(U9_GRqb5P4=a6ycGbPzfPPnoJ1^ z*|*9PPef=zp}~9mUGE>y`@639y03Ge>wM4W`#In9Ip=)OeN!#W40(AZcmM$4H8#Ro zfwjg#;o<<_Sk`0+Sh0KSnCbvPeZ~=%`(dyxi#M_|1%POI0H7oTzz%4ld;x$kBmgYC zfnI+DfLKt$Gs^=a4v))*IAH(aDPdG)ffnu{Bl}PQ;Cp>g4rMFy9S4n^WMfl(&VM)| zJP^rY1kX(XfZ7}5bgYTftNE9M9LJ+5ZD}!w1Z25yvU4ZR>LnS`V6U4!sSVlprHk&q zgE!1~JqY+p8VY}A!ua|&jXY_Z+4**&(5o|CjP5Z>t4aNAD@V;-xg^JJ!EQ_~(@u(P zQvrlzrJK_fPgvf@mieWXeN5nJ4Sl!UwqCrxzjzVT29&^gnsICnly7Y25VI2Ldv16m zQ7(vWVgyB)!Vd?yk|^If9~sOB&lXb{lw$+HtLQ5aEk#P$VHESkb|(B0^mZbhd1BU; zGR@1it^ZwR)-~}rg`i_8!ac#sIzG@mEfH^u=6eoh*<5?(y)QW0knvyxvJa^GT1bt! zNEZ@VRXBhTF!!-w1ouVzZUA>VFmO0+PTzSfqaj5n%q=ac46f)`fcZl>r3gXkW`$UNt>T$2rttiHY~+q=H!g`r<0*Up$p%RxQ1~y-51-*qGrX$gXxlQZjmh^q|jc z=uDou)^>8g*1jK}?ayw!S)oRE-*yzt*kfjIcV)7Xli}hTfJcf?G$&jn5V)IN@m1vIbs{%$kRnle+%Cy>dv z8@>Cjltk6}zJL5E*)@|;T%j)`VTn6)#;G;D&`zmSW>LYRCa179{O3PeoC9CUO53rL z3HzAwI@f&%N{6jW)aUHF=mAf3ca!h<_dmN2l@M8GRe5e z4WJy5fN|JMHwsQ8z3JtpjA5!xb=+2Bb#?X9+Hz%04c%HnA1Mx#&It7n&jA;ACHw-N zPA94^vk9{wYMks1k-ygSS?-q6&A!@!d9p|I2bp*`y1}(czsgYG)0&!^l%&AHRHdFs zhRbsogtD^oM1Fox&e+#Y>#MfGtM8Sj^IhiZG4hR6e+EMwdOel@sRevCyyYo*8!ei4 z_}WVf%ve=TZH|mZ+1Q{H@I{_z>dZIqVE07R^uCGJUud$aDKEI$#*=+Mck z{XdU>9{@NZhxpod6apDPiqZ;vi*kD`#g@+X!{}th~ud`^;`wqBg}%)+l4x zI4+Ly;0D}7E#KbG&JEnEkxJ<{)p)m{n-3m5Nae)j_Wk&@#2>XzSLf!q=9FJZ2pOWg z)Ri|i9c8#=xYSiK$_tkt_-IJ;cY9{K$!g1Il_t2AK*<+O3&^chgYx8dcgcJyKhjJR zEN2xFE0W8TORdBNO8|0O==n3FC264;X_>2@TZ9~g|+Zj;|eQw zRy@=Ivb(!$l$k8cWw-p5o1LxK#5~JHzI!1A%FBPn*0y<%xnzCkq+xtVV7gSfwZb5= z(YBbbm$jK)m6ud$0XsBBBp|Z!c%Ni zwgnuA5qag+I5;&$H>+~IIBQj(?CX)#elg)_+l;xhvlN1{A(XH_G}G|Bz&+Sq&^{+G zFHfgxyuPWi5jy<-{o9pZr6Vqg2BoZiF>wiL6~rosFN7LdaKHM>n+6lPBDWwSSM_ML zAvB?c`2$xl?h-=}_3oE576NlkfedSxKRDn+=UA=E(2|JyVxU|PI&{2l*sZCdf+1CC zW*8QB?&{T1rx&qQJ0*ilIV4g*XGN{ z2gOxiTqHd{H}rn&I-F)#V*@?P@i8k4TxPaJ0Nnb1tIN8Xup8Og$+{<;goQoP$4umf z_1fFPd@W|>GiPdn9RE~Ow}-cXu2MIF z)OJfPtlH#ivfc@eqBH0vsG_3cjg@J7x2JB^c zV_lA~)R0Cmsh+X-`jajNid;LHSaS^1_Kc>~^JO!dT}QjFu7trTtdu{VmUcu*Sy>Ru z0cwY7K99=p`$ER{%AdvK@!)|GqOwa#N%^g=>5AyXj9<;?d4xw_k1f?UjJXiE8%H>@ z3}VlnyW!fRi;+CYYUn(1NlB-5chA0qBtBcCJ`EG4Tg(hl28p;#*h*g?D5S;}Y28wR zTS4NE<@B{8X)yHK+VqYTefg;X8ylN}rLbjQBmKjfFhMGAi?!Zw|IYK#xpS!JaWPT$ zkde{R`sdGkwmvFlNA6JY_2O-q*uTpIWeBmc1$XX9M90Jo5>Y?6Y6eplJtp8Cwb>88|Pud|$d zD^+#oaVlsO>gDI2+&6W#Y_nwS(#K~&=fcbaRWn9F|3`Xw zy0T;B997fW)>b&JbHo~v$IkRr8yuWWgws45?Q6|kDlFi)Pgna7%d1N8f_F0!7Lv~J}_Na=>)9<5@7>3KxeN_qib z2m|1~M^tr??h0teBdx(N1h8#{ae5L`Q7=?ub6F+#q;$sAAt$)Vo5gj|8H>e=Pcn|{ z2n0wue%npBAM}-QGFc&_J&C%P-ERvicNI~QMGLRtEET^PVQyv zx96dT;0)P$;Ba^|>(eE*p8Hu{_-{<# z=#IvQhF2q_)sdANuq^JqSo5nbw{8jU?ED$@XX=P=Ov^PJPb$w~=`}=-Nmbrnjpm0WZBqejSWQ;3T_*Xq!vMLki zLuZz&oSMz#XxcehKmA?552_Zsiby|;KY{{2N|GlD#`hiT5mDD;{qVb{shA7 zS0I%5p&~Muf@d1ub9~}N``xy@8C*2ywnzj2J5G`H?Z}?)WC9i+LI5iOjYK1rk?P9G zv(_ji7I_wn#wa6Iu}CDUTr1%JIQR#8lDwk+e+TWo>qDS}!NCZjfnMYY_YeYL?C zy#X1;4;Zb9Vg4RiEG`fq=1&M9W3d(>G!#Hp?YAQS3-g~bu~;I3*b+R>puA$&Ua^pn?bs@(XnL z3`Lw%Ltre)4t5qq1R90JAVR?@heWxO@PPrYsIy4SSydG Date: Mon, 7 Sep 2020 18:02:00 +0200 Subject: [PATCH 40/46] :zap: Remove debug code --- packages/nodes-base/nodes/SentryIo/GenericFunctions.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/nodes-base/nodes/SentryIo/GenericFunctions.ts b/packages/nodes-base/nodes/SentryIo/GenericFunctions.ts index f578f35cad..82105de7d5 100644 --- a/packages/nodes-base/nodes/SentryIo/GenericFunctions.ts +++ b/packages/nodes-base/nodes/SentryIo/GenericFunctions.ts @@ -46,10 +46,6 @@ export async function sentryIoApiRequest(this: IHookFunctions | IExecuteFunction Authorization: `Bearer ${credentials?.token}`, }; - console.log('options'); - console.log(options); - - //@ts-ignore return this.helpers.request(options); From 210e615bf4d19e9dbfa8dd29157b66f278d335ec Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Mon, 7 Sep 2020 18:37:56 +0200 Subject: [PATCH 41/46] :arrow_up: Set n8n-core@0.44.0 and n8n-workflow@0.39.0 on n8n-nodes-dev --- packages/node-dev/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index 5b3be4fca0..50153098ee 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -58,8 +58,8 @@ "change-case": "^4.1.1", "copyfiles": "^2.1.1", "inquirer": "^7.0.1", - "n8n-core": "^0.43.0", - "n8n-workflow": "^0.33.0", + "n8n-core": "^0.44.0", + "n8n-workflow": "^0.39.0", "replace-in-file": "^6.0.0", "request": "^2.88.2", "tmp-promise": "^2.0.2", From 06973e2d2a5a52db1567794542ccccc67e0cfbc9 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Mon, 7 Sep 2020 18:38:24 +0200 Subject: [PATCH 42/46] :bookmark: Release n8n-node-dev@0.10.0 --- packages/node-dev/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index 50153098ee..51c25727cd 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -1,6 +1,6 @@ { "name": "n8n-node-dev", - "version": "0.9.0", + "version": "0.10.0", "description": "CLI to simplify n8n credentials/node development", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", From f32e63acfa3eee6f8972e674eeb6237d612d5688 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 8 Sep 2020 09:48:07 +0200 Subject: [PATCH 43/46] :zap: Display imageSize property only if "download" is true --- packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts b/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts index 92f88920e9..c9659cfee7 100644 --- a/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts +++ b/packages/nodes-base/nodes/Telegram/TelegramTrigger.node.ts @@ -130,6 +130,13 @@ export class TelegramTrigger implements INodeType { displayName: 'Image Size', name: 'imageSize', type: 'options', + displayOptions: { + show: { + download: [ + true, + ], + }, + }, options: [ { name: 'Small', From dc583bd81b66ea2ad5b5a171d902895aeb07f149 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Tue, 8 Sep 2020 08:00:54 -0400 Subject: [PATCH 44/46] :zap: Feature/asana improvements (#920) * :zap: Improvements to Asana-Node * Minor change to comment strings helpful when filtering the code based on indentation * Minor rephrasing of description in search action * Add loadOption to get all projects * Add loadOption to get all sections in a project * Add UI fields to move task to a specific section * Add execution for moveToSection operation * Add loadOptions helper to get all teams * Add UI fields to get projects * Add execution methods for projects getter * Add loadOptions helper to get all users * Add loadOptions helper to get all tags * Add UI fields for adding a tag to a task * Add execution method to add a tag to a task * Add functionality to remove Tag from Task * Add option to set 'Assignee' and 'Assignee Status' on a task to unset an assignee 'null' has to be send. Unfortunately this gives a warning in the UI. * a few fixes * Only show existing task tags when removing a tag * few more fixes * :zap: Improvements to #855 Co-authored-by: Silvio --- packages/nodes-base/nodes/Asana/Asana.node.ts | 1064 +++++++++++++++-- .../nodes/Asana/GenericFunctions.ts | 34 +- 2 files changed, 1007 insertions(+), 91 deletions(-) diff --git a/packages/nodes-base/nodes/Asana/Asana.node.ts b/packages/nodes-base/nodes/Asana/Asana.node.ts index 507a2c44de..be5269fa7e 100644 --- a/packages/nodes-base/nodes/Asana/Asana.node.ts +++ b/packages/nodes-base/nodes/Asana/Asana.node.ts @@ -1,17 +1,19 @@ import { IExecuteFunctions, } from 'n8n-core'; + import { IDataObject, ILoadOptionsFunctions, - INodePropertyOptions, - INodeTypeDescription, INodeExecutionData, + INodePropertyOptions, INodeType, + INodeTypeDescription, } from 'n8n-workflow'; import { asanaApiRequest, + asanaApiRequestAllItems, } from './GenericFunctions'; export class Asana implements INodeType { @@ -22,10 +24,10 @@ export class Asana implements INodeType { group: ['input'], version: 1, subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', - description: 'Access and edit Asana tasks', + description: 'Consume Asana REST API', defaults: { name: 'Asana', - color: '#339922', + color: '#FC636B', }, inputs: ['main'], outputs: ['main'], @@ -33,7 +35,7 @@ export class Asana implements INodeType { { name: 'asanaApi', required: true, - } + }, ], properties: [ { @@ -41,10 +43,22 @@ export class Asana implements INodeType { name: 'resource', type: 'options', options: [ + { + name: 'Project', + value: 'project', + }, { name: 'Task', value: 'task', }, + { + name: 'Task Comment', + value: 'taskComment', + }, + { + name: 'Task Tag', + value: 'taskTag', + }, { name: 'User', value: 'user', @@ -54,8 +68,6 @@ export class Asana implements INodeType { description: 'The resource to operate on.', }, - - // ---------------------------------- // task // ---------------------------------- @@ -84,18 +96,23 @@ export class Asana implements INodeType { { name: 'Get', value: 'get', - description: 'Get data of a task', + description: 'Get a task', }, { - name: 'Update', - value: 'update', - description: 'Update a task', + name: 'Move', + value: 'move', + description: 'Move a task', }, { name: 'Search', value: 'search', description: 'Search for tasks', }, + { + name: 'Update', + value: 'update', + description: 'Update a task', + }, ], default: 'create', description: 'The operation to perform.', @@ -146,7 +163,7 @@ export class Asana implements INodeType { }, // ---------------------------------- - // delete + // task:delete // ---------------------------------- { displayName: 'Task ID', @@ -168,7 +185,7 @@ export class Asana implements INodeType { }, // ---------------------------------- - // get + // task:get // ---------------------------------- { displayName: 'Task ID', @@ -190,7 +207,77 @@ export class Asana implements INodeType { }, // ---------------------------------- - // update + // task:move + // ---------------------------------- + + { + displayName: 'Task ID', + name: 'id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'move', + ], + resource: [ + 'task', + ], + }, + }, + description: 'The ID of the task to be moved.', + }, + { + displayName: 'Project', + name: 'projectId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getProjects', + }, + options: [], + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'move', + ], + resource: [ + 'task', + ], + }, + }, + description: 'Project to show the sections of.', + }, + { + displayName: 'Section', + name: 'section', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getSections', + loadOptionsDependsOn: [ + 'projectId', + ], + }, + options: [], + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'move', + ], + resource: [ + 'task', + ], + }, + }, + description: 'The Section to move the task to', + }, + + // ---------------------------------- + // task:update // ---------------------------------- { displayName: 'Task ID', @@ -211,9 +298,8 @@ export class Asana implements INodeType { description: 'The ID of the task to update the data of.', }, - // ---------------------------------- - // search + // task:search // ---------------------------------- { displayName: 'Workspace', @@ -235,10 +321,10 @@ export class Asana implements INodeType { ], }, }, - description: 'The workspace to create the task in', + description: 'The workspace in which the task is searched', }, { - displayName: 'Search Properties', + displayName: 'Filters', name: 'searchTaskProperties', type: 'collection', displayOptions: { @@ -253,9 +339,15 @@ export class Asana implements INodeType { }, default: {}, description: 'Properties to search for', - placeholder: 'Add Search Property', + placeholder: 'Add Filter', options: [ - // TODO: Add "assignee" and "assignee_status" + { + displayName: 'Completed', + name: 'completed', + type: 'boolean', + default: false, + description: 'If the task is marked completed.', + }, { displayName: 'Text', name: 'text', @@ -267,21 +359,14 @@ export class Asana implements INodeType { default: '', description: 'Text to search for in name or notes.', }, - { - displayName: 'Completed', - name: 'completed', - type: 'boolean', - default: false, - description: 'If the task is marked completed.', - }, ], }, // ---------------------------------- - // create/update + // task:create/update // ---------------------------------- { - displayName: 'Other Properties', + displayName: 'Additional Fields', name: 'otherProperties', type: 'collection', displayOptions: { @@ -296,34 +381,42 @@ export class Asana implements INodeType { }, }, default: {}, - description: 'Other properties to set', - placeholder: 'Add Property', + placeholder: 'Add Field', options: [ { - displayName: 'Name', - name: 'name', - type: 'string', - default: '', - displayOptions: { - show: { - '/operation': [ - 'update', - ], - }, - }, - description: 'The new name of the task', - }, - // TODO: Add "assignee" and "assignee_status" - { - displayName: 'Notes', - name: 'notes', - type: 'string', + displayName: 'Assignee', + name: 'assignee', + type: 'options', typeOptions: { - alwaysOpenEditWindow: true, - rows: 5, + loadOptionsMethod: 'getUsers', }, default: '', - description: 'The task notes', + description: 'Set Assignee on the task', + }, + { + displayName: 'Assignee Status', + name: 'assignee_status', + type: 'options', + options: [ + { + name: 'Inbox', + value: 'inbox', + }, + { + name: 'Today', + value: 'today', + }, + { + name: 'Upcoming', + value: 'upcoming', + }, + { + name: 'Later', + value: 'later', + }, + ], + default: 'inbox', + description: 'Set Assignee status on the task (requires Assignee)', }, { displayName: 'Completed', @@ -339,6 +432,20 @@ export class Asana implements INodeType { default: '', description: 'Date on which the time is due.', }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + displayOptions: { + show: { + '/operation': [ + 'update', + ], + }, + }, + description: 'The new name of the task', + }, { displayName: 'Liked', name: 'liked', @@ -346,10 +453,307 @@ export class Asana implements INodeType { default: false, description: 'If the task is liked by the authorized user.', }, + { + displayName: 'Notes', + name: 'notes', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + rows: 5, + }, + default: '', + description: 'The task notes', + }, ], }, + // ---------------------------------- + // taskComment + // ---------------------------------- + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'taskComment', + ], + }, + }, + options: [ + { + name: 'Add', + value: 'add', + description: 'Add a comment to a task', + }, + { + name: 'Remove', + value: 'remove', + description: 'Remove a comment from a task', + }, + ], + default: 'add', + description: 'The operation to perform.', + }, + + // ---------------------------------- + // taskComment:add + // ---------------------------------- + + { + displayName: 'Task ID', + name: 'id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'add', + ], + resource: [ + 'taskComment', + ], + }, + }, + description: 'The ID of the task to add the comment to', + }, + { + displayName: 'Is Text HTML', + name: 'isTextHtml', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'add', + ], + resource: [ + 'taskComment', + ], + }, + }, + default: false, + description: 'If body is HTML or simple text.', + }, + { + displayName: 'Text', + name: 'text', + type: 'string', + default: '', + required: true, + typeOptions: { + alwaysOpenEditWindow: true, + }, + displayOptions: { + show: { + operation: [ + 'add', + ], + resource: [ + 'taskComment', + ], + isTextHtml: [ + false, + ], + }, + }, + description: 'The plain text of the comment to add', + }, + { + displayName: 'HTML Text', + name: 'html_text', + type: 'string', + default: '', + required: true, + typeOptions: { + alwaysOpenEditWindow: true, + }, + displayOptions: { + show: { + operation: [ + 'add', + ], + resource: [ + 'taskComment', + ], + isTextHtml: [ + true, + ], + }, + }, + description: 'Comment as HTML string. Do not use together with plain text.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + displayOptions: { + show: { + operation: [ + 'add', + ], + resource: [ + 'taskComment', + ], + }, + }, + default: {}, + description: 'Properties of the task comment', + placeholder: 'Add Field', + options: [ + { + displayName: 'Pinned', + name: 'is_pinned', + type: 'boolean', + default: false, + description: 'Pin the comment.', + }, + ], + }, + + // ---------------------------------- + // taskComment:remove + // ---------------------------------- + + { + displayName: 'Comment ID', + name: 'id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'remove', + ], + resource: [ + 'taskComment', + ], + }, + }, + description: 'The ID of the comment to be removed', + }, + + // ---------------------------------- + // taskTag + // ---------------------------------- + + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'taskTag', + ], + }, + }, + options: [ + { + name: 'Add', + value: 'add', + description: 'Add a tag to a task', + }, + { + name: 'Remove', + value: 'remove', + description: 'Remove a tag from a task', + }, + ], + default: 'add', + description: 'The operation to perform.', + }, + + // ---------------------------------- + // taskTag:add + // ---------------------------------- + + { + displayName: 'Task ID', + name: 'id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'add', + ], + resource: [ + 'taskTag', + ], + }, + }, + description: 'The ID of the task to add the tag to', + }, + { + displayName: 'Tags', + name: 'tag', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTags', + }, + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'add', + ], + resource: [ + 'taskTag', + ], + }, + }, + description: 'The tag that should be added', + }, + + // ---------------------------------- + // taskTag:remove + // ---------------------------------- + + { + displayName: 'Task ID', + name: 'id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'remove', + ], + resource: [ + 'taskTag', + ], + }, + }, + description: 'The ID of the task to add the tag to', + }, + { + displayName: 'Tags', + name: 'tag', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTags', + }, + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'remove', + ], + resource: [ + 'taskTag', + ], + }, + }, + description: 'The tag that should be added', + }, // ---------------------------------- // user @@ -366,15 +770,15 @@ export class Asana implements INodeType { }, }, options: [ - { - name: 'Get All', - value: 'getAll', - description: 'Get data of all users', - }, { name: 'Get', value: 'get', - description: 'Get data of a user', + description: 'Get a user', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all users', }, ], default: 'get', @@ -385,7 +789,7 @@ export class Asana implements INodeType { // user:get // ---------------------------------- { - displayName: 'Id', + displayName: 'User ID', name: 'userId', type: 'string', default: '', @@ -428,6 +832,165 @@ export class Asana implements INodeType { }, description: 'The workspace in which to get users.', }, + + // ---------------------------------- + // Project + // ---------------------------------- + + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'project', + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get a project', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all projects', + }, + ], + default: 'get', + description: 'The operation to perform.', + }, + + // ---------------------------------- + // project:get + // ---------------------------------- + { + displayName: 'Project ID', + name: 'id', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'get', + ], + resource: [ + 'project', + ], + }, + }, + }, + + // ---------------------------------- + // project:getAll + // ---------------------------------- + { + displayName: 'Workspace', + name: 'workspace', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getWorkspaces', + }, + options: [], + default: '', + required: true, + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'project', + ], + }, + }, + description: 'The workspace in which to get users.', + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'project', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'project', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + displayOptions: { + show: { + resource: [ + 'project', + ], + operation: [ + 'getAll', + ], + }, + }, + default: {}, + description: 'Other properties to set', + placeholder: 'Add Property', + options: [ + { + displayName: 'Archived', + name: 'archived', + type: 'boolean', + default: false, + description: 'Only return projects whose archived field takes on the value of this parameter.', + }, + { + displayName: 'Teams', + name: 'team', + type: 'options', + typeOptions: { + loadOptionsDependsOn: [ + 'workspace', + ], + loadOptionsMethod: 'getTeams', + }, + default: '', + description: 'The new name of the task', + }, + ], + }, ], }; @@ -436,15 +999,11 @@ export class Asana implements INodeType { // Get all the available workspaces to display them to user so that he can // select them easily async getWorkspaces(this: ILoadOptionsFunctions): Promise { - const endpoint = 'workspaces'; - const responseData = await asanaApiRequest.call(this, 'GET', endpoint, {}); - - if (responseData.data === undefined) { - throw new Error('No data got returned'); - } + const endpoint = '/workspaces'; + const responseData = await asanaApiRequestAllItems.call(this, 'GET', endpoint, {}); const returnData: INodePropertyOptions[] = []; - for (const workspaceData of responseData.data) { + for (const workspaceData of responseData) { if (workspaceData.resource_type !== 'workspace') { // Not sure if for some reason also ever other resources // get returned but just in case filter them out @@ -457,12 +1016,155 @@ export class Asana implements INodeType { }); } + return returnData; + }, + + // Get all the available projects to display them to user so that they can be + // selected easily + async getProjects(this: ILoadOptionsFunctions): Promise { + const endpoint = '/projects'; + const responseData = await asanaApiRequest.call(this, 'GET', endpoint, {}); + + if (responseData.data === undefined) { + throw new Error('No data got returned'); + } + + const returnData: INodePropertyOptions[] = []; + for (const projectData of responseData.data) { + if (projectData.resource_type !== 'project') { + // Not sure if for some reason also ever other resources + // get returned but just in case filter them out + continue; + } + const projectName = projectData.name; + const projectId = projectData.gid; + + returnData.push({ + name: projectName, + value: projectId, + }); + } + + return returnData; + }, + // Get all the available sections in a project to display them to user so that they + // can be selected easily + async getSections(this: ILoadOptionsFunctions): Promise { + const projectId = this.getNodeParameter('projectId') as string; + const endpoint = `/projects/${projectId}/sections`; + const responseData = await asanaApiRequest.call(this, 'GET', endpoint, {}); + + if (responseData.data === undefined) { + throw new Error('No data got returned'); + } + + const returnData: INodePropertyOptions[] = []; + for (const sectionData of responseData.data) { + if (sectionData.resource_type !== 'section') { + // Not sure if for some reason also ever other resources + // get returned but just in case filter them out + continue; + } + + returnData.push({ + name: sectionData.name, + value: sectionData.gid, + }); + } + + return returnData; + }, + + // Get all the available teams to display them to user so that he can + // select them easily + async getTeams(this: ILoadOptionsFunctions): Promise { + const workspaceId = this.getCurrentNodeParameter('workspace'); + + const workspace = await asanaApiRequest.call(this, 'GET', `/workspaces/${workspaceId}`, {}); + + // if the workspace selected it's not an organization then error as they endpoint + // to retrieve the teams from an organization just work with workspaces that are an organization + + if (workspace.is_organization === false) { + throw Error('To filter by team, the workspace selected has to be an organization'); + } + + const endpoint = `/organizations/${workspaceId}/teams`; + + const responseData = await asanaApiRequestAllItems.call(this, 'GET', endpoint, {}); + + const returnData: INodePropertyOptions[] = []; + for (const teamData of responseData) { + if (teamData.resource_type !== 'team') { + // Not sure if for some reason also ever other resources + // get returned but just in case filter them out + continue; + } + + returnData.push({ + name: teamData.name, + value: teamData.gid, + }); + } + + return returnData; + }, + + // Get all tags to display them to user so that they can be selected easily + // See: https://developers.asana.com/docs/get-multiple-tags + async getTags(this: ILoadOptionsFunctions): Promise { + const endpoint = '/tags'; + const responseData = await asanaApiRequest.call(this, 'GET', endpoint, {}); + + if (responseData.data === undefined) { + throw new Error('No data got returned'); + } + + const returnData: INodePropertyOptions[] = []; + for (const tagData of responseData.data) { + if (tagData.resource_type !== 'tag') { + // Not sure if for some reason also ever other resources + // get returned but just in case filter them out + continue; + } + + returnData.push({ + name: tagData.name, + value: tagData.gid, + }); + } + + return returnData; + }, + // Get all users to display them to user so that they can be selected easily + // See: https://developers.asana.com/docs/get-multiple-users + async getUsers(this: ILoadOptionsFunctions): Promise { + const endpoint = `/users`; + const responseData = await asanaApiRequest.call(this, 'GET', endpoint, {}); + + if (responseData.data === undefined) { + throw new Error('No data got returned'); + } + + const returnData: INodePropertyOptions[] = []; + for (const userData of responseData.data) { + if (userData.resource_type !== 'user') { + // Not sure if for some reason also ever other resources + // get returned but just in case filter them out + continue; + } + + returnData.push({ + name: userData.name, + value: userData.gid, + }); + } + return returnData; } }, }; - async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); const returnData: IDataObject[] = []; @@ -481,6 +1183,7 @@ export class Asana implements INodeType { let body: IDataObject; let qs: IDataObject; + let responseData; for (let i = 0; i < items.length; i++) { body = {}; @@ -489,11 +1192,11 @@ export class Asana implements INodeType { if (resource === 'task') { if (operation === 'create') { // ---------------------------------- - // create + // task:create // ---------------------------------- requestMethod = 'POST'; - endpoint = 'tasks'; + endpoint = '/tasks'; body.name = this.getNodeParameter('name', i) as string; // body.notes = this.getNodeParameter('taskNotes', 0) as string; @@ -502,50 +1205,177 @@ export class Asana implements INodeType { const otherProperties = this.getNodeParameter('otherProperties', i) as IDataObject; Object.assign(body, otherProperties); + responseData = await asanaApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = responseData.data; + } else if (operation === 'delete') { // ---------------------------------- - // delete + // task:delete // ---------------------------------- requestMethod = 'DELETE'; - endpoint = 'tasks/' + this.getNodeParameter('id', i) as string; + + endpoint = '/tasks/' + this.getNodeParameter('id', i) as string; + + responseData = await asanaApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = responseData.data; } else if (operation === 'get') { // ---------------------------------- - // get + // task:get // ---------------------------------- requestMethod = 'GET'; - endpoint = 'tasks/' + this.getNodeParameter('id', i) as string; + + endpoint = '/tasks/' + this.getNodeParameter('id', i) as string; + + responseData = await asanaApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = responseData.data; + + } else if (operation === 'move') { + // ---------------------------------- + // task:move + // ---------------------------------- + + const sectionId = this.getNodeParameter('section', i) as string; + + requestMethod = 'POST'; + + endpoint = `/sections/${sectionId}/addTask`; + + body.task = this.getNodeParameter('id', i) as string; + + Object.assign(body); + + responseData = await asanaApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = { success: true }; } else if (operation === 'update') { // ---------------------------------- - // update + // task:update // ---------------------------------- requestMethod = 'PUT'; - endpoint = 'tasks/' + this.getNodeParameter('id', i) as string; + endpoint = '/tasks/' + this.getNodeParameter('id', i) as string; const otherProperties = this.getNodeParameter('otherProperties', i) as IDataObject; Object.assign(body, otherProperties); + responseData = await asanaApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = responseData.data; + } else if (operation === 'search') { // ---------------------------------- - // search + // tasksearch // ---------------------------------- const workspaceId = this.getNodeParameter('workspace', i) as string; requestMethod = 'GET'; - endpoint = `workspaces/${workspaceId}/tasks/search`; + endpoint = `/workspaces/${workspaceId}/tasks/search`; const searchTaskProperties = this.getNodeParameter('searchTaskProperties', i) as IDataObject; Object.assign(qs, searchTaskProperties); - } else { - throw new Error(`The operation "${operation}" is not known!`); + responseData = await asanaApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = responseData.data; } - } else if (resource === 'user') { + } + if (resource === 'taskComment') { + if (operation === 'add') { + // ---------------------------------- + // taskComment:add + // ---------------------------------- + + const taskId = this.getNodeParameter('id', i) as string; + + const isTextHtml = this.getNodeParameter('isTextHtml', i) as boolean; + + if (!isTextHtml) { + + body.text = this.getNodeParameter('text', i) as string; + + } else { + + body.html_text = this.getNodeParameter('html_text', i) as string; + } + + requestMethod = 'POST'; + + endpoint = `/tasks/${taskId}/stories`; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + Object.assign(body, additionalFields); + + responseData = await asanaApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = responseData.data; + } + + if (operation === 'remove') { + // ---------------------------------- + // taskComment:remove + // ---------------------------------- + + const commentId = this.getNodeParameter('id', i) as string; + + requestMethod = 'DELETE'; + + endpoint = `/stories/${commentId}`; + + responseData = await asanaApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = { success: true }; + } + } + if (resource === 'taskTag') { + if (operation === 'add') { + + // ---------------------------------- + // taskTag:add + // ---------------------------------- + + const taskId = this.getNodeParameter('id', i) as string; + + requestMethod = 'POST'; + + endpoint = `/tasks/${taskId}/addTag`; + + body.tag = this.getNodeParameter('tag', i) as string; + + responseData = await asanaApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = { success: true }; + } + + if (operation === 'remove') { + + // ---------------------------------- + // taskTag:remove + // ---------------------------------- + + const taskId = this.getNodeParameter('id', i) as string; + + requestMethod = 'POST'; + + endpoint = `/tasks/${taskId}/removeTag`; + + body.tag = this.getNodeParameter('tag', i) as string; + + responseData = await asanaApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = { success: true }; + } + } + + if (resource === 'user') { if (operation === 'get') { // ---------------------------------- // get @@ -554,7 +1384,10 @@ export class Asana implements INodeType { const userId = this.getNodeParameter('userId', i) as string; requestMethod = 'GET'; - endpoint = `users/${userId}`; + endpoint = `/users/${userId}`; + + responseData = await asanaApiRequest.call(this, requestMethod, endpoint, body, qs); + responseData = responseData.data; } else if (operation === 'getAll') { // ---------------------------------- @@ -564,17 +1397,72 @@ export class Asana implements INodeType { const workspaceId = this.getNodeParameter('workspace', i) as string; requestMethod = 'GET'; - endpoint = `workspaces/${workspaceId}/users`; + endpoint = `/workspaces/${workspaceId}/users`; + + responseData = await asanaApiRequest.call(this, requestMethod, endpoint, body, qs); + responseData = responseData.data; - } else { - throw new Error(`The operation "${operation}" is not known!`); } - } else { - throw new Error(`The resource "${resource}" is not known!`); } - const responseData = await asanaApiRequest.call(this, requestMethod, endpoint, body); + if (resource === 'project') { - returnData.push(responseData.data as IDataObject); + if (operation === 'get') { + // ---------------------------------- + // project:get + // ---------------------------------- + const projectId = this.getNodeParameter('id', i) as string; + + requestMethod = 'GET'; + + endpoint = `/projects/${projectId}`; + + responseData = await asanaApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = responseData.data; + + } + + if (operation === 'getAll') { + // ---------------------------------- + // project:getAll + // ---------------------------------- + const workspaceId = this.getNodeParameter('workspace', i) as string; + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + requestMethod = 'GET'; + endpoint = `/projects`; + + if (additionalFields.team) { + qs.team = additionalFields.team; + } else { + qs.workspace = workspaceId; + } + + if (additionalFields.archived) { + qs.archived = additionalFields.archived as boolean; + } + + if (returnAll) { + + responseData = await asanaApiRequestAllItems.call(this, requestMethod, endpoint, body, qs); + + } else { + + qs.limit = this.getNodeParameter('limit', i) as boolean; + + responseData = await asanaApiRequest.call(this, requestMethod, endpoint, body, qs); + + responseData = responseData.data; + } + } + } + + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else { + returnData.push(responseData); + } } return [this.helpers.returnJsonArray(returnData)]; diff --git a/packages/nodes-base/nodes/Asana/GenericFunctions.ts b/packages/nodes-base/nodes/Asana/GenericFunctions.ts index 83bdfe01a2..c8a0beb1b4 100644 --- a/packages/nodes-base/nodes/Asana/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Asana/GenericFunctions.ts @@ -4,8 +4,17 @@ import { ILoadOptionsFunctions, } from 'n8n-core'; -import { OptionsWithUri } from 'request'; +import { + OptionsWithUri, +} from 'request'; +import { + IDataObject, +} from 'n8n-workflow'; + +import { + get, +} from 'lodash'; /** * Make an API request to Asana @@ -16,7 +25,7 @@ import { OptionsWithUri } from 'request'; * @param {object} body * @returns {Promise} */ -export async function asanaApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: object, query?: object): Promise { // tslint:disable-line:no-any +export async function asanaApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, method: string, endpoint: string, body: object, query?: object, uri?: string | undefined): Promise { // tslint:disable-line:no-any const credentials = this.getCredentials('asanaApi'); if (credentials === undefined) { @@ -30,7 +39,7 @@ export async function asanaApiRequest(this: IHookFunctions | IExecuteFunctions | method, body: { data: body }, qs: query, - uri: `https://app.asana.com/api/1.0/${endpoint}`, + uri: uri || `https://app.asana.com/api/1.0${endpoint}`, json: true, }; @@ -54,3 +63,22 @@ export async function asanaApiRequest(this: IHookFunctions | IExecuteFunctions | throw error; } } + +export async function asanaApiRequestAllItems(this: IExecuteFunctions | ILoadOptionsFunctions ,method: string, endpoint: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + let uri: string | undefined; + query.limit = 100; + + do { + responseData = await asanaApiRequest.call(this, method, endpoint, body, query, uri); + uri = get(responseData, 'next_page.uri'); + returnData.push.apply(returnData, responseData['data']); + } while ( + responseData['next_page'] !== null + ); + + return returnData; +} From 10959e5277e1d4d11f960e70a2dc6209357ced19 Mon Sep 17 00:00:00 2001 From: Jan Oberhauser Date: Tue, 8 Sep 2020 14:01:16 +0200 Subject: [PATCH 45/46] :zap: Minor improvements to Asana-Node --- packages/nodes-base/nodes/Asana/Asana.node.ts | 63 ++++++++++++++++--- 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/packages/nodes-base/nodes/Asana/Asana.node.ts b/packages/nodes-base/nodes/Asana/Asana.node.ts index be5269fa7e..8b26999c6f 100644 --- a/packages/nodes-base/nodes/Asana/Asana.node.ts +++ b/packages/nodes-base/nodes/Asana/Asana.node.ts @@ -255,10 +255,10 @@ export class Asana implements INodeType { name: 'section', type: 'options', typeOptions: { - loadOptionsMethod: 'getSections', loadOptionsDependsOn: [ 'projectId', ], + loadOptionsMethod: 'getSections', }, options: [], default: '', @@ -563,7 +563,7 @@ export class Asana implements INodeType { }, { displayName: 'HTML Text', - name: 'html_text', + name: 'text', type: 'string', default: '', required: true, @@ -694,6 +694,9 @@ export class Asana implements INodeType { name: 'tag', type: 'options', typeOptions: { + loadOptionsDependsOn: [ + 'id', + ], loadOptionsMethod: 'getTags', }, default: '', @@ -738,6 +741,9 @@ export class Asana implements INodeType { name: 'tag', type: 'options', typeOptions: { + loadOptionsDependsOn: [ + 'id', + ], loadOptionsMethod: 'getTags', }, default: '', @@ -1016,6 +1022,12 @@ export class Asana implements INodeType { }); } + returnData.sort((a, b) => { + if (a.name < b.name) { return -1; } + if (a.name > b.name) { return 1; } + return 0; + }); + return returnData; }, @@ -1045,6 +1057,12 @@ export class Asana implements INodeType { }); } + returnData.sort((a, b) => { + if (a.name < b.name) { return -1; } + if (a.name > b.name) { return 1; } + return 0; + }); + return returnData; }, // Get all the available sections in a project to display them to user so that they @@ -1072,6 +1090,12 @@ export class Asana implements INodeType { }); } + returnData.sort((a, b) => { + if (a.name < b.name) { return -1; } + if (a.name > b.name) { return 1; } + return 0; + }); + return returnData; }, @@ -1107,6 +1131,12 @@ export class Asana implements INodeType { }); } + returnData.sort((a, b) => { + if (a.name < b.name) { return -1; } + if (a.name > b.name) { return 1; } + return 0; + }); + return returnData; }, @@ -1114,7 +1144,17 @@ export class Asana implements INodeType { // See: https://developers.asana.com/docs/get-multiple-tags async getTags(this: ILoadOptionsFunctions): Promise { const endpoint = '/tags'; - const responseData = await asanaApiRequest.call(this, 'GET', endpoint, {}); + + const taskId = this.getNodeParameter('id') as string; + let taskData; + try { + taskData = await asanaApiRequest.call(this, 'GET', `/tasks/${taskId}`, {}); + } catch (e) { + throw new Error(`Could not find task with id "${taskId}" so tags could not be loaded.`); + } + + const workspace = taskData.data.workspace.gid; + const responseData = await asanaApiRequest.call(this, 'GET', endpoint, {}, { workspace }); if (responseData.data === undefined) { throw new Error('No data got returned'); @@ -1134,6 +1174,12 @@ export class Asana implements INodeType { }); } + returnData.sort((a, b) => { + if (a.name < b.name) { return -1; } + if (a.name > b.name) { return 1; } + return 0; + }); + return returnData; }, // Get all users to display them to user so that they can be selected easily @@ -1298,12 +1344,9 @@ export class Asana implements INodeType { const isTextHtml = this.getNodeParameter('isTextHtml', i) as boolean; if (!isTextHtml) { - body.text = this.getNodeParameter('text', i) as string; - } else { - - body.html_text = this.getNodeParameter('html_text', i) as string; + body.html_text = this.getNodeParameter('text', i) as string; } requestMethod = 'POST'; @@ -1423,9 +1466,9 @@ export class Asana implements INodeType { } if (operation === 'getAll') { - // ---------------------------------- - // project:getAll - // ---------------------------------- + // ---------------------------------- + // project:getAll + // ---------------------------------- const workspaceId = this.getNodeParameter('workspace', i) as string; const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; const returnAll = this.getNodeParameter('returnAll', i) as boolean; From 30bbc540e6d3fe0d3077e5a1450ec6ae9adae797 Mon Sep 17 00:00:00 2001 From: Sou1Aced <60439807+Sou1Aced@users.noreply.github.com> Date: Tue, 8 Sep 2020 09:39:27 -0400 Subject: [PATCH 46/46] :bug: ClockifyTrigger.node.ts fix (#930) --- packages/nodes-base/nodes/Clockify/ClockifyTrigger.node.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/nodes-base/nodes/Clockify/ClockifyTrigger.node.ts b/packages/nodes-base/nodes/Clockify/ClockifyTrigger.node.ts index 1edc80b53b..783432fb3b 100644 --- a/packages/nodes-base/nodes/Clockify/ClockifyTrigger.node.ts +++ b/packages/nodes-base/nodes/Clockify/ClockifyTrigger.node.ts @@ -118,6 +118,7 @@ export class ClockifyTrigger implements INodeType { if (Array.isArray(result) && result.length !== 0) { result = [this.helpers.returnJsonArray(result)]; + return result; } return null; }