From b46a29b1a7d9cbe802a7284ab9c1cff7fd2b01f0 Mon Sep 17 00:00:00 2001 From: Rupenieks <32895755+Rupenieks@users.noreply.github.com> Date: Tue, 30 Jun 2020 13:34:09 +0200 Subject: [PATCH 0001/1150] :construction: Resource descriptions --- .../credentials/PaddleApi.credentials.ts | 23 + .../nodes/Paddle/CouponDescription.ts | 447 ++++++++++++++++++ .../nodes/Paddle/GenericFunctions.ts | 41 ++ .../nodes/Paddle/OrderDescription.ts | 52 ++ .../nodes-base/nodes/Paddle/Paddle.node.ts | 92 ++++ .../nodes/Paddle/PaddleTrigger.node.ts | 164 +++++++ .../nodes/Paddle/PaymentDescription.ts | 197 ++++++++ .../nodes/Paddle/PlanDescription.ts | 52 ++ .../nodes/Paddle/ProductDescription.ts | 31 ++ .../nodes/Paddle/UserDescription.ts | 136 ++++++ packages/nodes-base/nodes/Paddle/paddle.png | Bin 0 -> 3076 bytes packages/nodes-base/package.json | 2 + 12 files changed, 1237 insertions(+) create mode 100644 packages/nodes-base/credentials/PaddleApi.credentials.ts create mode 100644 packages/nodes-base/nodes/Paddle/CouponDescription.ts create mode 100644 packages/nodes-base/nodes/Paddle/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Paddle/OrderDescription.ts create mode 100644 packages/nodes-base/nodes/Paddle/Paddle.node.ts create mode 100644 packages/nodes-base/nodes/Paddle/PaddleTrigger.node.ts create mode 100644 packages/nodes-base/nodes/Paddle/PaymentDescription.ts create mode 100644 packages/nodes-base/nodes/Paddle/PlanDescription.ts create mode 100644 packages/nodes-base/nodes/Paddle/ProductDescription.ts create mode 100644 packages/nodes-base/nodes/Paddle/UserDescription.ts create mode 100644 packages/nodes-base/nodes/Paddle/paddle.png diff --git a/packages/nodes-base/credentials/PaddleApi.credentials.ts b/packages/nodes-base/credentials/PaddleApi.credentials.ts new file mode 100644 index 0000000000..143a24b3b8 --- /dev/null +++ b/packages/nodes-base/credentials/PaddleApi.credentials.ts @@ -0,0 +1,23 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class PaddleApi implements ICredentialType { + name = 'paddleApi'; + displayName = 'Paddle API'; + properties = [ + { + displayName: 'Vendor Auth Code', + name: 'vendorAuthCode', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Vendor ID', + name: 'vendorId', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Paddle/CouponDescription.ts b/packages/nodes-base/nodes/Paddle/CouponDescription.ts new file mode 100644 index 0000000000..063ae2eda0 --- /dev/null +++ b/packages/nodes-base/nodes/Paddle/CouponDescription.ts @@ -0,0 +1,447 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const couponOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'coupon', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a coupon.', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all coupons.', + }, + { + name: 'Update', + value: 'update', + description: 'Update a coupon.', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const couponFields = [ + +/* -------------------------------------------------------------------------- */ +/* coupon:create */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Coupon Type', + name: 'couponType', + type: 'options', + displayOptions: { + show: { + resource: [ + 'coupon', + ], + operation: [ + `create` + ] + }, + }, + default: '', + description: 'Either product (valid for specified products or subscription plans) or checkout (valid for any checkout).', + options: [ + { + name: 'Checkout', + value: 'checkout' + }, + { + name: 'Product', + value: 'product' + }, + ] + }, + { + displayName: 'Product ID(s)', + name: 'productIds', + type: 'string', + displayOptions: { + show: { + resource: [ + 'coupon', + ], + operation: [ + `create` + ], + couponType: [ + 'product', + ], + }, + }, + default: '', + description: 'Comma-separated list of product IDs. Required if coupon_type is product.', + required: true, + }, + { + displayName: 'Discount Type', + name: 'discountType', + type: 'options', + displayOptions: { + show: { + resource: [ + 'coupon', + ], + operation: [ + `create` + ], + }, + }, + default: 'flat', + description: 'Either flat or percentage.', + options: [ + { + name: 'Flat', + value: 'flat' + }, + { + name: 'Percentage', + value: 'percentage' + }, + ] + }, + { + displayName: 'Discount Amount Currency', + name: 'discountAmount', + type: 'number', + default: '', + description: 'Discount amount in currency.', + typeOptions: { + minValue: 0 + }, + displayOptions: { + show: { + resource: [ + 'coupon', + ], + operation: [ + `create` + ], + discountType: [ + 'flat', + ] + }, + }, + }, + { + displayName: 'Discount Amount %', + name: 'discountAmount', + type: 'number', + default: '', + description: 'Discount amount in percentage.', + typeOptions: { + minValue: 0, + maxValue: 100 + }, + displayOptions: { + show: { + resource: [ + 'coupon', + ], + operation: [ + `create` + ], + discountType: [ + 'percentage', + ] + }, + }, + }, + { + displayName: 'Currency', + name: 'currency', + type: 'options', + default: 'eur', + description: 'The currency must match the balance currency specified in your account.', + options: [ + { + name: 'EUR', + value: 'eur' + }, + { + name: 'GBP', + value: 'gbp' + }, + { + name: 'USD', + value: 'usd' + }, + ], + displayOptions: { + show: { + resource: [ + 'coupon', + ], + operation: [ + `create` + ], + discountType: [ + 'flat', + ] + }, + }, + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + resource: [ + 'coupon', + ], + operation: [ + 'create', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Allowed Uses', + name: 'allowedUses', + type: 'number', + default: 1, + description: 'Number of times a coupon can be used in a checkout. This will be set to 999,999 by default, if not specified.', + }, + { + displayName: 'Coupon Code', + name: 'couponCode', + type: 'string', + default: '', + description: 'Will be randomly generated if not specified.', + }, + { + displayName: 'Coupon Prefix', + name: 'couponPrefix', + type: 'string', + default: '', + description: 'Prefix for generated codes. Not valid if coupon_code is specified.', + }, + { + displayName: 'Expires', + name: 'expires', + type: 'DateTime', + default: '', + description: 'The coupon will expire on the date at 00:00:00 UTC.', + }, + { + displayName: 'Group', + name: 'group', + type: 'string', + typeOptions: { + minValue: 1, + maxValue: 50 + }, + default: '', + description: 'The name of the coupon group this coupon should be assigned to.', + }, + { + displayName: 'Recurring', + name: 'recurring', + type: 'boolean', + default: false, + description: 'If the coupon is used on subscription products, this indicates whether the discount should apply to recurring payments after the initial purchase.', + }, + { + displayName: 'Number of Coupons', + name: 'numberOfCoupons', + type: 'number', + default: 1, + description: 'Number of coupons to generate. Not valid if coupon_code is specified.', + }, + { + displayName: 'Description', + name: 'description', + type: 'string', + default: '', + description: 'Description of the coupon. This will be displayed in the Seller Dashboard.', + }, + ], + }, +/* -------------------------------------------------------------------------- */ +/* coupon:update */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Update by', + name: 'updateBy', + type: 'options', + displayOptions: { + show: { + resource: [ + 'coupon', + ], + operation: [ + `update` + ], + }, + }, + default: 'couponCode', + description: 'Either flat or percentage.', + options: [ + { + name: 'Coupon Code', + value: 'couponCode' + }, + { + name: 'Group', + value: 'group' + }, + ] + }, + { + displayName: 'Coupon Code', + name: 'couponCode', + type: 'string', + displayOptions: { + show: { + resource: [ + 'coupon', + ], + operation: [ + 'update' + ], + updateBy: [ + 'couponCode' + ] + }, + }, + default: '', + description: 'Identify the coupon to update', + }, + { + displayName: 'Group', + name: 'group', + type: 'string', + displayOptions: { + show: { + resource: [ + 'coupon', + ], + operation: [ + 'update' + ], + updateBy: [ + 'group' + ] + }, + }, + default: '', + description: 'The name of the group of coupons you want to update.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + resource: [ + 'coupon', + ], + operation: [ + 'update', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Allowed Uses', + name: 'allowedUses', + type: 'number', + default: 1, + description: 'Number of times a coupon can be used in a checkout. This will be set to 999,999 by default, if not specified.', + }, + { + displayName: 'Currency', + name: 'currency', + type: 'options', + default: 'eur', + description: 'The currency must match the balance currency specified in your account.', + options: [ + { + name: 'EUR', + value: 'eur' + }, + { + name: 'GBP', + value: 'gbp' + }, + { + name: 'USD', + value: 'usd' + }, + ], + }, + + { + displayName: 'Discount Amount', + name: 'discountAmount', + type: 'number', + default: '', + description: 'Discount amount.', + typeOptions: { + minValue: 0 + }, + }, + { + displayName: 'Expires', + name: 'expires', + type: 'DateTime', + default: '', + description: 'The coupon will expire on the date at 00:00:00 UTC.', + }, + { + displayName: 'New Coupon Code', + name: 'newCouponCode', + type: 'string', + default: '', + description: 'New code to rename the coupon to.', + }, + { + displayName: 'New Group Name', + name: 'newGroup', + type: 'string', + typeOptions: { + minValue: 1, + maxValue: 50 + }, + default: '', + description: 'New group name to move coupon to.', + }, + { + displayName: 'Product ID(s)', + name: 'productIds', + type: 'string', + default: '', + description: 'Comma-separated list of product IDs. Required if coupon_type is product.', + }, + { + displayName: 'Recurring', + name: 'recurring', + type: 'boolean', + default: false, + description: 'If the coupon is used on subscription products, this indicates whether the discount should apply to recurring payments after the initial purchase.', + }, + ], + }, + +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Paddle/GenericFunctions.ts b/packages/nodes-base/nodes/Paddle/GenericFunctions.ts new file mode 100644 index 0000000000..62f054414e --- /dev/null +++ b/packages/nodes-base/nodes/Paddle/GenericFunctions.ts @@ -0,0 +1,41 @@ +import { OptionsWithUri } from 'request'; + +import { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, + IExecuteSingleFunctions, + IWebhookFunctions, + BINARY_ENCODING +} from 'n8n-core'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function paddleApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IWebhookFunctions, endpoint: string, method: string, body: any = {}, query?: IDataObject, uri?: string): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('paddleApi'); + + const options = { + method, + qs: query || {}, + uri: uri || `${env}/v1${endpoint}`, + body, + json: true + }; + + try { + return await this.helpers.request!(options); + } catch (error) { + + if (error.response.body) { + let errorMessage = error.response.body.message; + if (error.response.body.details) { + errorMessage += ` - Details: ${JSON.stringify(error.response.body.details)}`; + } + throw new Error(errorMessage); + } + + throw error; + } +} diff --git a/packages/nodes-base/nodes/Paddle/OrderDescription.ts b/packages/nodes-base/nodes/Paddle/OrderDescription.ts new file mode 100644 index 0000000000..367082a4b3 --- /dev/null +++ b/packages/nodes-base/nodes/Paddle/OrderDescription.ts @@ -0,0 +1,52 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const orderOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'order', + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get an order', + } + ], + default: 'get', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const orderFields = [ + +/* -------------------------------------------------------------------------- */ +/* order:get */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Checkout ID', + name: 'checkoutId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'order', + ], + operation: [ + 'get', + ], + }, + }, + description: 'The identifier of the buyer’s checkout.', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Paddle/Paddle.node.ts b/packages/nodes-base/nodes/Paddle/Paddle.node.ts new file mode 100644 index 0000000000..b40102dec0 --- /dev/null +++ b/packages/nodes-base/nodes/Paddle/Paddle.node.ts @@ -0,0 +1,92 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, + INodePropertyOptions, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +export class Paddle implements INodeType { + description: INodeTypeDescription = { + displayName: 'Paddle', + name: 'paddle', + icon: 'file:paddle.png', + group: ['output'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Paddle API', + defaults: { + name: 'Paddle', + color: '#45567c', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'paddleApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Coupon', + value: 'coupon', + + }, + { + name: 'Payments', + value: 'payments', + }, + { + name: 'Plan', + value: 'plan', + }, + { + name: 'Product', + value: 'product', + }, + { + name: 'Order', + value: 'order', + }, + { + name: 'User', + value: 'user', + }, + ], + default: 'coupon', + description: 'Resource to consume.', + }, + + ], + }; + + 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 (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/Paddle/PaddleTrigger.node.ts b/packages/nodes-base/nodes/Paddle/PaddleTrigger.node.ts new file mode 100644 index 0000000000..a2b5c6e484 --- /dev/null +++ b/packages/nodes-base/nodes/Paddle/PaddleTrigger.node.ts @@ -0,0 +1,164 @@ +import { + IHookFunctions, + IWebhookFunctions, + } from 'n8n-core'; + + import { + IDataObject, + INodeTypeDescription, + INodeType, + IWebhookResponseData, + ILoadOptionsFunctions, + INodePropertyOptions, + } from 'n8n-workflow'; + + export class PaddleTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Paddle Trigger', + name: 'paddleTrigger', + icon: 'file:paddle.png', + group: ['trigger'], + version: 1, + description: 'Handle Paddle events via webhooks', + defaults: { + name: 'Paddle Trigger', + color: '#32325d', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'paddleApi', + required: true, + } + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + reponseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Events', + name: 'events', + type: 'multiOptions', + required: true, + default: [], + description: 'The event to listen to.', + typeOptions: { + loadOptionsMethod: 'getEvents' + }, + options: [], + }, + ], + }; + + + + // @ts-ignore (because of request) + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + if (webhookData.webhookId === undefined) { + // No webhook id is set so no webhook can exist + return false; + } + const endpoint = `/notifications/webhooks/${webhookData.webhookId}`; + try { + await payPalApiRequest.call(this, endpoint, 'GET'); + } catch (err) { + if (err.response && err.response.name === 'INVALID_RESOURCE_ID') { + // Webhook does not exist + delete webhookData.webhookId; + return false; + } + throw new Error(`Paddle Error: ${err}`); + } + return true; + }, + + async create(this: IHookFunctions): Promise { + let webhook; + const webhookUrl = this.getNodeWebhookUrl('default'); + const events = this.getNodeParameter('events', []) as string[]; + const body = { + url: webhookUrl, + event_types: events.map(event => { + return { name: event }; + }), + }; + const endpoint = '/notifications/webhooks'; + try { + webhook = await payPalApiRequest.call(this, endpoint, 'POST', body); + } catch (e) { + throw e; + } + + if (webhook.id === undefined) { + return false; + } + const webhookData = this.getWorkflowStaticData('node'); + webhookData.webhookId = webhook.id as string; + return true; + }, + + async delete(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + if (webhookData.webhookId !== undefined) { + const endpoint = `/notifications/webhooks/${webhookData.webhookId}`; + try { + await payPalApiRequest.call(this, endpoint, 'DELETE', {}); + } catch (e) { + return false; + } + delete webhookData.webhookId; + } + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + let webhook; + const webhookData = this.getWorkflowStaticData('node') as IDataObject; + const bodyData = this.getBodyData() as IDataObject; + const req = this.getRequestObject(); + const headerData = this.getHeaderData() as IDataObject; + const endpoint = '/notifications/verify-webhook-signature'; + + if (headerData['PAYPAL-AUTH-ALGO'] !== undefined + && headerData['PAYPAL-CERT-URL'] !== undefined + && headerData['PAYPAL-TRANSMISSION-ID'] !== undefined + && headerData['PAYPAL-TRANSMISSION-SIG'] !== undefined + && headerData['PAYPAL-TRANSMISSION-TIME'] !== undefined) { + const body = { + auth_algo: headerData['PAYPAL-AUTH-ALGO'], + cert_url: headerData['PAYPAL-CERT-URL'], + transmission_id: headerData['PAYPAL-TRANSMISSION-ID'], + transmission_sig: headerData['PAYPAL-TRANSMISSION-SIG'], + transmission_time: headerData['PAYPAL-TRANSMISSION-TIME'], + webhook_id: webhookData.webhookId, + webhook_event: bodyData, + }; + try { + webhook = await payPalApiRequest.call(this, endpoint, 'POST', body); + } catch (e) { + throw e; + } + if (webhook.verification_status !== 'SUCCESS') { + return {}; + } + } else { + return {}; + } + return { + workflowData: [ + this.helpers.returnJsonArray(req.body) + ], + }; + } + } diff --git a/packages/nodes-base/nodes/Paddle/PaymentDescription.ts b/packages/nodes-base/nodes/Paddle/PaymentDescription.ts new file mode 100644 index 0000000000..ef4faa55bc --- /dev/null +++ b/packages/nodes-base/nodes/Paddle/PaymentDescription.ts @@ -0,0 +1,197 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const paymentOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'payment', + ], + }, + }, + options: [ + { + name: 'Get All', + value: 'getAll', + description: 'Get all payments.', + }, + { + name: 'Reschedule', + value: 'reschedule', + description: 'Reschedule payment.', + } + ], + default: 'getAll', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const paymentFields = [ + +/* -------------------------------------------------------------------------- */ +/* payment:getAll */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Subscription ID', + name: 'subscriptionId', + type: 'number', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'getAll', + ], + }, + }, + description: 'A specific user subscription ID.', + }, + { + displayName: 'Plan', + name: 'planId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'getAll', + ], + }, + }, + description: 'Filter: The product/plan ID (single or comma-separated values).', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 1, + required: true, + typeOptions: { + minValue: 1, + maxValue: 200 + }, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'getAll', + ], + }, + }, + description: 'Number of subscription records to return per page.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'user', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'State', + name: 'state', + type: 'options', + default: 'active', + description: 'Filter: The user subscription status. Returns all active, past_due, trialing and paused subscription plans if not specified.', + options: [ + { + name: 'Active', + value: 'active' + }, + { + name: 'Past Due', + value: 'past_due' + }, + { + name: 'Paused', + value: 'paused' + }, + { + name: 'Trialing', + value: 'trialing' + }, + ] + }, + { + displayName: 'Is Paid', + name: 'isPaid', + type: 'boolean', + default: false, + description: 'Payment is paid.', + }, + { + displayName: 'From', + name: 'from', + type: 'DateTime', + default: '', + description: 'Payments starting from date.', + }, + { + displayName: 'To', + name: 'to', + type: 'DateTime', + default: '', + description: 'Payments up until date.', + }, + { + displayName: 'One off charge', + name: 'isOneOffCharge', + type: 'boolean', + default: false, + description: 'Payment is paid.', + }, + ], + }, +/* -------------------------------------------------------------------------- */ +/* payment:reschedule */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Payment ID', + name: 'paymentId', + type: 'number', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'getAll', + ], + }, + }, + description: 'The upcoming subscription payment ID.', // Use loadoptions to select payment + }, + { + displayName: 'Date', + name: 'date', + type: 'DateTime', + default: '', + description: 'Date you want to move the payment to.', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Paddle/PlanDescription.ts b/packages/nodes-base/nodes/Paddle/PlanDescription.ts new file mode 100644 index 0000000000..9ddf82046e --- /dev/null +++ b/packages/nodes-base/nodes/Paddle/PlanDescription.ts @@ -0,0 +1,52 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const planOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'plan', + ], + }, + }, + options: [ + { + name: 'Get All', + value: 'getAll', + description: 'Get all plans.', + } + ], + default: 'getAll', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const planFields = [ + +/* -------------------------------------------------------------------------- */ +/* plan:getAll */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Plan ID', + name: 'planId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'plan', + ], + operation: [ + 'getAll', + ], + }, + }, + description: 'Filter: The subscription plan ID.', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Paddle/ProductDescription.ts b/packages/nodes-base/nodes/Paddle/ProductDescription.ts new file mode 100644 index 0000000000..b2565b6dd2 --- /dev/null +++ b/packages/nodes-base/nodes/Paddle/ProductDescription.ts @@ -0,0 +1,31 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const productOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'product', + ], + }, + }, + options: [ + { + name: 'Get All', + value: 'getAll', + description: 'Get all products.', + } + ], + default: 'getAll', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const productFields = [ + +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Paddle/UserDescription.ts b/packages/nodes-base/nodes/Paddle/UserDescription.ts new file mode 100644 index 0000000000..b970be875a --- /dev/null +++ b/packages/nodes-base/nodes/Paddle/UserDescription.ts @@ -0,0 +1,136 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const userOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'user', + ], + }, + }, + options: [ + { + name: 'Get All', + value: 'getAll', + description: 'Get all users', + } + ], + default: 'getAll', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const userFields = [ + +/* -------------------------------------------------------------------------- */ +/* user:getAll */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Subscription ID', + name: 'subscriptionId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'getAll', + ], + }, + }, + description: 'A specific user subscription ID.', + }, + { + displayName: 'Plan ID', + name: 'planId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'getAll', + ], + }, + }, + description: 'Filter: The subscription plan ID.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 1, + required: true, + typeOptions: { + minValue: 1, + maxValue: 200 + }, + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'getAll', + ], + }, + }, + description: 'Number of subscription records to return per page.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'user', + ], + }, + }, + default: {}, + options: [ + { + displayName: 'State', + name: 'state', + type: 'options', + default: 'active', + description: 'Filter: The user subscription status. Returns all active, past_due, trialing and paused subscription plans if not specified.', + options: [ + { + name: 'Active', + value: 'active' + }, + { + name: 'Past Due', + value: 'past_due' + }, + { + name: 'Paused', + value: 'paused' + }, + { + name: 'Trialing', + value: 'trialing' + }, + ] + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Paddle/paddle.png b/packages/nodes-base/nodes/Paddle/paddle.png new file mode 100644 index 0000000000000000000000000000000000000000..80426ca92c3439e320c00be5eb12476c4957f7ab GIT binary patch literal 3076 zcmcImX;c$g77nW*q9CGx3o)P~kbQwf77>CVgf(guu&q=o5J^%Y6-feuqK%3S>YxpX z*eEEXqJoIpE^R<*pVEIt2Na~5xUbu|IiUgjx<#Y~-BlCsgL;*>1C^9L8 z3ns;bOaac@3+tg`p#&000bo@Uu@qscJaJ!mS?FGOOvGWoKos$wI3JxuERPe0^@ZgS zmQG;cK{Cx9>&7II88i}=;fke@$V?*1ok*tQNn{p@!J@liM}9cen_M7dMfmxT_(JbI zad8TTj720Wl}dt=N`U3DL^6|!a!`mA3LZt^5w%nSsPIz6VU)oSLO{7lrVznWtd0@j z!)q0uI8^Cy2ol*CtrQta6Phrh3Xl=W1d=YKQ6Pu&b*Mx#hDH?ZMAXZO-oGS9xM~?h zjDQe$tsI2diI7y`FzQSIj@ioA%EezI6@WxY3`tNJf(j##xst`f3K)rl|0a&H$79jR zSiW)yP{49742wq#6*kI(rJzw`otBHF0$7R6*C`q?fcyXj5kKqORu3zWlQ1xgVGAO)d7KTjMgiXajRSWJNFMt0)^ zcsj%c@gxC_j|WIX8lLLzEg>5 z1y2{aK`0T8iFc>c=y-r3gxo;}olX@(qimsa5&Gr;;y<(MR0&Wi_)QyMmT+?omH@-r}h(XiyVgsBfv2+{kI&I~92uPi)!i zSBFrR_(}E$|;-xlH(rd|DMIkT)V(ymB@S7-IIh39$A zk1{XsO%7{o+R>M`xX`=3!Sv^F$WSxh&N1Po-TaAXqmskaRTGU~q}H}xVQ&p)8GkNM z=`jzk-MC?j>%`Xd{hVT^2c^JJ1uSB970)|Rq`vLW#QnRljR9f zQ_~J<>gU-lqc-z5Mw*nxG(UT<-rG)FPb~`&p)*Cs#L%eu#$8n&_1OKh-}SX^KZ4Y}%YSCKo6F5ic1sMj_;6{{ zcQyRMqdvgE`JrsX18es(Eo=0}!o?=krZqhKd`?Rf=&(lWQv1o;IRoQTudFYs@anS= zKdDj7ef=~S)9ltZ2Wc%a0DAk(mmJaxaY@OHnrD=!mpp8(>bArZo{YEGZ%oZ?i+XT! z5$~g=Ua($_on}Vfo86!EuU0SE=C~5W+truz{lSd58@G4G9(QnY-*oH5s*31}hF-r$ zHzl_Bs`>z@TiaDwwkdJF%c|d8hKfTZkDN_0h|48j<}`e6&0PpT)l#)?Z9vjd;jfCPItX334aSpHPYttgMajg z80#9VQ%-f!S6Y%&_%i2+*+XTUmhbLq$hbAzJui9Hx{|iSsuHf!=h3xvo6zbdZG~#v zUbB|0srlGOUsQ+TQrXX?&8)#$HE`D~-YpYry{+TCY|0s;r-?tE`C;Qq+LOBGDot5J zZhDmLRno4207tzWo%MH1ZB^1cL>qAWhrE&rdX+r|RpFRu%Zl&fmOgH{0#9f6%GRYk zeZ490Ftf6(lrLvj0_v=vw07Lyks_V;=m+9U4iOXnqbV@RviAFqU(gU6lov1(C!zR=!`8FZStkNs-?;nG~# znH@&8Yk#->Si;*%vrudO=%BXdvbO6@+G_6Il2pZu&jz@=2a@);PNdkac3HA0F`P~3 z1>O2kAn&nIy{b+M2gpqX3)^d%TaE^=OVt#FBFAU!&DoJ%c%r@kjWV1a5wR+LZNdti z%QG(bSWIeT_dlM5DYktg73+68*QEqrJm;0}Jb!uH@kM1VH9*%zZQ8vUbfp zyUW^~_7jI@x6iBUTEe85U4Ah*P+xBwKh5xSk0Na4_7g+CXKqp{noXuduAv&2GcHHA zw`P}Ve#mPgM{C|usP$RlTk z7j-^=pJUKyIPZP(Vy%-QyQH}MUqwr);`+<(^G#F*zNECAnlSJ-;32eqct5-U@M-?(!&^QdR_#p7 znwjT-`1dh;F`eIU47HqC=t@sny?I}^b%toxblG!Zrjrq8a$a+cS5OC-dxxRdp@~ZF zjF)T3M}AU0on!wX*&pZbY)}j3Hog&S%THD|*q_@W&Z#WT3cS|yxGL)W!Hvo})(&P1 zJSJ6%(xBD0JaoyL@!)N0DYVSHc4%C= XufyuO^DY|czS9HQA$}(ot={+t`pL%m literal 0 HcmV?d00001 diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index c19d8e2349..c22657642e 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -104,6 +104,7 @@ "dist/credentials/OAuth1Api.credentials.js", "dist/credentials/OAuth2Api.credentials.js", "dist/credentials/OpenWeatherMapApi.credentials.js", + "dist/credentials/PaddleApi.credentials.js", "dist/credentials/PagerDutyApi.credentials.js", "dist/credentials/PagerDutyOAuth2Api.credentials.js", "dist/credentials/PayPalApi.credentials.js", @@ -246,6 +247,7 @@ "dist/nodes/NextCloud/NextCloud.node.js", "dist/nodes/NoOp.node.js", "dist/nodes/OpenWeatherMap.node.js", + "dist/nodes/Paddle/Paddle.node.js", "dist/nodes/PagerDuty/PagerDuty.node.js", "dist/nodes/PayPal/PayPal.node.js", "dist/nodes/PayPal/PayPalTrigger.node.js", From 4a968ee8ea14965908ec6e3c0859a75a112b260d Mon Sep 17 00:00:00 2001 From: Rupenieks <32895755+Rupenieks@users.noreply.github.com> Date: Tue, 30 Jun 2020 17:38:55 +0200 Subject: [PATCH 0002/1150] :construction: Node logic / Genericfunctions setup --- .../nodes/Paddle/CouponDescription.ts | 24 +- .../nodes/Paddle/GenericFunctions.ts | 24 +- .../nodes-base/nodes/Paddle/Paddle.node.ts | 226 +++++++++++++++++- .../nodes/Paddle/PaddleTrigger.node.ts | 9 +- .../nodes/Paddle/PaymentDescription.ts | 23 -- 5 files changed, 254 insertions(+), 52 deletions(-) diff --git a/packages/nodes-base/nodes/Paddle/CouponDescription.ts b/packages/nodes-base/nodes/Paddle/CouponDescription.ts index 063ae2eda0..553a0c0ffd 100644 --- a/packages/nodes-base/nodes/Paddle/CouponDescription.ts +++ b/packages/nodes-base/nodes/Paddle/CouponDescription.ts @@ -37,7 +37,6 @@ export const couponOperations = [ ] as INodeProperties[]; export const couponFields = [ - /* -------------------------------------------------------------------------- */ /* coupon:create */ /* -------------------------------------------------------------------------- */ @@ -55,7 +54,7 @@ export const couponFields = [ ] }, }, - default: '', + default: 'checkout', description: 'Either product (valid for specified products or subscription plans) or checkout (valid for any checkout).', options: [ { @@ -277,6 +276,26 @@ export const couponFields = [ ], }, /* -------------------------------------------------------------------------- */ +/* coupon:getAll */ +/* -------------------------------------------------------------------------- */ + { + displayName: 'Product ID', + name: 'productId', + type: 'number', + displayOptions: { + show: { + resource: [ + 'coupon', + ], + operation: [ + `getAll` + ] + }, + }, + default: '', + description: 'The specific product/subscription ID.', + }, +/* -------------------------------------------------------------------------- */ /* coupon:update */ /* -------------------------------------------------------------------------- */ { @@ -391,7 +410,6 @@ export const couponFields = [ }, ], }, - { displayName: 'Discount Amount', name: 'discountAmount', diff --git a/packages/nodes-base/nodes/Paddle/GenericFunctions.ts b/packages/nodes-base/nodes/Paddle/GenericFunctions.ts index 62f054414e..c56498935a 100644 --- a/packages/nodes-base/nodes/Paddle/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Paddle/GenericFunctions.ts @@ -16,26 +16,24 @@ import { export async function paddleApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IWebhookFunctions, endpoint: string, method: string, body: any = {}, query?: IDataObject, uri?: string): Promise { // tslint:disable-line:no-any const credentials = this.getCredentials('paddleApi'); - const options = { + if (credentials === undefined) { + throw new Error('Could not retrieve credentials!'); + } + + const options : OptionsWithUri = { method, - qs: query || {}, - uri: uri || `${env}/v1${endpoint}`, + uri: `https://vendors.paddle.com/api${endpoint}` , body, json: true }; + body.vendor_id = credentials.vendorId; + body.vendor_auth_code = credentials.vendorAuthCode; + try { return await this.helpers.request!(options); } catch (error) { - - if (error.response.body) { - let errorMessage = error.response.body.message; - if (error.response.body.details) { - errorMessage += ` - Details: ${JSON.stringify(error.response.body.details)}`; - } - throw new Error(errorMessage); - } - - throw error; + console.log(error); + throw new Error(error); } } diff --git a/packages/nodes-base/nodes/Paddle/Paddle.node.ts b/packages/nodes-base/nodes/Paddle/Paddle.node.ts index b40102dec0..0a781e9bb4 100644 --- a/packages/nodes-base/nodes/Paddle/Paddle.node.ts +++ b/packages/nodes-base/nodes/Paddle/Paddle.node.ts @@ -1,15 +1,21 @@ -import { - IExecuteFunctions, -} from 'n8n-core'; - +import { IExecuteFunctions } from 'n8n-core'; import { IDataObject, - ILoadOptionsFunctions, + INodeExecutionData, - INodePropertyOptions, + INodeType, - INodeTypeDescription, + INodeTypeDescription } from 'n8n-workflow'; +import { couponFields, couponOperations } from './CouponDescription'; +import { paddleApiRequest } from './GenericFunctions'; +import { paymentFields, paymentOperations } from './PaymentDescription'; +import { planFields, planOperations } from './PlanDescription'; +import { productFields, productOperations } from './ProductDescription'; +import { userFields, userOperations } from './UserDescription'; + +import moment = require('moment'); +import { response } from 'express'; export class Paddle implements INodeType { description: INodeTypeDescription = { @@ -41,7 +47,6 @@ export class Paddle implements INodeType { { name: 'Coupon', value: 'coupon', - }, { name: 'Payments', @@ -68,6 +73,22 @@ export class Paddle implements INodeType { description: 'Resource to consume.', }, + // COUPON + couponFields, + couponOperations, + // PAYMENT + paymentFields, + paymentOperations, + // PLAN + planFields, + planOperations, + // PRODUCT + productFields, + productOperations, + // USER + userFields, + userOperations + ], }; @@ -76,10 +97,197 @@ export class Paddle implements INodeType { const returnData: IDataObject[] = []; const length = items.length as unknown as number; let responseData; - const qs: IDataObject = {}; + const body: 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 === 'coupon') { + if (operation === 'create') { + const productIds = this.getNodeParameter('productIds', i) as string; + const discountType = this.getNodeParameter('discountType', i) as string; + const discountAmount = this.getNodeParameter('discountAmount', i) as number; + const currency = this.getNodeParameter('currency', i) as string; + + body.product_ids = productIds; + body.discount_type = discountType; + body.discount_amount = discountAmount; + body.currency = currency; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (additionalFields.allowedUses) { + body.allowed_uses = additionalFields.allowedUses as number; + } + if (additionalFields.couponCode) { + body.coupon_code = additionalFields.couponCode as string; + } + if (additionalFields.couponPrefix) { + body.coupon_prefix = additionalFields.couponPrefix as string; + } + if (additionalFields.expires) { + body.expires = moment(additionalFields.expires as Date).format('YYYY/MM/DD') as string; + } + if (additionalFields.group) { + body.group = additionalFields.group as string; + } + if (additionalFields.recurring) { + if (additionalFields.recurring === true) { + body.recurring = 1; + } else { + body.recurring = 0; + } + } + if (additionalFields.numberOfCoupons) { + body.num_coupons = additionalFields.numberOfCoupons as number; + } + if (additionalFields.description) { + body.description = additionalFields.description as string; + } + + const endpoint = '/2.1/product/create_coupon'; + + responseData = paddleApiRequest.call(this, endpoint, 'POST', body); + } + + if (operation === 'getAll') { + const productIds = this.getNodeParameter('productId', i) as string; + const endpoint = '/2.0/product/list_coupons'; + + body.product_ids = productIds as string; + + responseData = paddleApiRequest.call(this, endpoint, 'POST', body); + } + + if (operation === 'update') { + const updateBy = this.getNodeParameter('updateBy', i) as string; + + if (updateBy === 'group') { + body.group = this.getNodeParameter('group', i) as string; + } else { + body.coupon_code = this.getNodeParameter('couponCode', i) as string; + } + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (additionalFields.allowedUses) { + body.allowed_uses = additionalFields.allowedUses as number; + } + if (additionalFields.currency) { + body.currency = additionalFields.currency as string; + } + if (additionalFields.newCouponCode) { + body.new_coupon_code = additionalFields.newCouponCode as string; + } + if (additionalFields.expires) { + body.expires = moment(additionalFields.expires as Date).format('YYYY/MM/DD') as string; + } + if (additionalFields.newGroup) { + body.new_group = additionalFields.newGroup as string; + } + if (additionalFields.recurring) { + if (additionalFields.recurring === true) { + body.recurring = 1; + } else { + body.recurring = 0; + } + } + if (additionalFields.productIds) { + body.product_ids = additionalFields.productIds as number; + } + if (additionalFields.discountAmount) { + body.discount_amount = additionalFields.discountAmount as number; + } + + const endpoint = '/2.1/product/update_coupon'; + + responseData = paddleApiRequest.call(this, endpoint, 'POST', body); + } + } + if (resource === 'payment') { + if (operation === 'getAll') { + const subscriptionId = this.getNodeParameter('subscription', i) as string; + const planId = this.getNodeParameter('planId', i) as string; + + body.subscription_id = subscriptionId; + body.plan_id = planId; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (additionalFields.state) { + body.state = additionalFields.state as string; + } + if (additionalFields.isPaid) { + if (additionalFields.isPaid === true) { + body.is_paid = 0; + } else { + body.is_paid = 1; + } + } + if (additionalFields.from) { + body.from = moment(additionalFields.from as Date).format('YYYY/MM/DD') as string; + } + if (additionalFields.to) { + body.to = moment(additionalFields.to as Date).format('YYYY/MM/DD') as string; + } + if (additionalFields.isOneOffCharge) { + body.is_one_off_charge = additionalFields.isOneOffCharge as boolean; + } + + const endpoint = '/2.0/subscription/payments'; + responseData = paddleApiRequest.call(this, endpoint, 'POST', body); + } + if (operation === 'reschedule') { + const paymentId = this.getNodeParameter('paymentId', i) as number; + const date = this.getNodeParameter('date', i) as Date; + + body.payment_id = paymentId; + body.date = body.to = moment(date as Date).format('YYYY/MM/DD') as string; + + const endpoint = '/2.0/subscription/payments_reschedule'; + + responseData = paddleApiRequest.call(this, endpoint, 'POST', body); + } + } + if (resource === 'plan') { + if (operation === 'getAll') { + const planId = this.getNodeParameter('planId', i) as string; + + body.plan = planId; + + const endpoint = '/2.0/subscription/plans'; + + responseData = paddleApiRequest.call(this, endpoint, 'POST', body); + } + } + if (resource === 'product') { + if (operation === 'getAll') { + const endpoint = '/2.0/product/get_products'; + + responseData = paddleApiRequest.call(this, endpoint, 'POST', body); + } + } + + if (resource === 'user') { + if (operation === 'getAll') { + const subscriptionId = this.getNodeParameter('subscriptionId', i) as string; + const planId = this.getNodeParameter('planId', i) as string; + const limit = this.getNodeParameter('limit', i) as number; + + body.subscription_id = subscriptionId; + body.plan_id = planId; + body.results_per_page = limit; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (additionalFields.state) { + body.state = additionalFields.state as string; + } + + const endpoint = '/2.0/subscription/users'; + + responseData = paddleApiRequest.call(this, endpoint, 'POST', body); + } + } if (Array.isArray(responseData)) { returnData.push.apply(returnData, responseData as IDataObject[]); diff --git a/packages/nodes-base/nodes/Paddle/PaddleTrigger.node.ts b/packages/nodes-base/nodes/Paddle/PaddleTrigger.node.ts index a2b5c6e484..f439a5beb6 100644 --- a/packages/nodes-base/nodes/Paddle/PaddleTrigger.node.ts +++ b/packages/nodes-base/nodes/Paddle/PaddleTrigger.node.ts @@ -11,6 +11,7 @@ import { ILoadOptionsFunctions, INodePropertyOptions, } from 'n8n-workflow'; +import { paddleApiRequest } from './GenericFunctions'; export class PaddleTrigger implements INodeType { description: INodeTypeDescription = { @@ -69,7 +70,7 @@ import { } const endpoint = `/notifications/webhooks/${webhookData.webhookId}`; try { - await payPalApiRequest.call(this, endpoint, 'GET'); + await paddleApiRequest.call(this, endpoint, 'GET'); } catch (err) { if (err.response && err.response.name === 'INVALID_RESOURCE_ID') { // Webhook does not exist @@ -93,7 +94,7 @@ import { }; const endpoint = '/notifications/webhooks'; try { - webhook = await payPalApiRequest.call(this, endpoint, 'POST', body); + webhook = await paddleApiRequest.call(this, endpoint, 'POST', body); } catch (e) { throw e; } @@ -111,7 +112,7 @@ import { if (webhookData.webhookId !== undefined) { const endpoint = `/notifications/webhooks/${webhookData.webhookId}`; try { - await payPalApiRequest.call(this, endpoint, 'DELETE', {}); + await paddleApiRequest.call(this, endpoint, 'DELETE', {}); } catch (e) { return false; } @@ -145,7 +146,7 @@ import { webhook_event: bodyData, }; try { - webhook = await payPalApiRequest.call(this, endpoint, 'POST', body); + webhook = await paddleApiRequest.call(this, endpoint, 'POST', body); } catch (e) { throw e; } diff --git a/packages/nodes-base/nodes/Paddle/PaymentDescription.ts b/packages/nodes-base/nodes/Paddle/PaymentDescription.ts index ef4faa55bc..77ff5ca432 100644 --- a/packages/nodes-base/nodes/Paddle/PaymentDescription.ts +++ b/packages/nodes-base/nodes/Paddle/PaymentDescription.ts @@ -32,7 +32,6 @@ export const paymentOperations = [ ] as INodeProperties[]; export const paymentFields = [ - /* -------------------------------------------------------------------------- */ /* payment:getAll */ /* -------------------------------------------------------------------------- */ @@ -72,28 +71,6 @@ export const paymentFields = [ }, description: 'Filter: The product/plan ID (single or comma-separated values).', }, - { - displayName: 'Limit', - name: 'limit', - type: 'number', - default: 1, - required: true, - typeOptions: { - minValue: 1, - maxValue: 200 - }, - displayOptions: { - show: { - resource: [ - 'user', - ], - operation: [ - 'getAll', - ], - }, - }, - description: 'Number of subscription records to return per page.', - }, { displayName: 'Additional Fields', name: 'additionalFields', From 0b46f5d63a8a0052ce733fa82a9186392b4fa557 Mon Sep 17 00:00:00 2001 From: Rupenieks <32895755+Rupenieks@users.noreply.github.com> Date: Thu, 2 Jul 2020 11:12:27 +0200 Subject: [PATCH 0003/1150] :construction: Tests / changes --- .../nodes/Paddle/CouponDescription.ts | 143 +++++++- .../nodes/Paddle/GenericFunctions.ts | 29 +- .../nodes-base/nodes/Paddle/Paddle.node.ts | 328 +++++++++++------- .../nodes/Paddle/PaymentDescription.ts | 130 ++++--- .../nodes/Paddle/PlanDescription.ts | 9 +- .../nodes/Paddle/UserDescription.ts | 96 +++-- 6 files changed, 514 insertions(+), 221 deletions(-) diff --git a/packages/nodes-base/nodes/Paddle/CouponDescription.ts b/packages/nodes-base/nodes/Paddle/CouponDescription.ts index 553a0c0ffd..64e87cf9a0 100644 --- a/packages/nodes-base/nodes/Paddle/CouponDescription.ts +++ b/packages/nodes-base/nodes/Paddle/CouponDescription.ts @@ -51,6 +51,9 @@ export const couponFields = [ ], operation: [ `create` + ], + jsonParameters: [ + false ] }, }, @@ -82,6 +85,9 @@ export const couponFields = [ couponType: [ 'product', ], + jsonParameters: [ + false + ] }, }, default: '', @@ -100,6 +106,9 @@ export const couponFields = [ operation: [ `create` ], + jsonParameters: [ + false + ] }, }, default: 'flat', @@ -122,7 +131,7 @@ export const couponFields = [ default: '', description: 'Discount amount in currency.', typeOptions: { - minValue: 0 + minValue: 1 }, displayOptions: { show: { @@ -134,6 +143,9 @@ export const couponFields = [ ], discountType: [ 'flat', + ], + jsonParameters: [ + false ] }, }, @@ -145,7 +157,7 @@ export const couponFields = [ default: '', description: 'Discount amount in percentage.', typeOptions: { - minValue: 0, + minValue: 1, maxValue: 100 }, displayOptions: { @@ -158,6 +170,9 @@ export const couponFields = [ ], discountType: [ 'percentage', + ], + jsonParameters: [ + false ] }, }, @@ -166,20 +181,20 @@ export const couponFields = [ displayName: 'Currency', name: 'currency', type: 'options', - default: 'eur', + default: 'EUR', description: 'The currency must match the balance currency specified in your account.', options: [ { name: 'EUR', - value: 'eur' + value: 'EUR' }, { name: 'GBP', - value: 'gbp' + value: 'GBP' }, { name: 'USD', - value: 'usd' + value: 'USD' }, ], displayOptions: { @@ -192,10 +207,53 @@ export const couponFields = [ ], discountType: [ 'flat', + ], + jsonParameters: [ + false ] }, }, }, + { + displayName: 'JSON Parameters', + name: 'jsonParameters', + type: 'boolean', + default: false, + description: '', + displayOptions: { + show: { + resource: [ + 'coupon', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: ' Additional Fields', + name: 'additionalFieldsJson', + type: 'json', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + displayOptions: { + show: { + resource: [ + 'coupon', + ], + operation: [ + 'create', + ], + jsonParameters: [ + true, + ], + }, + }, + description: `Attributes in JSON form.`, + }, { displayName: 'Additional Fields', name: 'additionalFields', @@ -209,6 +267,9 @@ export const couponFields = [ operation: [ 'create', ], + jsonParameters: [ + false + ] }, }, default: {}, @@ -237,7 +298,7 @@ export const couponFields = [ { displayName: 'Expires', name: 'expires', - type: 'DateTime', + type: 'dateTime', default: '', description: 'The coupon will expire on the date at 00:00:00 UTC.', }, @@ -310,6 +371,9 @@ export const couponFields = [ operation: [ `update` ], + jsonParameters: [ + false, + ], }, }, default: 'couponCode', @@ -339,7 +403,10 @@ export const couponFields = [ ], updateBy: [ 'couponCode' - ] + ], + jsonParameters: [ + false, + ], }, }, default: '', @@ -359,12 +426,55 @@ export const couponFields = [ ], updateBy: [ 'group' - ] + ], + jsonParameters: [ + false, + ], }, }, default: '', description: 'The name of the group of coupons you want to update.', }, + { + displayName: 'JSON Parameters', + name: 'jsonParameters', + type: 'boolean', + default: false, + description: '', + displayOptions: { + show: { + resource: [ + 'coupon', + ], + operation: [ + 'update', + ], + }, + }, + }, + { + displayName: ' Additional Fields', + name: 'additionalFieldsJson', + type: 'json', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + displayOptions: { + show: { + resource: [ + 'coupon', + ], + operation: [ + 'update', + ], + jsonParameters: [ + true, + ], + }, + }, + description: `Attributes in JSON form.`, + }, { displayName: 'Additional Fields', name: 'additionalFields', @@ -378,6 +488,9 @@ export const couponFields = [ operation: [ 'update', ], + jsonParameters: [ + false + ] }, }, default: {}, @@ -393,20 +506,20 @@ export const couponFields = [ displayName: 'Currency', name: 'currency', type: 'options', - default: 'eur', + default: 'EUR', description: 'The currency must match the balance currency specified in your account.', options: [ { name: 'EUR', - value: 'eur' + value: 'EUR' }, { name: 'GBP', - value: 'gbp' + value: 'GBP' }, { name: 'USD', - value: 'usd' + value: 'USD' }, ], }, @@ -423,7 +536,7 @@ export const couponFields = [ { displayName: 'Expires', name: 'expires', - type: 'DateTime', + type: 'dateTime', default: '', description: 'The coupon will expire on the date at 00:00:00 UTC.', }, @@ -450,7 +563,7 @@ export const couponFields = [ name: 'productIds', type: 'string', default: '', - description: 'Comma-separated list of product IDs. Required if coupon_type is product.', + description: 'Comma-separated list of products e.g. 499531,1234,123546. If blank then remove associated products.', }, { displayName: 'Recurring', diff --git a/packages/nodes-base/nodes/Paddle/GenericFunctions.ts b/packages/nodes-base/nodes/Paddle/GenericFunctions.ts index c56498935a..8aa1318845 100644 --- a/packages/nodes-base/nodes/Paddle/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Paddle/GenericFunctions.ts @@ -22,18 +22,41 @@ export async function paddleApiRequest(this: IHookFunctions | IExecuteFunctions const options : OptionsWithUri = { method, + headers: { + 'content-type': 'application/json' + }, uri: `https://vendors.paddle.com/api${endpoint}` , body, json: true }; - body.vendor_id = credentials.vendorId; - body.vendor_auth_code = credentials.vendorAuthCode; + body['vendor_id'] = credentials.vendorId; + body['vendor_auth_code'] = credentials.vendorAuthCode; + + console.log(options.body); + console.log(options); try { - return await this.helpers.request!(options); + const response = await this.helpers.request!(options); + console.log(response); + + if (!response.success) { + throw new Error(`Code: ${response.error.code}. Message: ${response.error.message}`); + } + + return response.response; } catch (error) { console.log(error); throw new Error(error); } } + +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/Paddle/Paddle.node.ts b/packages/nodes-base/nodes/Paddle/Paddle.node.ts index 0a781e9bb4..e5478aa115 100644 --- a/packages/nodes-base/nodes/Paddle/Paddle.node.ts +++ b/packages/nodes-base/nodes/Paddle/Paddle.node.ts @@ -1,21 +1,20 @@ import { IExecuteFunctions } from 'n8n-core'; import { IDataObject, - INodeExecutionData, - INodeType, INodeTypeDescription } from 'n8n-workflow'; + import { couponFields, couponOperations } from './CouponDescription'; -import { paddleApiRequest } from './GenericFunctions'; -import { paymentFields, paymentOperations } from './PaymentDescription'; +import { paddleApiRequest, validateJSON } from './GenericFunctions'; +import { paymentsFields, paymentsOperations } from './PaymentDescription'; import { planFields, planOperations } from './PlanDescription'; import { productFields, productOperations } from './ProductDescription'; import { userFields, userOperations } from './UserDescription'; import moment = require('moment'); -import { response } from 'express'; +import { orderOperations, orderFields } from './OrderDescription'; export class Paddle implements INodeType { description: INodeTypeDescription = { @@ -72,23 +71,24 @@ export class Paddle implements INodeType { default: 'coupon', description: 'Resource to consume.', }, - // COUPON - couponFields, - couponOperations, + ...couponOperations, + ...couponFields, // PAYMENT - paymentFields, - paymentOperations, + ...paymentsOperations, + ...paymentsFields, // PLAN - planFields, - planOperations, + ...planOperations, + ...planFields, // PRODUCT - productFields, - productOperations, + ...productOperations, + ...productFields, + // ORDER + ...orderOperations, + ...orderFields, // USER - userFields, - userOperations - + ...userOperations, + ...userFields ], }; @@ -103,192 +103,274 @@ export class Paddle implements INodeType { for (let i = 0; i < length; i++) { if (resource === 'coupon') { if (operation === 'create') { - const productIds = this.getNodeParameter('productIds', i) as string; - const discountType = this.getNodeParameter('discountType', i) as string; - const discountAmount = this.getNodeParameter('discountAmount', i) as number; - const currency = this.getNodeParameter('currency', i) as string; + const jsonParameters = this.getNodeParameter('jsonParameters', i) as boolean; - body.product_ids = productIds; - body.discount_type = discountType; - body.discount_amount = discountAmount; - body.currency = currency; + if (jsonParameters) { + const additionalFieldsJson = this.getNodeParameter('additionalFieldsJson', i) as string; - const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + if (additionalFieldsJson !== '') { + if (validateJSON(additionalFieldsJson) !== undefined) { + Object.assign(body, JSON.parse(additionalFieldsJson)); + } else { + throw new Error('Additional fields must be a valid JSON'); + } + } - if (additionalFields.allowedUses) { - body.allowed_uses = additionalFields.allowedUses as number; - } - if (additionalFields.couponCode) { - body.coupon_code = additionalFields.couponCode as string; - } - if (additionalFields.couponPrefix) { - body.coupon_prefix = additionalFields.couponPrefix as string; - } - if (additionalFields.expires) { - body.expires = moment(additionalFields.expires as Date).format('YYYY/MM/DD') as string; - } - if (additionalFields.group) { - body.group = additionalFields.group as string; - } - if (additionalFields.recurring) { - if (additionalFields.recurring === true) { + } else { + + const discountType = this.getNodeParameter('discountType', i) as string; + const couponType = this.getNodeParameter('couponType', i) as string; + const discountAmount = this.getNodeParameter('discountAmount', i) as number; + const currency = this.getNodeParameter('currency', i) as string; + + if (couponType === 'product') { + body.product_ids = this.getNodeParameter('productIds', i) as string; + } + + body.coupon_type = couponType; + body.discount_type = discountType; + body.discount_amount = discountAmount; + body.currency = currency; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (additionalFields.allowedUses) { + body.allowed_uses = additionalFields.allowedUses as number; + } + if (additionalFields.couponCode) { + body.coupon_code = additionalFields.couponCode as string; + } + if (additionalFields.couponPrefix) { + body.coupon_prefix = additionalFields.couponPrefix as string; + } + if (additionalFields.expires) { + body.expires = moment(additionalFields.expires as Date).format('YYYY-MM-DD') as string; + } + if (additionalFields.group) { + body.group = additionalFields.group as string; + } + if (additionalFields.recurring) { body.recurring = 1; } else { body.recurring = 0; } - } - if (additionalFields.numberOfCoupons) { - body.num_coupons = additionalFields.numberOfCoupons as number; - } - if (additionalFields.description) { - body.description = additionalFields.description as string; - } + if (additionalFields.numberOfCoupons) { + body.num_coupons = additionalFields.numberOfCoupons as number; + } + if (additionalFields.description) { + body.description = additionalFields.description as string; + } - const endpoint = '/2.1/product/create_coupon'; + const endpoint = '/2.1/product/create_coupon'; - responseData = paddleApiRequest.call(this, endpoint, 'POST', body); + responseData = await paddleApiRequest.call(this, endpoint, 'POST', body); + } } if (operation === 'getAll') { const productIds = this.getNodeParameter('productId', i) as string; const endpoint = '/2.0/product/list_coupons'; - body.product_ids = productIds as string; + body.product_id = productIds as string; - responseData = paddleApiRequest.call(this, endpoint, 'POST', body); + responseData = await paddleApiRequest.call(this, endpoint, 'POST', body); } if (operation === 'update') { - const updateBy = this.getNodeParameter('updateBy', i) as string; - if (updateBy === 'group') { - body.group = this.getNodeParameter('group', i) as string; + 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 { - body.coupon_code = this.getNodeParameter('couponCode', i) as string; - } + const updateBy = this.getNodeParameter('updateBy', i) as string; - const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + if (updateBy === 'group') { + body.group = this.getNodeParameter('group', i) as string; + } else { + body.coupon_code = this.getNodeParameter('couponCode', i) as string; + } - if (additionalFields.allowedUses) { - body.allowed_uses = additionalFields.allowedUses as number; - } - if (additionalFields.currency) { - body.currency = additionalFields.currency as string; - } - if (additionalFields.newCouponCode) { - body.new_coupon_code = additionalFields.newCouponCode as string; - } - if (additionalFields.expires) { - body.expires = moment(additionalFields.expires as Date).format('YYYY/MM/DD') as string; - } - if (additionalFields.newGroup) { - body.new_group = additionalFields.newGroup as string; - } - if (additionalFields.recurring) { - if (additionalFields.recurring === true) { + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (additionalFields.allowedUses) { + body.allowed_uses = additionalFields.allowedUses as number; + } + if (additionalFields.currency) { + body.currency = additionalFields.currency as string; + } + if (additionalFields.newCouponCode) { + body.new_coupon_code = additionalFields.newCouponCode as string; + } + if (additionalFields.expires) { + body.expires = moment(additionalFields.expires as Date).format('YYYY-MM-DD') as string; + } + if (additionalFields.newGroup) { + body.new_group = additionalFields.newGroup as string; + } + if (additionalFields.recurring) { body.recurring = 1; } else { body.recurring = 0; } - } - if (additionalFields.productIds) { - body.product_ids = additionalFields.productIds as number; - } - if (additionalFields.discountAmount) { - body.discount_amount = additionalFields.discountAmount as number; + if (additionalFields.productIds) { + body.product_ids = additionalFields.productIds as number; + } + if (additionalFields.discountAmount) { + body.discount_amount = additionalFields.discountAmount as number; + } } const endpoint = '/2.1/product/update_coupon'; - responseData = paddleApiRequest.call(this, endpoint, 'POST', body); + responseData = await paddleApiRequest.call(this, endpoint, 'POST', body); } } if (resource === 'payment') { if (operation === 'getAll') { - const subscriptionId = this.getNodeParameter('subscription', i) as string; - const planId = this.getNodeParameter('planId', i) as string; + const jsonParameters = this.getNodeParameter('jsonParameters', i) as boolean; - body.subscription_id = subscriptionId; - body.plan_id = planId; + if (jsonParameters) { + const additionalFieldsJson = this.getNodeParameter('additionalFieldsJson', i) as string; - const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; - - if (additionalFields.state) { - body.state = additionalFields.state as string; - } - if (additionalFields.isPaid) { - if (additionalFields.isPaid === true) { - body.is_paid = 0; - } else { - body.is_paid = 1; + 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.subscriptionId) { + body.subscription_id = additionalFields.subscriptionId as number; + } + if (additionalFields.plan) { + body.plan = additionalFields.plan as string; + } + if (additionalFields.state) { + body.state = additionalFields.state as string; + } + if (additionalFields.recurring) { + body.recurring = 1; + } else { + body.recurring = 0; + } + if (additionalFields.from) { + body.from = moment(additionalFields.from as Date).format('YYYY-MM-DD') as string; + } + if (additionalFields.to) { + body.to = moment(additionalFields.to as Date).format('YYYY-MM-DD') as string; + } + if (additionalFields.isOneOffCharge) { + body.is_one_off_charge = additionalFields.isOneOffCharge as boolean; } - } - if (additionalFields.from) { - body.from = moment(additionalFields.from as Date).format('YYYY/MM/DD') as string; - } - if (additionalFields.to) { - body.to = moment(additionalFields.to as Date).format('YYYY/MM/DD') as string; - } - if (additionalFields.isOneOffCharge) { - body.is_one_off_charge = additionalFields.isOneOffCharge as boolean; } const endpoint = '/2.0/subscription/payments'; - responseData = paddleApiRequest.call(this, endpoint, 'POST', body); + + responseData = await paddleApiRequest.call(this, endpoint, 'POST', body); } if (operation === 'reschedule') { const paymentId = this.getNodeParameter('paymentId', i) as number; const date = this.getNodeParameter('date', i) as Date; body.payment_id = paymentId; - body.date = body.to = moment(date as Date).format('YYYY/MM/DD') as string; + body.date = body.to = moment(date as Date).format('YYYY-MM-DD') as string; const endpoint = '/2.0/subscription/payments_reschedule'; - responseData = paddleApiRequest.call(this, endpoint, 'POST', body); + responseData = await paddleApiRequest.call(this, endpoint, 'POST', body); } } if (resource === 'plan') { if (operation === 'getAll') { + const endpoint = '/2.0/subscription/plans'; + + responseData = await paddleApiRequest.call(this, endpoint, 'POST', body); + } + if (operation === 'get') { const planId = this.getNodeParameter('planId', i) as string; body.plan = planId; const endpoint = '/2.0/subscription/plans'; - responseData = paddleApiRequest.call(this, endpoint, 'POST', body); + responseData = await paddleApiRequest.call(this, endpoint, 'POST', body); } } if (resource === 'product') { if (operation === 'getAll') { const endpoint = '/2.0/product/get_products'; - responseData = paddleApiRequest.call(this, endpoint, 'POST', body); + responseData = await paddleApiRequest.call(this, endpoint, 'POST', body); } } + if (resource === 'order') { + if (operation === 'get') { + const endpoint = '/1.0/order'; + const checkoutId = this.getNodeParameter('checkoutId', i) as string; + body.checkout_id = checkoutId; + + responseData = await paddleApiRequest.call(this, endpoint, 'GET', body); + } + } if (resource === 'user') { if (operation === 'getAll') { - const subscriptionId = this.getNodeParameter('subscriptionId', i) as string; - const planId = this.getNodeParameter('planId', i) as string; - const limit = this.getNodeParameter('limit', i) as number; - body.subscription_id = subscriptionId; - body.plan_id = planId; - body.results_per_page = limit; + const jsonParameters = this.getNodeParameter('jsonParameters', i) as boolean; - const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + 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 returnAll = this.getNodeParameter('returnAll', i) as boolean; + + if (!returnAll) { + body.results_per_page = this.getNodeParameter('limit', i) as number; + } + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (additionalFields.state) { + body.state = additionalFields.state as string; + } + if (additionalFields.planId) { + body.plan_id = additionalFields.planId as string; + } + if (additionalFields.subscriptionId) { + body.subscription_id = additionalFields.subscriptionId as string; + } - if (additionalFields.state) { - body.state = additionalFields.state as string; } - const endpoint = '/2.0/subscription/users'; - responseData = paddleApiRequest.call(this, endpoint, 'POST', body); + responseData = await paddleApiRequest.call(this, endpoint, 'POST', body); } } + console.log(responseData); + if (Array.isArray(responseData)) { returnData.push.apply(returnData, responseData as IDataObject[]); } else { diff --git a/packages/nodes-base/nodes/Paddle/PaymentDescription.ts b/packages/nodes-base/nodes/Paddle/PaymentDescription.ts index 77ff5ca432..45d3553850 100644 --- a/packages/nodes-base/nodes/Paddle/PaymentDescription.ts +++ b/packages/nodes-base/nodes/Paddle/PaymentDescription.ts @@ -2,7 +2,7 @@ import { INodeProperties, } from 'n8n-workflow'; -export const paymentOperations = [ +export const paymentsOperations = [ { displayName: 'Operation', name: 'operation', @@ -10,7 +10,7 @@ export const paymentOperations = [ displayOptions: { show: { resource: [ - 'payment', + 'payments', ], }, }, @@ -31,45 +31,49 @@ export const paymentOperations = [ }, ] as INodeProperties[]; -export const paymentFields = [ +export const paymentsFields = [ /* -------------------------------------------------------------------------- */ -/* payment:getAll */ +/* payments:getAll */ /* -------------------------------------------------------------------------- */ { - displayName: 'Subscription ID', - name: 'subscriptionId', - type: 'number', - default: '', - required: true, + displayName: 'JSON Parameters', + name: 'jsonParameters', + type: 'boolean', + default: false, + description: '', displayOptions: { show: { resource: [ - 'user', + 'payments', ], operation: [ 'getAll', ], }, }, - description: 'A specific user subscription ID.', }, { - displayName: 'Plan', - name: 'planId', - type: 'string', + displayName: ' Additional Fields', + name: 'additionalFieldsJson', + type: 'json', + typeOptions: { + alwaysOpenEditWindow: true, + }, default: '', - required: true, displayOptions: { show: { resource: [ - 'user', + 'payments', ], operation: [ 'getAll', ], + jsonParameters: [ + true, + ], }, }, - description: 'Filter: The product/plan ID (single or comma-separated values).', + description: `Attributes in JSON form.`, }, { displayName: 'Additional Fields', @@ -78,16 +82,54 @@ export const paymentFields = [ placeholder: 'Add Field', displayOptions: { show: { + resource: [ + 'payments', + ], operation: [ 'getAll', ], - resource: [ - 'user', - ], + jsonParameters: [ + false + ] }, }, default: {}, options: [ + { + displayName: 'Date From', + name: 'from', + type: 'dateTime', + default: '', + description: 'payments starting from date.', + }, + { + displayName: 'Date To', + name: 'to', + type: 'dateTime', + default: '', + description: 'payments up until date.', + }, + { + displayName: 'Is Paid', + name: 'isPaid', + type: 'boolean', + default: false, + description: 'payment is paid.', + }, + { + displayName: 'Plan', + name: 'plan', + type: 'string', + default: '', + description: 'Filter: The product/plan ID (single or comma-separated values).', + }, + { + displayName: 'Subscription ID', + name: 'subscriptionId', + type: 'number', + default: '', + description: 'A specific user subscription ID.', + }, { displayName: 'State', name: 'state', @@ -113,62 +155,50 @@ export const paymentFields = [ }, ] }, - { - displayName: 'Is Paid', - name: 'isPaid', - type: 'boolean', - default: false, - description: 'Payment is paid.', - }, - { - displayName: 'From', - name: 'from', - type: 'DateTime', - default: '', - description: 'Payments starting from date.', - }, - { - displayName: 'To', - name: 'to', - type: 'DateTime', - default: '', - description: 'Payments up until date.', - }, { displayName: 'One off charge', name: 'isOneOffCharge', type: 'boolean', default: false, - description: 'Payment is paid.', }, ], }, /* -------------------------------------------------------------------------- */ -/* payment:reschedule */ +/* payments:reschedule */ /* -------------------------------------------------------------------------- */ { - displayName: 'Payment ID', - name: 'paymentId', + displayName: 'payments ID', + name: 'paymentsId', type: 'number', default: '', required: true, displayOptions: { show: { resource: [ - 'user', + 'payments', ], operation: [ - 'getAll', + 'reschedule', ], }, }, - description: 'The upcoming subscription payment ID.', // Use loadoptions to select payment + description: 'The upcoming subscription payments ID.', // Use loadoptions to select payments }, { displayName: 'Date', name: 'date', - type: 'DateTime', + type: 'dateTime', default: '', - description: 'Date you want to move the payment to.', + displayOptions: { + show: { + resource: [ + 'payments', + ], + operation: [ + 'reschedule', + ], + }, + }, + description: 'Date you want to move the payments to.', }, ] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Paddle/PlanDescription.ts b/packages/nodes-base/nodes/Paddle/PlanDescription.ts index 9ddf82046e..f6887ca64f 100644 --- a/packages/nodes-base/nodes/Paddle/PlanDescription.ts +++ b/packages/nodes-base/nodes/Paddle/PlanDescription.ts @@ -15,6 +15,11 @@ export const planOperations = [ }, }, options: [ + { + name: 'Get', + value: 'get', + description: 'Get a plan.', + }, { name: 'Get All', value: 'getAll', @@ -29,7 +34,7 @@ export const planOperations = [ export const planFields = [ /* -------------------------------------------------------------------------- */ -/* plan:getAll */ +/* plan:get */ /* -------------------------------------------------------------------------- */ { displayName: 'Plan ID', @@ -43,7 +48,7 @@ export const planFields = [ 'plan', ], operation: [ - 'getAll', + 'get', ], }, }, diff --git a/packages/nodes-base/nodes/Paddle/UserDescription.ts b/packages/nodes-base/nodes/Paddle/UserDescription.ts index b970be875a..d45cc2ddb1 100644 --- a/packages/nodes-base/nodes/Paddle/UserDescription.ts +++ b/packages/nodes-base/nodes/Paddle/UserDescription.ts @@ -27,16 +27,13 @@ export const userOperations = [ ] as INodeProperties[]; export const userFields = [ - /* -------------------------------------------------------------------------- */ /* user:getAll */ /* -------------------------------------------------------------------------- */ { - displayName: 'Subscription ID', - name: 'subscriptionId', - type: 'string', - default: '', - required: true, + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', displayOptions: { show: { resource: [ @@ -47,25 +44,8 @@ export const userFields = [ ], }, }, - description: 'A specific user subscription ID.', - }, - { - displayName: 'Plan ID', - name: 'planId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - resource: [ - 'user', - ], - operation: [ - 'getAll', - ], - }, - }, - description: 'Filter: The subscription plan ID.', + default: false, + description: 'If all results should be returned or only up to a given limit.', }, { displayName: 'Limit', @@ -85,10 +65,53 @@ export const userFields = [ operation: [ 'getAll', ], + returnAll: [ + false + ] }, }, description: 'Number of subscription records to return per page.', }, + { + displayName: 'JSON Parameters', + name: 'jsonParameters', + type: 'boolean', + default: false, + description: '', + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: ' Additional Fields', + name: 'additionalFieldsJson', + type: 'json', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + displayOptions: { + show: { + resource: [ + 'user', + ], + operation: [ + 'getAll', + ], + jsonParameters: [ + true, + ], + }, + }, + description: `Attributes in JSON form.`, + }, { displayName: 'Additional Fields', name: 'additionalFields', @@ -96,16 +119,33 @@ export const userFields = [ placeholder: 'Add Field', displayOptions: { show: { - operation: [ - 'getAll', - ], resource: [ 'user', ], + operation: [ + 'getAll', + ], + jsonParameters: [ + false + ] }, }, default: {}, options: [ + { + displayName: 'Plan ID', + name: 'planId', + type: 'string', + default: '', + description: 'Filter: The subscription plan ID.', + }, + { + displayName: 'Subscription ID', + name: 'subscriptionId', + type: 'string', + default: '', + description: 'A specific user subscription ID.', + }, { displayName: 'State', name: 'state', From e209077160ca9126f75f10a4cb7846c18adbedaf Mon Sep 17 00:00:00 2001 From: Erin Date: Thu, 2 Jul 2020 16:41:59 -0400 Subject: [PATCH 0004/1150] :sparkles: ConvertKit Trigger and Regular Node --- .../credentials/ConvertKitApi.credentials.ts | 21 ++ .../nodes/ConvertKit/ConvertKit.node.ts | 289 ++++++++++++++++++ .../ConvertKit/ConvertKitTrigger.node.ts | 163 ++++++++++ .../nodes/ConvertKit/FieldDescription.ts | 83 +++++ .../nodes/ConvertKit/FormDescription.ts | 174 +++++++++++ .../nodes/ConvertKit/GenericFunctions.ts | 44 +++ .../nodes/ConvertKit/SequenceDescription.ts | 174 +++++++++++ .../nodes/ConvertKit/TagDescription.ts | 204 +++++++++++++ .../nodes/ConvertKit/convertKit.png | Bin 0 -> 4958 bytes packages/nodes-base/package.json | 3 + 10 files changed, 1155 insertions(+) create mode 100644 packages/nodes-base/credentials/ConvertKitApi.credentials.ts create mode 100644 packages/nodes-base/nodes/ConvertKit/ConvertKit.node.ts create mode 100644 packages/nodes-base/nodes/ConvertKit/ConvertKitTrigger.node.ts create mode 100644 packages/nodes-base/nodes/ConvertKit/FieldDescription.ts create mode 100644 packages/nodes-base/nodes/ConvertKit/FormDescription.ts create mode 100644 packages/nodes-base/nodes/ConvertKit/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/ConvertKit/SequenceDescription.ts create mode 100644 packages/nodes-base/nodes/ConvertKit/TagDescription.ts create mode 100644 packages/nodes-base/nodes/ConvertKit/convertKit.png diff --git a/packages/nodes-base/credentials/ConvertKitApi.credentials.ts b/packages/nodes-base/credentials/ConvertKitApi.credentials.ts new file mode 100644 index 0000000000..1685c11a24 --- /dev/null +++ b/packages/nodes-base/credentials/ConvertKitApi.credentials.ts @@ -0,0 +1,21 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class ConvertKitApi implements ICredentialType { + name = 'convertKitApi'; + displayName = 'ConvertKit Api'; + properties = [ + { + displayName: 'API Secret', + name: 'apiSecret', + type: 'string' as NodePropertyTypes, + default: '', + typeOptions: { + password: true, + }, + }, + ]; +} diff --git a/packages/nodes-base/nodes/ConvertKit/ConvertKit.node.ts b/packages/nodes-base/nodes/ConvertKit/ConvertKit.node.ts new file mode 100644 index 0000000000..71f58a4172 --- /dev/null +++ b/packages/nodes-base/nodes/ConvertKit/ConvertKit.node.ts @@ -0,0 +1,289 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, + INodeTypeDescription, + INodeType, +} from 'n8n-workflow'; + +import { + convertKitApiRequest, +} from './GenericFunctions'; + +import { + fieldOperations, + fieldFields, +} from './FieldDescription'; + +import { + formOperations, + formFields, +} from './FormDescription'; + +import { + sequenceOperations, + sequenceFields, +} from './SequenceDescription'; + +import { + tagOperations, + tagFields, +} from './TagDescription'; + +export class ConvertKit implements INodeType { + description: INodeTypeDescription = { + displayName: 'ConvertKit', + name: 'convertKit', + icon: 'file:convertKit.png', + group: ['input'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume ConvertKit API.', + defaults: { + name: 'ConvertKit', + color: '#fb6970', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'convertKitApi', + required: true, + } + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Field', + value: 'field', + }, + { + name: 'Form', + value: 'form', + }, + { + name: 'Sequence', + value: 'sequence', + }, + { + name: 'Tag', + value: 'tag', + }, + ], + default: 'field', + description: 'The resource to operate on.' + }, + //-------------------- + // Field Description + //-------------------- + ...fieldOperations, + ...fieldFields, + //-------------------- + // FormDescription + //-------------------- + ...formOperations, + ...formFields, + //-------------------- + // Sequence Description + //-------------------- + ...sequenceOperations, + ...sequenceFields, + //-------------------- + // Tag Description + //-------------------- + ...tagOperations, + ...tagFields, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: IDataObject[] = []; + + let method = ''; + let endpoint = ''; + const qs: IDataObject = {}; + let responseData; + + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + const fullOperation = `${resource}/${operation}`; + + for (let i = 0; i < items.length; i++) { + //-------------------- + // Field Operations + //-------------------- + if(resource === 'field') { + //--------- + // Update + //--------- + if(operation === 'update') { + qs.label = this.getNodeParameter('label', i) as string; + + const id = this.getNodeParameter('id', i) as string; + + method = 'PUT'; + endpoint = `/custom_fields/${id}`; + //--------- + // Get All + //--------- + } else if(operation === 'getAll') { + method = 'GET'; + endpoint = '/custom_fields'; + //--------- + // Create + //--------- + } else if(operation === 'create') { + qs.label = this.getNodeParameter('label', i) as string; + + method = 'POST'; + endpoint = '/custom_fields'; + //--------- + // Delete + //--------- + } else if(operation === 'delete') { + const id = this.getNodeParameter('id', i) as string; + + method = 'DELETE'; + endpoint = `/custom_fields/${id}`; + } else { + throw new Error(`The operation "${operation}" is not known!`); + } + //-------------------------------------------- + // Form, Sequence, and Tag Operations + //-------------------------------------------- + } else if(['form', 'sequence', 'tag'].includes(resource)) { + //----------------- + // Add Subscriber + //----------------- + if(operation === 'addSubscriber') { + qs.email= this.getNodeParameter('email', i) as string; + const id = this.getNodeParameter('id', i); + + const additionalParams = this.getNodeParameter('additionalFields', 0) as IDataObject; + + if(additionalParams.firstName) { + qs.first_name = additionalParams.firstName; + } + + if(additionalParams.fields !== undefined) { + const fields = {} as IDataObject; + const fieldsParams = additionalParams.fields as IDataObject; + const field = fieldsParams?.field as IDataObject[]; + + for(let j = 0; j < field.length; j++) { + const key = field[j].key as string; + const value = field[j].value as string; + + fields[key] = value; + } + + qs.fields = fields; + } + + if(resource === 'form') { + method = 'POST'; + endpoint = `/forms/${id}/subscribe`; + } else if(resource === 'sequence') { + method = 'POST'; + endpoint = `/sequences/${id}/subscribe`; + } else if(resource === 'tag') { + method = 'POST'; + endpoint = `/tags/${id}/subscribe`; + } + //----------------- + // Get All + //----------------- + } else if(operation === 'getAll') { + method = 'GET'; + if(resource === 'form') { + endpoint = '/forms'; + } else if(resource === 'tag') { + endpoint = '/tags'; + } else if(resource === 'sequence') { + endpoint = '/sequences'; + } + //-------------------- + // Get Subscriptions + //-------------------- + } else if(operation === 'getSubscriptions') { + const id = this.getNodeParameter('id', i); + const additionalParams = this.getNodeParameter('additionalFields', 0) as IDataObject; + if(additionalParams.subscriberState) { + qs.subscriber_state = additionalParams.subscriberState; + } + + method = 'GET'; + if(resource === 'form') { + endpoint = `/forms/${id}/subscriptions`; + } else if(resource === 'tag') { + endpoint = `/tags/${id}/subscriptions`; + } else if(resource === 'sequence') { + endpoint = `/sequences/${id}/subscriptions`; + } + //------------ + // Create Tag + //------------ + } else if(operation === 'create') { + const name = this.getNodeParameter('name', i); + qs.tag = { name, }; + + method = 'POST'; + endpoint = '/tags'; + //------------ + // Remove Tag + //------------ + } else if(operation === 'removeSubscriber') { + const id = this.getNodeParameter('id', i); + + qs.email = this.getNodeParameter('email', i); + + method = 'POST'; + endpoint = `/tags/${id}/unsubscribe`; + } else { + throw new Error(`The operation "${operation}" is not known!`); + } + } else { + throw new Error(`The resource "${resource}" is not known!`); + } + + responseData = await convertKitApiRequest.call(this, method, endpoint, {}, qs); + + if(fullOperation === 'field/getAll') { + responseData = responseData.custom_fields; + } else if(['form/addSubscriber', 'tag/addSubscriber', 'sequence/addSubscriber'].includes(fullOperation)) { + responseData = responseData.subscription; + } else if(fullOperation === 'form/getAll') { + responseData = responseData.forms; + } else if(['form/getSubscriptions', 'tag/getSubscriptions'].includes(fullOperation)) { + responseData = responseData.subscriptions; + } else if(fullOperation === 'tag/getAll') { + responseData = responseData.tags; + } else if(fullOperation === 'sequence/getAll') { + responseData = responseData.courses; + } + + if (Array.isArray(responseData)) { + returnData.push.apply(returnData, responseData as IDataObject[]); + } else if (responseData !== undefined) { + returnData.push(responseData as IDataObject); + } else { + if(method === 'GET') { + returnData.push( { } ); + } else { + returnData.push( { success: true } ); + } + } + } + + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/ConvertKit/ConvertKitTrigger.node.ts b/packages/nodes-base/nodes/ConvertKit/ConvertKitTrigger.node.ts new file mode 100644 index 0000000000..5b1c6c2274 --- /dev/null +++ b/packages/nodes-base/nodes/ConvertKit/ConvertKitTrigger.node.ts @@ -0,0 +1,163 @@ +import { + IHookFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeTypeDescription, + INodeType, + IWebhookResponseData, +} from 'n8n-workflow'; + +import { + convertKitApiRequest, +} from './GenericFunctions'; + + +export class ConvertKitTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'ConvertKit Trigger', + name: 'convertKitTrigger', + icon: 'file:convertKit.png', + subtitle: '={{$parameter["event"]}}', + group: ['trigger'], + version: 1, + description: 'Handle ConvertKit events via webhooks', + defaults: { + name: 'ConvertKit Trigger', + color: '#fb6970', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'convertKitApi', + required: true, + } + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Event', + name: 'event', + type: 'options', + required: true, + default: 'subscriberActivated', + description: 'The events that can trigger the webhook and whether they are enabled.', + options: [ + { + name: 'Subscriber Activated', + value: 'subscriberActivated', + description: 'Whether the webhook is triggered when a subscriber is activated.', + }, + { + name: 'Link Clicked', + value: 'linkClicked', + description: 'Whether the webhook is triggered when a link is clicked.', + }, + ], + }, + { + displayName: 'Initiating Link', + name: 'link', + type: 'string', + required: true, + default: '', + description: 'The URL of the initiating link', + displayOptions: { + show: { + event: [ + 'linkClicked', + ], + }, + }, + }, + ], + }; + + // @ts-ignore (because of request) + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + + if(webhookData.webhookId) { + return true; + } + return false; + }, + + async create(this: IHookFunctions): Promise { + let webhook; + const webhookUrl = this.getNodeWebhookUrl('default'); + const event = this.getNodeParameter('event', 0); + const endpoint = '/automations/hooks'; + + const qs: IDataObject = {}; + + try { + qs.target_url = webhookUrl; + + if(event === 'subscriberActivated') { + qs.event = { + name: 'subscriber.subscriber_activate', + }; + } else if(event === 'linkClicked') { + const link = this.getNodeParameter('link', 0) as string; + qs.event = { + name: 'subscriber.link_click', + initiator_value: link, + }; + } + webhook = await convertKitApiRequest.call(this, 'POST', endpoint, {}, qs); + } catch (error) { + throw error; + } + + if (webhook.rule.id === undefined) { + return false; + } + + const webhookData = this.getWorkflowStaticData('node'); + webhookData.webhookId = webhook.rule.id as string; + webhookData.events = event; + return true; + }, + + async delete(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + if (webhookData.webhookId !== undefined) { + const endpoint = `/automations/hooks/${webhookData.webhookId}`; + try { + await convertKitApiRequest.call(this, 'DELETE', endpoint, {}, {}); + } catch (error) { + return false; + } + delete webhookData.webhookId; + delete webhookData.events; + } + return true; + }, + }, + }; + + + async webhook(this: IWebhookFunctions): Promise { + const returnData: IDataObject[] = []; + returnData.push(this.getBodyData()); + + return { + workflowData: [ + this.helpers.returnJsonArray(returnData), + ], + }; + } +} diff --git a/packages/nodes-base/nodes/ConvertKit/FieldDescription.ts b/packages/nodes-base/nodes/ConvertKit/FieldDescription.ts new file mode 100644 index 0000000000..8966e60efa --- /dev/null +++ b/packages/nodes-base/nodes/ConvertKit/FieldDescription.ts @@ -0,0 +1,83 @@ +import { + INodeProperties +} from 'n8n-workflow'; + +export const fieldOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'field', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a field.', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a field.', + }, + { + name: 'Get All', + value: 'getAll', + description: `List all of your account's custom fields.`, + }, + { + name: 'Update', + value: 'update', + description: 'Update a field.', + }, + ], + default: 'update', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const fieldFields = [ + { + displayName: 'Field ID', + name: 'id', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'field', + ], + operation: [ + 'update', + 'delete', + ], + }, + }, + default: '', + description: 'The ID of your custom field.', + }, + { + displayName: 'Label', + name: 'label', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'field', + ], + operation: [ + 'update', + 'create', + ], + }, + }, + default: '', + description: 'The label of the custom field.', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/ConvertKit/FormDescription.ts b/packages/nodes-base/nodes/ConvertKit/FormDescription.ts new file mode 100644 index 0000000000..3e266d18a5 --- /dev/null +++ b/packages/nodes-base/nodes/ConvertKit/FormDescription.ts @@ -0,0 +1,174 @@ +import { + INodeProperties +} from 'n8n-workflow'; + +export const formOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'form', + ], + }, + }, + options: [ + { + name: 'Add Subscriber', + value: 'addSubscriber', + description: 'Add a subscriber.', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get a list of all the forms for your account.', + }, + { + name: 'Get Subscriptions', + value: 'getSubscriptions', + description: 'List subscriptions to a form including subscriber data.', + }, + ], + default: 'addSubscriber', + description: 'The operations to perform.', + }, +] as INodeProperties[]; + +export const formFields = [ + { + displayName: 'Email Address', + name: 'email', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'form', + ], + operation: [ + 'addSubscriber', + ], + }, + }, + default: '', + description: `The subscriber's email address.`, + }, + { + displayName: 'Form ID', + name: 'id', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'form', + ], + operation: [ + 'addSubscriber', + 'getSubscriptions', + ], + }, + }, + default: '', + description: 'Form ID.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'form', + ], + operation: [ + 'addSubscriber', + ], + }, + }, + options: [ + { + displayName: 'First Name', + name: 'firstName', + type: 'string', + default: '', + description: `The subscriber's first name.`, + }, + { + displayName: 'Custom Fields', + name: 'fields', + placeholder: 'Add Custom Field', + description: 'Object of key/value pairs for custom fields (the custom field must exist before you can use it here).', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + name: 'field', + displayName: 'Custom Field', + values: [ + { + displayName: 'Field Key', + name: 'key', + type: 'string', + default: '', + placeholder: 'last_name', + description: `The field's key.`, + }, + { + displayName: 'Field Value', + name: 'value', + type: 'string', + default: '', + placeholder: 'Doe', + description: 'Value of the field.', + }, + ], + }, + ], + }, + ], + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'form', + ], + operation: [ + 'getSubscriptions', + ], + }, + }, + options: [ + { + displayName: 'Subscriber State', + name: 'subscriberState', + type: 'options', + options: [ + { + name: 'Active', + value: 'active', + }, + { + name: 'Cancelled', + value: 'cancelled', + }, + ], + default: 'active', + }, + ], + description: 'Receive only active subscribers or cancelled subscribers.', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/ConvertKit/GenericFunctions.ts b/packages/nodes-base/nodes/ConvertKit/GenericFunctions.ts new file mode 100644 index 0000000000..525aed043a --- /dev/null +++ b/packages/nodes-base/nodes/ConvertKit/GenericFunctions.ts @@ -0,0 +1,44 @@ +import { + OptionsWithUri +} from 'request'; + +import { + IExecuteFunctions, + ILoadOptionsFunctions, + IExecuteSingleFunctions, +} from 'n8n-core'; + +import { + IDataObject, + IHookFunctions +} from 'n8n-workflow'; + +export async function convertKitApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IHookFunctions, + method: string, endpoint: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('convertKitApi'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + let options: OptionsWithUri = { + headers: { + 'Content-Type': 'application/json', + }, + method, + qs, + body, + uri: uri ||`https://api.convertkit.com/v3${endpoint}`, + json: true, + }; + options = Object.assign({}, options, option); + if (Object.keys(options.body).length === 0) { + delete options.body; + } + try { + qs.api_secret = credentials.apiSecret; + + return await this.helpers.request!(options); + } catch (error) { + throw new Error(`ConvertKit error response: ${error.message}`); + } +} diff --git a/packages/nodes-base/nodes/ConvertKit/SequenceDescription.ts b/packages/nodes-base/nodes/ConvertKit/SequenceDescription.ts new file mode 100644 index 0000000000..1b82b741fa --- /dev/null +++ b/packages/nodes-base/nodes/ConvertKit/SequenceDescription.ts @@ -0,0 +1,174 @@ +import { + INodeProperties +} from 'n8n-workflow'; + +export const sequenceOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'sequence', + ], + }, + }, + options: [ + { + name: 'Add Subscriber', + value: 'addSubscriber', + description: 'Add a subscriber.', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Returns a list of sequences for the account.', + }, + { + name: 'Get Subscriptions', + value: 'getSubscriptions', + description: 'List subscriptions to a sequence including subscriber data.', + }, + ], + default: 'addSubscriber', + description: 'The operations to perform.', + }, +] as INodeProperties[]; + +export const sequenceFields = [ + { + displayName: 'Email Address', + name: 'email', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'sequence', + ], + operation: [ + 'addSubscriber', + ], + }, + }, + default: '', + description: `The subscriber's email address.`, + }, + { + displayName: 'Sequence ID', + name: 'id', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'sequence', + ], + operation: [ + 'addSubscriber', + 'getSubscriptions', + ], + }, + }, + default: '', + description: 'Sequence ID.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'sequence', + ], + operation: [ + 'addSubscriber', + ], + }, + }, + options: [ + { + displayName: 'First Name', + name: 'firstName', + type: 'string', + default: '', + description: `The subscriber's first name.`, + }, + { + displayName: 'Custom Fields', + name: 'fields', + placeholder: 'Add Custom Field', + description: 'Object of key/value pairs for custom fields (the custom field must exist before you can use it here).', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + name: 'field', + displayName: 'Custom Field', + values: [ + { + displayName: 'Field Key', + name: 'key', + type: 'string', + default: '', + placeholder: 'last_name', + description: `The field's key.`, + }, + { + displayName: 'Field Value', + name: 'value', + type: 'string', + default: '', + placeholder: 'Doe', + description: 'Value of the field.', + }, + ], + }, + ], + }, + ], + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'sequence', + ], + operation: [ + 'getSubscriptions', + ], + }, + }, + options: [ + { + displayName: 'Subscriber State', + name: 'subscriberState', + type: 'options', + options: [ + { + name: 'Active', + value: 'active', + }, + { + name: 'Cancelled', + value: 'cancelled', + }, + ], + default: 'active', + }, + ], + description: 'Receive only active subscribers or cancelled subscribers.', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/ConvertKit/TagDescription.ts b/packages/nodes-base/nodes/ConvertKit/TagDescription.ts new file mode 100644 index 0000000000..a1a718ba3c --- /dev/null +++ b/packages/nodes-base/nodes/ConvertKit/TagDescription.ts @@ -0,0 +1,204 @@ +import { + INodeProperties +} from 'n8n-workflow'; + +export const tagOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'tag', + ], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Create a tag.', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Returns a list of tags for the account.', + }, + { + name: 'Get Subscriptions', + value: 'getSubscriptions', + description: 'List subscriptions to a tag including subscriber data.', + }, + { + name: 'Remove Subscriber', + value: 'removeSubscriber', + description: 'Remove a tag from a subscriber.', + }, + { + name: 'Add Subscriber', + value: 'addSubscriber', + description: 'Add a tag to a subscriber.', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const tagFields = [ + { + displayName: 'Name', + name: 'name', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'tag', + ], + operation: [ + 'create', + ], + }, + }, + default: '', + description: 'Tag name.', + }, + { + displayName: 'Email Address', + name: 'email', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'tag', + ], + operation: [ + 'addSubscriber', + 'removeSubscriber', + ], + }, + }, + default: '', + description: 'Subscriber email address.', + }, + { + displayName: 'Tag ID', + name: 'id', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'tag', + ], + operation: [ + 'addSubscriber', + 'removeSubscriber', + 'getSubscriptions', + ], + }, + }, + default: '', + description: 'Tag ID.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'tag', + ], + operation: [ + 'addSubscriber', + ], + }, + }, + options: [ + { + displayName: 'First Name', + name: 'firstName', + type: 'string', + default: '', + description: 'Subscriber first name.', + }, + { + displayName: 'Custom Fields', + name: 'fields', + placeholder: 'Add Custom Field', + description: 'Object of key/value pairs for custom fields (the custom field must exist before you can use it here).', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + name: 'field', + displayName: 'Custom Field', + values: [ + { + displayName: 'Field Key', + name: 'key', + type: 'string', + default: '', + placeholder: 'last_name', + description: `The field's key.`, + }, + { + displayName: 'Field Value', + name: 'value', + type: 'string', + default: '', + placeholder: 'Doe', + description: 'Value of the field.', + }, + ], + }, + ], + }, + ], + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'tag', + ], + operation: [ + 'getSubscriptions', + ], + }, + }, + options: [ + { + displayName: 'Subscriber State', + name: 'subscriberState', + type: 'options', + options: [ + { + name: 'Active', + value: 'active', + }, + { + name: 'Cancelled', + value: 'cancelled', + }, + ], + default: 'active', + }, + ], + description: 'Receive only active subscribers or cancelled subscribers.', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/ConvertKit/convertKit.png b/packages/nodes-base/nodes/ConvertKit/convertKit.png new file mode 100644 index 0000000000000000000000000000000000000000..2dc9cf3ee52a2d64f636c43724bc9d1b81cb6372 GIT binary patch literal 4958 zcmY*-cRU+j_kZlIM(t61)d;GHStIt2ik-v^5g}%kYEiRhXwA|@soF}-8c|ynt?}5@ zXlWI#O~3kip5OEPe(&pb?mg$+_j$k1xqse7OLGGTT5ehZ0Ki~msAqk3s{b`=%Bvl8 zJ{x#-kYKG1bO6;Kc)wl+G#EpBEC4{q`qxMRc?FzT0DB|^8U!^n1-beA$->?J5gxK( zewZut6;l}K%JuUIf(wNCq0m@Rn1;|l2+)=PH!UY5@DC)&S3?MDW+|ZSALt>VBzsF1 zD5Oa%ARwR~={G-THr4)9N`}vq#-2qSLna%-+cxlJ^#NF8vCEI zt_GC*dm|?=3zYjW_f@I--z>-?5b1HH{Fh%-Ui}~B|H=JFM_ul(`2T9=-;@56y&6@M zR$cDD&!$Oh+9Pfc05BIB>D_^NQ;a(LqMY8d6}5~lG-jn!rE@X~6qkRYDt+-rpF#bxmOH#x2C0QC|z@R7vU4El3=e`j<`40P&2mF@nyEX0`-*sndttBcWXnG zC4i?mnZqP^X{`1#U{`%zudFxcyh3JOvX0$Vdovlx+W^p7gXScxa@N02PCs4H89FNc zR72y}0i(NkG5_fZP5X}f;!Wi^7EHpEF}&#vV3U1tYF6Y{GKxLan2whc;rVbxV4pQ?gm6nv3=(kGXEMxTtWm{1ySn?)HSB z=2(m5Vyy{#9ESaci#@vh0v|oSBESGQKUJ8t7#ZHqHMH_O$k%zeRp*$CnZrQ8GWcQ2 z+UBZPX(5YQBQB>QML2es>DuLe@vKIDUK%!X3#7@?H$Uhfmi;)Fx}K+GWM5$luYD>A z&W$>F(?=ARcqfRLvYy@H+^mx$mp6U!gswPmI&xP=GK;eqqH|w7zu}WN3E;QPkC(sS z@|<>b%tGdU_X;N}Y+aO}ui|``Ya7n)_J>RbF5KB^?sJkX#Xo^eiB3WUxhOF{7hM4b z(&R3Br4tt+U?;QMR+23w870$l23U&tp~zJ=cSOor%IdyBMy?sBFRiI!HtVUVGR>_a1QcbI`EO8jb7 zWPNjj%KOHJ{+1dB+oEW@qxn)zZIeZMI!H-plC)9{4w$AjIlT9Qxtctm9^vB$KCsEWl$Y| z=91Ys>Zua&ojj?r8RwJ@Q4$}R)H|l|bAB)QN}H##_T1KI`C_b$mjPWWCyb0nbF|5J zyj8Nxb3u&T-uP_#b_z9HzhJi*pIv(OiE$jOZnr)3NH!;iN-}_N3_FswRBcdYo>@5u z6peHQ_`S-2`<+F_J1kyLL64K=QryVyFQ-9D21CFP>|il1Xi`w0?&)z zA}>4$43{$8V4LSFM?-i*rFZGt$pNs9_av`S954_=Iw^a&kyc_+6PE#ehU;eUL(<06 z%irp~x8A#3|M^g;X!BL|kDG!SJlc5MI8j5~7gM|MPjfwV6!5gdb=%EPA5T}$Z=jnG zxUT0y8sQGlG$&^l0^Wbip{N?)2BOn!jg^X=8P){WBAABlh`?5Xw|>jIkM5IsZWzVH zIGvq6d|4_abLFOz5G|I|vMLg9OhH%@ni5d)U>W z_ukUC!%enRB&anpP8_gcXG%bh$bHmPNb2UdYTGhJ-LAF?CSabiyh5h*@v#p79Ly{O zVfz&(W(+@^i{rj|IVFb!O2WoGyoxW;#5rY`s)ZBJKSSjerA zV2{rMyIb)y#_AsWLhmx;-q7=qvh(q!av^z_$5(x|hnv7_`yL1#WJ(Ff+s4fay|H7d zp0>07+UJ`Y%JutP^pCB9L7C9@#|)m0m?vEs@p*%o!AX1VZ5tOuD)O7kl=BmaDY_0e z&PIH>JP(1%F()qgtll^7D6h3`u6%)opZiUq^8uQh`%ej+tsR!_AV zBb|OM2N;d&ZIxff<@pt^s#Nn4)gF>xz7}hieIpJvNTT}E_k;*Oq2E9njp`|!e}gXG z&5B8KVG(m{ppR2Y)(<9eGbTJ!><7~AsV5qQHbmW&eoA|K-xerMkfL}_dNMf@CP%$P zR_HtdHy&Hqy??s0W(nI}4m14vT6H+lu-l$J;Gzi39ZZsZ7=oTk?8!8!BVn2HenR7U z?V~up`)atKl!2s+xZp`O<;8LsN-?QJ?Mx zlRe;a2u|H~L~NQ@5LYrJ^75GvothP>)2Ysl`enZu%=`gHPdx5@V8}Rfy3pGAa^Uku zy*Z6A+ll&d!eS~JeX4<~;j!4(?$cWHo%lpqIk!qOlPUE^KO7`YMtZjq|;!m%)tG#zaGsl^R znZP-Wia5ipuvn>Ul#GPoyM-wMhm5*+(mDTXcmw&#oYv@}y1bEI zcQXf(-&yx(R4Ynx*EEH$*8Ap=BL~w!(HP~17wUD>&{^saDozD9<~i9=2jA(u zsu+H)of2gl?+9K-TF*&zVQ^{^F@B3v7+!5sX=|uK>dsmIYcSO3BRPuI$uxH%@I5(f zu+ej+k!?=o;C0Q?1)=4+4t+2zV=!nt(RlBg-?WAUTe1@|sBhNiSn)VX!Hcp*gCqCQ z&u_-F`O|^?Q|;3)YFp3RFD`gv--_UpELWM;4ZE0Do0QSvW?R|9i1F|=Rj%9dh_GWu zhMdrAUD~aV!Ub?C+%zqo99r|U(Ay8d`bpq$lSWVL$f9a3OEM8=^RT})nz=p85=Aj> zqej1GJe$^vWo%*W5^8oR2{ncGa&~g^+u_PD*H7-v4zVUw@4k2|T2untCho z@^`w}`TGgp>fQ&vsV8bO3k#7dwC1*Fhn&gOETj(_!CG)2B8Zf2saW1Ua(~u~qVg;)94NqkyZTY@OEvbW z*i;Qn5YJ_u>ljn&t1SDbksT@HzNf_e4E)*+(_1bs&}TDoql|7c&op?1WF36?&N4}U zF5kZ?WEcWT{#jiFidVFol(0L^ZEJO?iw_aM#H{gGf2Io)KT~0)63RTasPv+Ef9zBs zv*~7K;~0=5F51-7uJ)o=vArEhz%R%mC2Z*v4=m8>H^OC`-lV6F?lJ;R2g@m z5hlyq9hwScTDMhgpWe;nqUm23I~brb+L@68jSEwQzhepOWRkyaZAsUyK634h5B)wj zd9l7Q{G}n>BPBbAdO87k?hsdPYtS7Us-+|whJ16B2Lfd3)2r&Bz6jP+=XcjT{Al%6 zTYQ_CB2O@$sfXST&asacQM%44y*s*6|HD83ne@4tw`FkgkXhazlY5lX9b$H&H-2m# z8yyoldPplq5AXd#ho-nuY40R!u@C(+9Aacy3wT;4lVoP(?^|#&u;;^%$#4-DKMrx6{#-j z0NS?gmFpzjz+3XK_VFbD{Eeq{pa#M4IHY3C(im6H60_#Qd6H zE3x2k;&$J1S)WIIMZ>1*j{j0Z5}qRZ0~|Pj>ChB1j2nHzSAr{pO)W0_afQi|qU*HTzba5=S#KAF#i~m3<0wc6%>Da{Eua zygO+lqx`ir@P<4*93DF0T)|a%cXkAWQ|p|>P>PCn5(7>F_5m?F$^10u$mQF~53--M z*8E(5ULjjcM^=px+@bZ-!@qw%9(IS<^0ml4++RvRvv7|jzZrSMd`3{HTOmcyM0zc5 zhRGt9Qk~&N1jyXSFC|+al034PNjj(eK^Ga=-E&kLH}~5qy+ICC$}tY-q%D*p>YAUdEmcF{AGp zq%vTbijI2E)ti)_k?6R?cd3vdsGaH0Dv0VU7d#W?bz@eNZeInB2a_9$=7CsGMsJ~i z1)UL@z9*~JLk$+T=TSfIkrR#w5B5a*`RKlhB!Q4zbCqGZf({{ zYVrCLg&y-?_-itx)m=;dPYQ%o8L&(zc6n zVbW5da)&4)Gz)Vph||q4$R%1orUM7_wQi#*s#9G-`Df_Zfi18dqs^mWl7`IL{zcXz z)9QVSLXBn?X>_e(i|}YRtaQj%zdx#8x=i$?r0!Qm4|<7LpPF$f`lfhMK}>WwSHZ## z+U1YP_H>)i!2S?!qH5&zsf!WBHR56%bLK?lZl@~K628HYWx|-PAh<>+X4ZAgXv{ng zCx?uj9}!jw>|-C(m(AJO4VLsf7}_2e%Fyi+M*-Yya{+6TR`tjSzl)GszrUqqi*D!r zfHq`^BQAx3;an+aZX*mKhM@1LTcpGkbCC$CM-E{46Dviz-u@dTFCDMb-RGY-3j%gs zR`zrX?<$LO%`deEtTEmNhDvUZU3=wrtnvBNbr=gp=$oNF=Yuj~p!DEw7onXR5h;eA z8&Q^p^yOba?@owaj?E^OQZ$Sgb}|!4R11fSs7H=-mcU><^x3D%fzSJNE~>uwj;;Rq zqB*JM$#(C(k^iD%P41^#%eXL^0PeVE#|#jg;X6icSIX+OkAp%gG|q70X;YPQjMj!3 ziOY7{S|p&A&vV96BAjAl(8L_oDWzO9r#f|mDzEI7jN^66Y-{ Date: Tue, 7 Jul 2020 10:35:20 -0400 Subject: [PATCH 0005/1150] :wrench: Prompt User to Save Before Page Unload --- packages/editor-ui/src/views/NodeView.vue | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index de89534e09..573849eb39 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -1328,6 +1328,10 @@ export default mixins( document.addEventListener('keydown', this.keyDown); document.addEventListener('keyup', this.keyUp); + window.onbeforeunload = this.confirmSave; + }, + async confirmSave(e: Event) { + window.confirm(); }, __addConnection (connection: [IConnection, IConnection], addVisualConnection = false) { if (addVisualConnection === true) { From fa0e8c84f59c2a1b0112cc0c76360e032d39a1e1 Mon Sep 17 00:00:00 2001 From: Sven Schmidt <0xsven@gmail.com> Date: Thu, 9 Jul 2020 11:36:28 +0200 Subject: [PATCH 0006/1150] Contentful integration --- .../ContentfulDeliveryApi.credentials.ts | 22 +++ .../nodes/Contentful/ GenericFunctions.ts | 34 ++++ .../nodes/Contentful/AssetDescription.ts | 49 ++++++ .../Contentful/ContentTypeDescription.ts | 49 ++++++ .../nodes/Contentful/Contentful.node.ts | 142 ++++++++++++++++ .../nodes/Contentful/EntryDescription.ts | 83 ++++++++++ .../nodes/Contentful/LocaleDescription.ts | 29 ++++ .../Contentful/SearchParameterDescription.ts | 37 +++++ .../nodes/Contentful/SpaceDescription.ts | 29 ++++ .../nodes/Contentful/contentful.png | Bin 0 -> 4281 bytes .../nodes/Contentful/resolveResponse.ts | 156 ++++++++++++++++++ packages/nodes-base/package.json | 2 + 12 files changed, 632 insertions(+) create mode 100644 packages/nodes-base/credentials/ContentfulDeliveryApi.credentials.ts create mode 100644 packages/nodes-base/nodes/Contentful/ GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Contentful/AssetDescription.ts create mode 100644 packages/nodes-base/nodes/Contentful/ContentTypeDescription.ts create mode 100644 packages/nodes-base/nodes/Contentful/Contentful.node.ts create mode 100644 packages/nodes-base/nodes/Contentful/EntryDescription.ts create mode 100644 packages/nodes-base/nodes/Contentful/LocaleDescription.ts create mode 100644 packages/nodes-base/nodes/Contentful/SearchParameterDescription.ts create mode 100644 packages/nodes-base/nodes/Contentful/SpaceDescription.ts create mode 100644 packages/nodes-base/nodes/Contentful/contentful.png create mode 100644 packages/nodes-base/nodes/Contentful/resolveResponse.ts diff --git a/packages/nodes-base/credentials/ContentfulDeliveryApi.credentials.ts b/packages/nodes-base/credentials/ContentfulDeliveryApi.credentials.ts new file mode 100644 index 0000000000..ce620fcdbb --- /dev/null +++ b/packages/nodes-base/credentials/ContentfulDeliveryApi.credentials.ts @@ -0,0 +1,22 @@ +import { ICredentialType, NodePropertyTypes } from 'n8n-workflow'; + +export class ContentfulDeliveryApi implements ICredentialType { + name = 'contentfulDeliveryApi'; + displayName = 'Delivery API'; + properties = [ + { + displayName: 'Space Id', + name: 'space_id', + type: 'string' as NodePropertyTypes, + default: '', + description: 'The id for the Cotentful space.' + }, + { + displayName: 'Access Token', + name: 'access_token', + type: 'string' as NodePropertyTypes, + default: '', + description: 'Access token that has access to the space' + } + ]; +} diff --git a/packages/nodes-base/nodes/Contentful/ GenericFunctions.ts b/packages/nodes-base/nodes/Contentful/ GenericFunctions.ts new file mode 100644 index 0000000000..c59bab1449 --- /dev/null +++ b/packages/nodes-base/nodes/Contentful/ GenericFunctions.ts @@ -0,0 +1,34 @@ +import { IExecuteFunctions } from 'n8n-core'; +import { OptionsWithUrl } from 'request'; + +/** + * @param {IExecuteFunctions} that Reference to the system's execute functions + * @param {string} endpoint? Endpoint of api call + * @param {string} environmentId? Id of contentful environment (eg. master, staging, etc.) + * @param {Record} qs? Query string, can be used for search parameters + */ +export const contentfulApiRequest = async ( + that: IExecuteFunctions, + endpoint?: string, + environmentId?: string, + qs?: Record +) => { + const credentials = that.getCredentials('contentfulDeliveryApi'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + let url = `https://cdn.contentful.com/spaces/${credentials.space_id}`; + if (environmentId) url = `${url}/environments/${environmentId}`; + if (endpoint) url = `${url}${endpoint}`; + qs = qs || {}; + qs.access_token = credentials.access_token as string; + + const res = await that.helpers.request!({ + url, + method: 'GET', + qs + } as OptionsWithUrl); + + return JSON.parse(res); +}; diff --git a/packages/nodes-base/nodes/Contentful/AssetDescription.ts b/packages/nodes-base/nodes/Contentful/AssetDescription.ts new file mode 100644 index 0000000000..18ccba8257 --- /dev/null +++ b/packages/nodes-base/nodes/Contentful/AssetDescription.ts @@ -0,0 +1,49 @@ +import { INodeProperties, INodePropertyOptions } from "n8n-workflow"; + +export const resource = { + name: "Asset", + value: "asset", +} as INodePropertyOptions; + +export const operations = [ + { + displayName: "Operation", + name: "operation", + type: "options", + displayOptions: { + show: { + resource: [resource.value], + }, + }, + options: [ + { + name: "Get Assets", + value: "get_assets", + }, + { + name: "Get Single Asset", + value: "get_asset", + }, + ], + default: "get_assets", + description: "The operation to perform.", + }, +] as INodeProperties[]; + +export const fields = [ + { + displayName: "Asset Id", + name: "asset_id", + type: "string", + default: "", + placeholder: "", + description: "", + required: true, + displayOptions: { + show: { + resource: [resource.value], + operation: ["get_asset"], + }, + }, + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Contentful/ContentTypeDescription.ts b/packages/nodes-base/nodes/Contentful/ContentTypeDescription.ts new file mode 100644 index 0000000000..0a2ebfd1e7 --- /dev/null +++ b/packages/nodes-base/nodes/Contentful/ContentTypeDescription.ts @@ -0,0 +1,49 @@ +import { INodeProperties, INodePropertyOptions } from "n8n-workflow"; + +export const resource = { + name: "Content Types", + value: "content_type", +} as INodePropertyOptions; + +export const operations = [ + { + displayName: "Operation", + name: "operation", + type: "options", + displayOptions: { + show: { + resource: [resource.value], + }, + }, + options: [ + { + name: "Get Content types", + value: "get_content_types", + }, + { + name: "Get Single Content Type", + value: "get_content_type", + }, + ], + default: "get_content_types", + description: "The operation to perform.", + }, +] as INodeProperties[]; + +export const fields = [ + { + displayName: "Content Type Id", + name: "content_type_id", + type: "string", + default: "", + placeholder: "", + description: "", + required: true, + displayOptions: { + show: { + resource: [resource.value], + operation: ["get_content_type"], + }, + }, + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Contentful/Contentful.node.ts b/packages/nodes-base/nodes/Contentful/Contentful.node.ts new file mode 100644 index 0000000000..866cddb268 --- /dev/null +++ b/packages/nodes-base/nodes/Contentful/Contentful.node.ts @@ -0,0 +1,142 @@ +import { IExecuteFunctions } from 'n8n-core'; +import { IDataObject, INodeExecutionData, INodeType, INodeTypeDescription, NodePropertyTypes } from 'n8n-workflow'; + +import { contentfulApiRequest } from './ GenericFunctions'; +import resolveResponse from './resolveResponse'; + +import * as SpaceDescription from './SpaceDescription'; +import * as ContentTypeDescription from './ContentTypeDescription'; +import * as EntryDescription from './EntryDescription'; +import * as AssetDescription from './AssetDescription'; +import * as LocaleDescription from './LocaleDescription'; +import * as SearchParameterDescription from './SearchParameterDescription'; + +export class Contentful implements INodeType { + description: INodeTypeDescription = { + displayName: 'Contentful', + name: 'contentful', + icon: 'file:contentful.png', + group: ['input'], + version: 1, + description: "Access data through Contentful's Content Delivery API", + defaults: { + name: 'Contentful', + color: '#2E75D4' + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'contentfulDeliveryApi', + required: true + } + ], + properties: [ + // Common fields: + { + displayName: 'Environment Id', + name: 'environment_id', + type: 'string' as NodePropertyTypes, + default: 'master', + description: + 'The id for the Contentful environment (e.g. master, staging, etc.). Depending on your plan, you might not have environments. In that case use "master".' + }, + + // Resources: + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + SpaceDescription.resource, + ContentTypeDescription.resource, + EntryDescription.resource, + AssetDescription.resource, + LocaleDescription.resource + ], + default: '', + description: 'The resource to operate on.' + }, + + // Operations: + ...SpaceDescription.operations, + ...ContentTypeDescription.operations, + ...EntryDescription.operations, + ...AssetDescription.operations, + ...LocaleDescription.operations, + + // Resource specific fields: + ...SpaceDescription.fields, + ...ContentTypeDescription.fields, + ...EntryDescription.fields, + ...AssetDescription.fields, + ...LocaleDescription.fields, + + // Options: + ...SearchParameterDescription.fields + ] + }; + + async execute(this: IExecuteFunctions): Promise { + const environmentId = this.getNodeParameter('environment_id', 0) as string; + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + const items = this.getInputData(); + const returnData: IDataObject[] = []; + const qs: Record = {}; + + for (let i = 0; i < items.length; i++) { + if (resource === 'space') { + if (operation === 'get_space') { + const res = await contentfulApiRequest(this); + returnData.push(res); + } + } else if (resource === 'content_type') { + if (operation === 'get_content_types') { + const res = await contentfulApiRequest(this, '/content_types', environmentId); + const resolvedData = resolveResponse(res, {}); + returnData.push(...resolvedData); + } else if (operation === 'get_content_type') { + const id = this.getNodeParameter('content_type_id', 0) as string; + const res = await contentfulApiRequest(this, `/content_types/${id}`, environmentId); + returnData.push(...res.items); + } + } else if (resource === 'entry') { + if (operation === 'get_entries') { + const shouldResolve = this.getNodeParameter('resolve', 0) as boolean; + if (shouldResolve) qs.include = this.getNodeParameter('include', 0) as number; + const searchParameters = this.getNodeParameter('search_parameters', 0) as IDataObject; + if (searchParameters.parameters && Array.isArray(searchParameters.parameters)) { + searchParameters.parameters.forEach(parameter => { + const { name, value } = parameter as { name: string; value: string }; + qs[name] = value; + }); + } + const res = await contentfulApiRequest(this, '/entries', environmentId, qs); + const resolvedData = shouldResolve ? resolveResponse(res, {}) : res.items; + returnData.push(...resolvedData); + } else if (operation === 'get_entry') { + const id = this.getNodeParameter('entry_id', 0) as string; + const res = await contentfulApiRequest(this, `/entries/${id}`, environmentId); + returnData.push(res); + } + } else if (resource === 'asset') { + if (operation === 'get_assets') { + const res = await contentfulApiRequest(this, '/assets', environmentId); + returnData.push(...res.items); + } else if (operation === 'get_asset') { + const id = this.getNodeParameter('asset_id', 0) as string; + const res = await contentfulApiRequest(this, `/assets/${id}`, environmentId); + returnData.push(res); + } + } else if (resource === 'locale') { + if (operation === 'get_locales') { + const res = await contentfulApiRequest(this, '/locales', environmentId); + returnData.push(res); + } + } + } + return [this.helpers.returnJsonArray(returnData)]; + } +} diff --git a/packages/nodes-base/nodes/Contentful/EntryDescription.ts b/packages/nodes-base/nodes/Contentful/EntryDescription.ts new file mode 100644 index 0000000000..781bcc6fe1 --- /dev/null +++ b/packages/nodes-base/nodes/Contentful/EntryDescription.ts @@ -0,0 +1,83 @@ +import { INodeProperties, INodePropertyOptions } from 'n8n-workflow'; + +export const resource = { + name: 'Entry', + value: 'entry' +} as INodePropertyOptions; + +export const operations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [resource.value] + } + }, + options: [ + { + name: 'Get Entries', + value: 'get_entries' + }, + { + name: 'Get Single Entry', + value: 'get_entry' + } + ], + default: 'get_entries', + description: 'The operation to perform.' + } +] as INodeProperties[]; + +export const fields = [ + { + displayName: 'Resolve', + name: 'resolve', + type: 'boolean', + default: false, + description: 'Linked entries can be automatically resolved in the results if you click activate this feature.', + displayOptions: { + show: { + resource: [resource.value], + operation: ['get_entries'] + } + } + }, + { + displayName: 'Include', + name: 'include', + type: 'number', + default: 1, + placeholder: '', + description: + "When you have related content (e.g. entries with links to image assets) it's possible to include them in the results. Using the include parameter, you can specify the number of levels of entries to include in the results. A lower number might improve performance.", + typeOptions: { + minValue: 0, + maxValue: 10 + }, + displayOptions: { + show: { + resource: [resource.value], + operation: ['get_entries'], + resolve: [true] + } + } + }, + + { + displayName: 'Entry Id', + name: 'entry_id', + type: 'string', + default: '', + placeholder: '', + description: '', + required: true, + displayOptions: { + show: { + resource: [resource.value], + operation: ['get_entry'] + } + } + } +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Contentful/LocaleDescription.ts b/packages/nodes-base/nodes/Contentful/LocaleDescription.ts new file mode 100644 index 0000000000..87e7f6b932 --- /dev/null +++ b/packages/nodes-base/nodes/Contentful/LocaleDescription.ts @@ -0,0 +1,29 @@ +import { INodeProperties, INodePropertyOptions } from "n8n-workflow"; + +export const resource = { + name: "Locale", + value: "locale", +} as INodePropertyOptions; + +export const operations = [ + { + displayName: "Operation", + name: "operation", + type: "options", + displayOptions: { + show: { + resource: [resource.value], + }, + }, + options: [ + { + name: "Get Locales", + value: "get_locales", + }, + ], + default: "get_locales", + description: "The operation to perform.", + }, +] as INodeProperties[]; + +export const fields = [] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Contentful/SearchParameterDescription.ts b/packages/nodes-base/nodes/Contentful/SearchParameterDescription.ts new file mode 100644 index 0000000000..7557cbb755 --- /dev/null +++ b/packages/nodes-base/nodes/Contentful/SearchParameterDescription.ts @@ -0,0 +1,37 @@ +import { INodeProperties } from 'n8n-workflow'; + +export const fields = [ + { + displayName: 'Search Parameters', + name: 'search_parameters', + description: 'You can use a variety of query parameters to search and filter items.', + placeholder: 'Add parameter', + type: 'fixedCollection', + typeOptions: { + multipleValues: true + }, + default: {}, + options: [ + { + displayName: 'Parameters', + name: 'parameters', + values: [ + { + displayName: 'Parameter Name', + name: 'name', + type: 'string', + default: '', + description: 'Name of the search parameter to set.' + }, + { + displayName: 'Parameter Value', + name: 'value', + type: 'string', + default: '', + description: 'Value of the search parameter to set.' + } + ] + } + ] + } +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Contentful/SpaceDescription.ts b/packages/nodes-base/nodes/Contentful/SpaceDescription.ts new file mode 100644 index 0000000000..2baabee099 --- /dev/null +++ b/packages/nodes-base/nodes/Contentful/SpaceDescription.ts @@ -0,0 +1,29 @@ +import { INodeProperties } from "n8n-workflow"; + +export const resource = { + name: "Space", + value: "space", +}; + +export const operations = [ + { + displayName: "Operation", + name: "operation", + type: "options", + displayOptions: { + show: { + resource: [resource.value], + }, + }, + options: [ + { + name: "Get Space", + value: "get_space", + }, + ], + default: "get_space", + description: "The operation to perform.", + }, +] as INodeProperties[]; + +export const fields = [] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Contentful/contentful.png b/packages/nodes-base/nodes/Contentful/contentful.png new file mode 100644 index 0000000000000000000000000000000000000000..4448da65ae1c71a2243e4fd6545568a6b1d54358 GIT binary patch literal 4281 zcmY*dXIPU z>->{`zETsojm7#aOG^g_2g8Fe!~Ft0q-B+ql%!?kq~+wK&KXjeP+zQ5h?Flz@Nbd- z(b0CpxCD6mV?F(RVHdhi&VGSdH35MOqkr4q=frxt|F4rT=AW|83zWXtk(PzaNdK#S zo~n9*Dw_m&x}7^;=p$rR|6=|>>>nRh=?nA!bC|y?{R=%W6+x{k{qMCQs15r?Z2$l= zjMUaJzfJz$4xMZ<#d3>)aR1rGehVVg3lms`X=?E5PtBC=XUv{?PFWUPp2%1lRrz?8 zTr;%w5j@|>;Ec1ZEo))rnyay>WqDyHVa`nE>WaUjxCI_9R5`r!bMP>yx#XpF_r%B&2X?5Li9}pbez6_1BbsGF zD2j%iOVfyICZX8#74zELHC`#2TII?e@3ia-|e? zw_=*)=NX3GVHoiv=r3n~o)D5Y=7O|Lu?>I^*aBXG=4lI`>CtJ3f>&{%!$}GgRP+i* zF-7*byL3lS7I}Z&t|p~EqduchB(USDBD|>zb@~eYVqcI%upI%4d~Rmew1iJhZ1-B@ zTE?LGz!b&q#c?y=_>5N^5o8;c5sSoqt(8P{Cw)qTMf@Pad;Iy$98D#ynNf{0Oz-2q z$Q`bAX+EQ#^bdq$91TpcZUYx%e%+Ll1g+?h7QvT?zk2`or|V~tFVYlT>-W?#r-Sb? zE#cbY%#3wr342y3VJ?m<+9SrnHK$E}Fbtu07@TN;xJC5hMQ+=U7 zjx5pU&`2BoEM0=jyndAU zr-YSk=An`>6w>bSYw^qcZfWzc{gA1 zAo-(01I_EzI&D8ZF-vOgowt;39`Ztcrj(Q|Ykz3uJ7XFXu>5(5;f&p$?%+}{+DS9~ zP9bV2)#L=<=iu;MG(X#uAI#>WIUMK~AZg~D(u3}yIe+!m$(*5-ha&tNf*P7%8Y&pQ z#y}PF-dfRZ!2y3z>-Z&o-A3-)QEIn#nY19(W}ZV8&%mxjeDm2>L3bsarY))5nyYB; zq{%`wYE|>yyd-2J?X`h} ztRi8llQWG*)5LBKcN6j4$WbR4&KjwWSXCBzFU4~MN@c)OCmPKP`|{auB*>q%ACda; zSuYVz5Lj7%5NeL3)A_+L9a9+;oNo(9MJhQmxl@^YLgcK%rQeQ(Rl1sIOt&Fr8r`Yf z#6%B<=L;LYy?#$!oms{`unK3>T560xIToVwyEd1bVbHW$$H|txy5b}b>Mb(2M6A6Z zG_dXyRsWBGw;7UFK!6b>5qmpL8$6lNzR;558TkR5j6fD4cc&qPye$V)us}iL|@cvrE;S(nmhX68A!SVcccvf2_4d!aV%E2?!{*3d>d zgBq)%F6V>@!t-(+P^C(o0N?L*+tf;YPw*VR)j_;EA%cGP)Y;20G8wdlOr{A0da+3d z2kffEyQq|W5?95QgBXvPM90FZ4&DM)`h{4`OgkkknqIWL#iL@wyE+T2ERI>P7%V8a z2ZuUy27Si_z1*K^p)axhwRrT8q{ zI(b{L1Bk%AM<=-T3R&z77>^ilGc2ZBVnzps~ba75BYmht7}3vPP#N zKUNB^yop+H)7x!RLy^ara7*s+b5Hjd>36I)3r20xzTrkiz47pvXWy&ae#_VVpp@Yi z73%6TB2y6G&hHWKPqH3ta5|;bE<17*ewCfYztuz2-z4!81787a9KAa`<;l%2-|bYh z&k*lutAgw3UPt^R#x%p8#sVSSO z$twH3dYrBkzZ$;iB!ed>w8Wmeb%{_kG6?X;W4vQJZ`gS!Bml$rUrJOaWk(_!P8jS!A0lbj zZ+&`sf@m2x(0Xm<=NmF32GC%!F6iTstI&Sm$;6+xuY|ij(r9v?lCEi3c?pO6^o!S$ zE7*pqB`=e|6WR5kdSM8T?HA4Ry`GI}@?CNUOl-$*tP&e_5usJc4M>=nge8GTZoe-{ zZub$FJpcJy{z>GU%MS4`e~*$kUtRsoIUKcVa;;ce=2 zem*&p6dzucrlJN;w*yTkzX#tw#7jSCxfk9?h_+Fn^@q7vG8R--oT+adkqcOim`;Or z&=AV?Po8(a*D+LfTO4n78Qo|m82spZ1AQb%SIodtdD!O4b!c#K8oYEzGOXqDI0J48 z32}|qpf4ruphdSEO9?72YS7?9uHYRZzi11VNR=^si%5KmL@9`pJL zTm1K0h6KmgZZGNb?gVSzRX{KM;nJn0n9#fk2yp-O;IXslqIiP*_bLk|;mv>e}b8$@ppXEo}u|q(p~m9W)nJttR+m& z;HwC@u2qzYeott*;a=VNH<)3~LR zNP5XAU)kcU-KS$CGw4zKX=Nh|sEGe#eW3wQPiW<4 zdVB*h2NxOMf`i-rd_WGa)!!nU3GCI}3cH$b?^kbZUh;wXG=a`?k+)AD>iQ;Vj!=Z~ zoAY^=Ioo3-_b1d~gU324O}>Bb$F2%*&!~Q zpK{li@wB?>qn^jS{*uDH_#~>*)d_&UnNssOYc+TKSR${uS87ej2FD`8iQMTgz)bzT z&Bk@D(XEM1rJLwXq5?d)BvEEe*eK0C)O<#lv17R} zlBaFbTsrpXDDmprX96?(aDo!F)-`6b0SG-il}jqEDa?VT8dfZL1{Xbt<+(HOj&x8_!hYfy5D(jt?ehp8z zS&CV~Uh*B`rmg>|!V(uaBB6VB6?KiV?N7ruVxZs&1zY5F+oCC zmqOkxy#aiU?ts)nEC0FbpnxvNa6lx4SMeugy)V#1}D1~zY3!EHNI;V5@8 ztuHPK#Z1|El)r+H*yE?(q8Kv`Pv^6sOjAB^0$x8PSYS>|G(sH7d$=QH+jvzuxZ^{j zP8qs5h;mrpDV?Hu$>9(zw`>Y$a8=f zQ09pA&ODR4@lad_n^+o0WE$TtALt*m((-H8{EW PivW< + object && object.sys && object.sys.type === 'Link'; + +/** + * findNormalizableLinkInArray + * + * @param array + * @param predicate + * @return {*} + */ +const findNormalizableLinkInArray = (array, predicate) => { + for (let i = 0, len = array.length; i < len; i++) { + if (predicate(array[i])) { + return array[i]; + } + } + return UNRESOLVED_LINK; +}; + +/** + * getLink Function + * + * @param response + * @param link + * @return {undefined} + */ +const getLink = (allEntries, link) => { + const { linkType: type, id } = link.sys; + + const predicate = ({ sys }) => sys.type === type && sys.id === id; + + return findNormalizableLinkInArray(allEntries, predicate); +}; + +/** + * cleanUpLinks Function + * - Removes unresolvable links from Arrays and Objects + * + * @param {Object[]|Object} input + */ +const cleanUpLinks = input => { + if (Array.isArray(input)) { + return input.filter(val => val !== UNRESOLVED_LINK); + } + for (const key in input) { + if (input[key] === UNRESOLVED_LINK) { + delete input[key]; + } + } + return input; +}; + +/** + * walkMutate Function + * @param input + * @param predicate + * @param mutator + * @return {*} + */ +const walkMutate = (input, predicate, mutator, removeUnresolved) => { + if (predicate(input)) { + return mutator(input); + } + + if (input && typeof input === 'object') { + for (const key in input) { + if (input.hasOwnProperty(key)) { + input[key] = walkMutate( + input[key], + predicate, + mutator, + removeUnresolved + ); + } + } + if (removeUnresolved) { + input = cleanUpLinks(input); + } + } + return input; +}; + +const normalizeLink = (allEntries, link, removeUnresolved) => { + const resolvedLink = getLink(allEntries, link); + if (resolvedLink === UNRESOLVED_LINK) { + return removeUnresolved ? resolvedLink : link; + } + return resolvedLink; +}; + +const makeEntryObject = (item, itemEntryPoints) => { + if (!Array.isArray(itemEntryPoints)) { + return item; + } + + const entryPoints = Object.keys(item).filter( + ownKey => itemEntryPoints.indexOf(ownKey) !== -1 + ); + + return entryPoints.reduce((entryObj, entryPoint) => { + entryObj[entryPoint] = item[entryPoint]; + return entryObj; + }, {}); +}; + +/** + * resolveResponse Function + * Resolves contentful response to normalized form. + * @param {Object} response Contentful response + * @param {Object} options + * @param {Boolean} options.removeUnresolved - Remove unresolved links default:false + * @param {Array} options.itemEntryPoints - Resolve links only in those item properties + * @return {Object} + */ +const resolveResponse = (response, options) => { + options = options || {}; + if (!response.items) { + return []; + } + const responseClone = cloneDeep(response); + const allIncludes = Object.keys(responseClone.includes || {}).reduce( + (all, type) => [...all, ...response.includes[type]], + [] + ); + + const allEntries = [...responseClone.items, ...allIncludes]; + + allEntries.forEach(item => { + const entryObject = makeEntryObject(item, options.itemEntryPoints); + + Object.assign( + item, + walkMutate( + entryObject, + isLink, + link => normalizeLink(allEntries, link, options.removeUnresolved), + options.removeUnresolved + ) + ); + }); + + return responseClone.items; +}; + +export default resolveResponse; diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 37b9adecf2..45b703b9d8 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -43,6 +43,7 @@ "dist/credentials/ClockifyApi.credentials.js", "dist/credentials/CockpitApi.credentials.js", "dist/credentials/CodaApi.credentials.js", + "dist/credentials/ContentfulDeliveryApi.credentials.js", "dist/credentials/CopperApi.credentials.js", "dist/credentials/CalendlyApi.credentials.js", "dist/credentials/DisqusApi.credentials.js", @@ -159,6 +160,7 @@ "dist/nodes/Clockify/ClockifyTrigger.node.js", "dist/nodes/Cockpit/Cockpit.node.js", "dist/nodes/Coda/Coda.node.js", + "dist/nodes/Contentful/Contentful.node.js", "dist/nodes/Copper/CopperTrigger.node.js", "dist/nodes/Cron.node.js", "dist/nodes/Crypto.node.js", From 1a785581ffd100853567c12fb5a1e376c075af2e Mon Sep 17 00:00:00 2001 From: Sven Schmidt <0xsven@gmail.com> Date: Thu, 9 Jul 2020 12:10:22 +0200 Subject: [PATCH 0007/1150] Handle preview api --- .../ContentfulDeliveryApi.credentials.ts | 11 ++++++++++- .../nodes/Contentful/ GenericFunctions.ts | 15 +++++++++++++-- .../nodes/Contentful/Contentful.node.ts | 19 ++++++++++++++++++- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/nodes-base/credentials/ContentfulDeliveryApi.credentials.ts b/packages/nodes-base/credentials/ContentfulDeliveryApi.credentials.ts index ce620fcdbb..d9078f0cc1 100644 --- a/packages/nodes-base/credentials/ContentfulDeliveryApi.credentials.ts +++ b/packages/nodes-base/credentials/ContentfulDeliveryApi.credentials.ts @@ -9,13 +9,22 @@ export class ContentfulDeliveryApi implements ICredentialType { name: 'space_id', type: 'string' as NodePropertyTypes, default: '', + required: true, description: 'The id for the Cotentful space.' }, { - displayName: 'Access Token', + displayName: 'Content Delivery API - access token', name: 'access_token', type: 'string' as NodePropertyTypes, default: '', + required: true, + description: 'Access token that has access to the space' + }, + { + displayName: 'Content Preview API - access token', + name: 'access_token_preview', + type: 'string' as NodePropertyTypes, + default: '', description: 'Access token that has access to the space' } ]; diff --git a/packages/nodes-base/nodes/Contentful/ GenericFunctions.ts b/packages/nodes-base/nodes/Contentful/ GenericFunctions.ts index c59bab1449..246c43c220 100644 --- a/packages/nodes-base/nodes/Contentful/ GenericFunctions.ts +++ b/packages/nodes-base/nodes/Contentful/ GenericFunctions.ts @@ -18,11 +18,22 @@ export const contentfulApiRequest = async ( throw new Error('No credentials got returned!'); } - let url = `https://cdn.contentful.com/spaces/${credentials.space_id}`; + const source = that.getNodeParameter('source', 0) as string; + const isPreview = source === 'preview_api'; + let accessToken = credentials.access_token as string; + if (isPreview) { + accessToken = credentials.access_token_preview as string; + console.log('accessToken', accessToken); + if (!accessToken) { + throw new Error('No access token for preview API set in credentials!'); + } + } + + let url = `https://${isPreview ? 'preview' : 'cdn'}.contentful.com/spaces/${credentials.space_id}`; if (environmentId) url = `${url}/environments/${environmentId}`; if (endpoint) url = `${url}${endpoint}`; qs = qs || {}; - qs.access_token = credentials.access_token as string; + qs.access_token = accessToken; const res = await that.helpers.request!({ url, diff --git a/packages/nodes-base/nodes/Contentful/Contentful.node.ts b/packages/nodes-base/nodes/Contentful/Contentful.node.ts index 866cddb268..e16fb51f29 100644 --- a/packages/nodes-base/nodes/Contentful/Contentful.node.ts +++ b/packages/nodes-base/nodes/Contentful/Contentful.node.ts @@ -33,11 +33,28 @@ export class Contentful implements INodeType { ], properties: [ // Common fields: + { + displayName: 'Source', + name: 'source', + type: 'options' as NodePropertyTypes, + default: 'Delivery API', + description: 'Pick where your data comes from, delivery or preview API', + options: [ + { + name: 'Delivery API', + value: 'delivery_api' + }, + { + name: 'Preview API', + value: 'preview_api' + } + ] + }, { displayName: 'Environment Id', name: 'environment_id', type: 'string' as NodePropertyTypes, - default: 'master', + default: '', description: 'The id for the Contentful environment (e.g. master, staging, etc.). Depending on your plan, you might not have environments. In that case use "master".' }, From 784cafffa077dce01611f2d5ea508bf0c83f0a0a Mon Sep 17 00:00:00 2001 From: Sven Schmidt <0xsven@gmail.com> Date: Thu, 9 Jul 2020 12:17:19 +0200 Subject: [PATCH 0008/1150] Renaming a file --- packages/nodes-base/nodes/Contentful/Contentful.node.ts | 2 +- .../Contentful/{ GenericFunctions.ts => GenericFunctions.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/nodes-base/nodes/Contentful/{ GenericFunctions.ts => GenericFunctions.ts} (100%) diff --git a/packages/nodes-base/nodes/Contentful/Contentful.node.ts b/packages/nodes-base/nodes/Contentful/Contentful.node.ts index e16fb51f29..930534e5f7 100644 --- a/packages/nodes-base/nodes/Contentful/Contentful.node.ts +++ b/packages/nodes-base/nodes/Contentful/Contentful.node.ts @@ -1,7 +1,7 @@ import { IExecuteFunctions } from 'n8n-core'; import { IDataObject, INodeExecutionData, INodeType, INodeTypeDescription, NodePropertyTypes } from 'n8n-workflow'; -import { contentfulApiRequest } from './ GenericFunctions'; +import { contentfulApiRequest } from './GenericFunctions'; import resolveResponse from './resolveResponse'; import * as SpaceDescription from './SpaceDescription'; diff --git a/packages/nodes-base/nodes/Contentful/ GenericFunctions.ts b/packages/nodes-base/nodes/Contentful/GenericFunctions.ts similarity index 100% rename from packages/nodes-base/nodes/Contentful/ GenericFunctions.ts rename to packages/nodes-base/nodes/Contentful/GenericFunctions.ts From 6573642fc1e7c27b031c34af0fda4ecba1c722ec Mon Sep 17 00:00:00 2001 From: Sven Schmidt <0xsven@gmail.com> Date: Thu, 9 Jul 2020 16:29:06 +0200 Subject: [PATCH 0009/1150] Fixing a typo --- .../nodes-base/credentials/ContentfulDeliveryApi.credentials.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes-base/credentials/ContentfulDeliveryApi.credentials.ts b/packages/nodes-base/credentials/ContentfulDeliveryApi.credentials.ts index d9078f0cc1..c8db4ada74 100644 --- a/packages/nodes-base/credentials/ContentfulDeliveryApi.credentials.ts +++ b/packages/nodes-base/credentials/ContentfulDeliveryApi.credentials.ts @@ -10,7 +10,7 @@ export class ContentfulDeliveryApi implements ICredentialType { type: 'string' as NodePropertyTypes, default: '', required: true, - description: 'The id for the Cotentful space.' + description: 'The id for the Contentful space.' }, { displayName: 'Content Delivery API - access token', From ad1228e0ea310b10c81c9bbea3562396f2ece8e9 Mon Sep 17 00:00:00 2001 From: Erin Date: Thu, 9 Jul 2020 16:54:50 -0400 Subject: [PATCH 0010/1150] :sparkles: Everything works except refresh --- .../editor-ui/src/components/MainSidebar.vue | 27 +++++++++++++---- .../editor-ui/src/components/WorkflowOpen.vue | 17 +++++++++-- .../src/components/mixins/workflowHelpers.ts | 29 ++++++++++++++++++ packages/editor-ui/src/views/NodeView.vue | 30 ++++++++++++++----- 4 files changed, 87 insertions(+), 16 deletions(-) diff --git a/packages/editor-ui/src/components/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue index 71388c2cd3..57096c0f29 100644 --- a/packages/editor-ui/src/components/MainSidebar.vue +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -443,13 +443,28 @@ export default mixins( } else if (key === 'workflow-settings') { this.workflowSettingsDialogVisible = true; } else if (key === 'workflow-new') { - this.$router.push({ name: 'NodeViewNew' }); + const workflowId = this.$store.getters.workflowId; + const result = await this.dataHasChanged(workflowId); + if(result) { + const importConfirm = await this.confirmMessage(`When you switch workflows your current workflow changes will be lost.`, 'Save your Changes?', 'warning', 'Yes, switch workflows and forget changes'); + if (importConfirm === true) { + this.$router.push({ name: 'NodeViewNew' }); - this.$showMessage({ - title: 'Workflow created', - message: 'A new workflow got created!', - type: 'success', - }); + this.$showMessage({ + title: 'Workflow created', + message: 'A new workflow got created!', + type: 'success', + }); + } + } else { + this.$router.push({ name: 'NodeViewNew' }); + + this.$showMessage({ + title: 'Workflow created', + message: 'A new workflow got created!', + type: 'success', + }); + } } else if (key === 'credentials-open') { this.credentialOpenDialogVisible = true; } else if (key === 'credentials-new') { diff --git a/packages/editor-ui/src/components/WorkflowOpen.vue b/packages/editor-ui/src/components/WorkflowOpen.vue index 9592ecb4d5..9ef5e94eb0 100644 --- a/packages/editor-ui/src/components/WorkflowOpen.vue +++ b/packages/editor-ui/src/components/WorkflowOpen.vue @@ -33,6 +33,7 @@ import WorkflowActivator from '@/components/WorkflowActivator.vue'; import { restApi } from '@/components/mixins/restApi'; import { genericHelpers } from '@/components/mixins/genericHelpers'; +import { workflowHelpers } from '@/components/mixins/workflowHelpers'; import { showMessage } from '@/components/mixins/showMessage'; import { IWorkflowShortResponse } from '@/Interface'; @@ -42,6 +43,7 @@ export default mixins( genericHelpers, restApi, showMessage, + workflowHelpers, ).extend({ name: 'WorkflowOpen', props: [ @@ -87,9 +89,20 @@ export default mixins( this.$emit('closeDialog'); return false; }, - openWorkflow (data: IWorkflowShortResponse, column: any) { // tslint:disable-line:no-any + async openWorkflow (data: IWorkflowShortResponse, column: any) { // tslint:disable-line:no-any if (column.label !== 'Active') { - this.$emit('openWorkflow', data.id); + const workflowId = this.$store.getters.workflowId; + const result = await this.dataHasChanged(workflowId); + if(result) { + const importConfirm = await this.confirmMessage(`When you switch workflows your current workflow changes will be lost.`, 'Save your Changes?', 'warning', 'Yes, switch workflows and forget changes'); + if (importConfirm === false) { + return; + } else { + this.$emit('openWorkflow', data.id); + } + } else { + this.$emit('openWorkflow', data.id); + } } }, openDialog () { diff --git a/packages/editor-ui/src/components/mixins/workflowHelpers.ts b/packages/editor-ui/src/components/mixins/workflowHelpers.ts index 507d6d31e4..76ad77cdb9 100644 --- a/packages/editor-ui/src/components/mixins/workflowHelpers.ts +++ b/packages/editor-ui/src/components/mixins/workflowHelpers.ts @@ -22,6 +22,7 @@ import { INodeTypesMaxCount, INodeUi, IWorkflowData, + IWorkflowDb, IWorkflowDataUpdate, XYPositon, } from '../../Interface'; @@ -30,6 +31,8 @@ import { restApi } from '@/components/mixins/restApi'; import { nodeHelpers } from '@/components/mixins/nodeHelpers'; import { showMessage } from '@/components/mixins/showMessage'; +import { isEqual } from 'lodash'; + import mixins from 'vue-typed-mixins'; export const workflowHelpers = mixins( @@ -478,5 +481,31 @@ export const workflowHelpers = mixins( node.position[1] += offsetPosition[1]; } }, + async dataHasChanged(id: string) { + const currentData = await this.getWorkflowDataToSave(); + + let data: IWorkflowDb; + data = await this.restApi().getWorkflow(id); + + if(data !== undefined) { + console.log(currentData); + console.log(data); + const x = { + nodes: data.nodes, + connections: data.connections, + settings: data.settings, + name: data.name + }; + const y = { + nodes: currentData.nodes, + connections: currentData.connections, + settings: currentData.settings, + name: currentData.name + }; + return !isEqual(x, y); + } + + return true; + }, }, }); diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 573849eb39..8ce1378d52 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -126,7 +126,7 @@ import RunData from '@/components/RunData.vue'; import mixins from 'vue-typed-mixins'; -import { debounce } from 'lodash'; +import { debounce, isEqual } from 'lodash'; import axios from 'axios'; import { IConnection, @@ -330,6 +330,8 @@ export default mixins( this.$store.commit('setWorkflowSettings', data.settings || {}); await this.addNodes(data.nodes, data.connections); + + return data; }, mouseDown (e: MouseEvent) { // Save the location of the mouse click @@ -1309,6 +1311,7 @@ export default mixins( if (this.$route.name === 'ExecutionById') { // Load an execution const executionId = this.$route.params.id; + await this.openExecution(executionId); } else { // Load a workflow @@ -1316,7 +1319,6 @@ export default mixins( if (this.$route.params.name) { workflowId = this.$route.params.name; } - if (workflowId !== null) { // Open existing workflow await this.openWorkflow(workflowId); @@ -1328,10 +1330,22 @@ export default mixins( document.addEventListener('keydown', this.keyDown); document.addEventListener('keyup', this.keyUp); - window.onbeforeunload = this.confirmSave; - }, - async confirmSave(e: Event) { - window.confirm(); + + window.addEventListener("beforeunload", (e) => { + let workflowId = null as string | null; + if (this.$route.params.name) { + workflowId = this.$route.params.name; + } + if(workflowId !== null) { + //const dataHasChanged = await this.dataHasChanged(workflowId); + } + + const confirmationMessage = 'It looks like you have been editing something. ' + + 'If you leave before saving, your changes will be lost.'; + + (e || window.event).returnValue = confirmationMessage; //Gecko + IE + return confirmationMessage; //Gecko + Webkit, Safari, Chrome etc. + }); }, __addConnection (connection: [IConnection, IConnection], addVisualConnection = false) { if (addVisualConnection === true) { @@ -1876,13 +1890,13 @@ export default mixins( async mounted () { this.$root.$on('importWorkflowData', async (data: IDataObject) => { - await this.importWorkflowData(data.data as IWorkflowDataUpdate); + const resData = await this.importWorkflowData(data.data as IWorkflowDataUpdate); }); this.$root.$on('importWorkflowUrl', async (data: IDataObject) => { const workflowData = await this.getWorkflowDataFromUrl(data.url as string); if (workflowData !== undefined) { - await this.importWorkflowData(workflowData); + const resData = await this.importWorkflowData(workflowData); } }); From 70a584a46d419f83b05034a7cd3eacfd8bea248c Mon Sep 17 00:00:00 2001 From: Erin Date: Mon, 20 Jul 2020 10:57:58 -0400 Subject: [PATCH 0011/1150] :tada: Works with ctrl s, now working on a user saving from the side bar --- packages/editor-ui/src/views/NodeView.vue | 55 +++++++++++++++++------ 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 8ce1378d52..5f2a08e3fc 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -186,6 +186,38 @@ export default mixins( // When a node gets set as active deactivate the create-menu this.createNodeActive = false; }, + nodes: { + handler: async function (val, oldVal) { + // Load a workflow + let workflowId = null as string | null; + if (this.$route && this.$route.params.name) { + workflowId = this.$route.params.name; + } + if(workflowId !== null) { + this.isDirty = await this.dataHasChanged(workflowId); + } else { + this.isDirty = true; + } + console.log(this.isDirty); + }, + deep: true + }, + connections: { + handler: async function (val, oldVal) { + // Load a workflow + let workflowId = null as string | null; + if (this.$route && this.$route.params.name) { + workflowId = this.$route.params.name; + } + if(workflowId !== null) { + this.isDirty = await this.dataHasChanged(workflowId); + } else { + this.isDirty = true; + } + console.log(this.isDirty); + }, + deep: true + }, }, computed: { activeNode (): INodeUi | null { @@ -259,6 +291,7 @@ export default mixins( ctrlKeyPressed: false, debouncedFunctions: [] as any[], // tslint:disable-line:no-any stopExecutionInProgress: false, + isDirty: false, }; }, beforeDestroy () { @@ -433,6 +466,8 @@ export default mixins( e.stopPropagation(); e.preventDefault(); + this.isDirty = false; + this.callDebounced('saveCurrentWorkflow', 1000); } else if (e.key === 'Enter') { // Activate the last selected node @@ -1305,6 +1340,7 @@ export default mixins( if (this.$route.params.action === 'workflowSave') { // In case the workflow got saved we do not have to run init // as only the route changed but all the needed data is already loaded + this.isDirty = false; return Promise.resolve(); } @@ -1331,20 +1367,13 @@ export default mixins( document.addEventListener('keydown', this.keyDown); document.addEventListener('keyup', this.keyUp); - window.addEventListener("beforeunload", (e) => { - let workflowId = null as string | null; - if (this.$route.params.name) { - workflowId = this.$route.params.name; + window.addEventListener("beforeunload", (e) => { + if(this.isDirty === true) { + const confirmationMessage = 'It looks like you have been editing something. ' + + 'If you leave before saving, your changes will be lost.'; + (e || window.event).returnValue = confirmationMessage; //Gecko + IE + return confirmationMessage; //Gecko + Webkit, Safari, Chrome etc. } - if(workflowId !== null) { - //const dataHasChanged = await this.dataHasChanged(workflowId); - } - - const confirmationMessage = 'It looks like you have been editing something. ' - + 'If you leave before saving, your changes will be lost.'; - - (e || window.event).returnValue = confirmationMessage; //Gecko + IE - return confirmationMessage; //Gecko + Webkit, Safari, Chrome etc. }); }, __addConnection (connection: [IConnection, IConnection], addVisualConnection = false) { From 5f32341a9ea14a50b405ed0606949ae660706bd1 Mon Sep 17 00:00:00 2001 From: Erin Date: Mon, 20 Jul 2020 11:52:24 -0400 Subject: [PATCH 0012/1150] Remove logs --- packages/editor-ui/src/components/MainSidebar.vue | 2 ++ .../editor-ui/src/components/mixins/workflowHelpers.ts | 2 -- packages/editor-ui/src/views/NodeView.vue | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/editor-ui/src/components/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue index 57096c0f29..5cb68b4ee3 100644 --- a/packages/editor-ui/src/components/MainSidebar.vue +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -435,8 +435,10 @@ export default mixins( saveAs(blob, workflowName + '.json'); } else if (key === 'workflow-save') { + console.log("saving......"); this.saveCurrentWorkflow(); } else if (key === 'workflow-save-as') { + console.log("saving......"); this.saveCurrentWorkflow(true); } else if (key === 'help-about') { this.aboutDialogVisible = true; diff --git a/packages/editor-ui/src/components/mixins/workflowHelpers.ts b/packages/editor-ui/src/components/mixins/workflowHelpers.ts index 76ad77cdb9..b3c2416b28 100644 --- a/packages/editor-ui/src/components/mixins/workflowHelpers.ts +++ b/packages/editor-ui/src/components/mixins/workflowHelpers.ts @@ -488,8 +488,6 @@ export const workflowHelpers = mixins( data = await this.restApi().getWorkflow(id); if(data !== undefined) { - console.log(currentData); - console.log(data); const x = { nodes: data.nodes, connections: data.connections, diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 5f2a08e3fc..c75899424d 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -187,7 +187,7 @@ export default mixins( this.createNodeActive = false; }, nodes: { - handler: async function (val, oldVal) { + async handler (val, oldVal) { // Load a workflow let workflowId = null as string | null; if (this.$route && this.$route.params.name) { @@ -198,12 +198,11 @@ export default mixins( } else { this.isDirty = true; } - console.log(this.isDirty); }, deep: true }, connections: { - handler: async function (val, oldVal) { + async handler (val, oldVal) { // Load a workflow let workflowId = null as string | null; if (this.$route && this.$route.params.name) { @@ -214,7 +213,6 @@ export default mixins( } else { this.isDirty = true; } - console.log(this.isDirty); }, deep: true }, @@ -1373,6 +1371,8 @@ export default mixins( + 'If you leave before saving, your changes will be lost.'; (e || window.event).returnValue = confirmationMessage; //Gecko + IE return confirmationMessage; //Gecko + Webkit, Safari, Chrome etc. + } else { + return; } }); }, From bc0b349faa91cc92ac2ae50e08e3db8bffcdfb8a Mon Sep 17 00:00:00 2001 From: ricardo Date: Tue, 4 Aug 2020 15:07:54 -0400 Subject: [PATCH 0013/1150] :zap: Improvements to Contenful-Node --- .../credentials/ContentfulApi.credentials.ts | 36 +++ .../ContentfulDeliveryApi.credentials.ts | 31 --- .../nodes/Contentful/AssetDescription.ts | 228 +++++++++++++--- .../Contentful/ContentTypeDescription.ts | 100 ++++--- .../nodes/Contentful/Contentful.node.ts | 251 +++++++++++++----- .../nodes/Contentful/EntryDescription.ts | 214 +++++++++++---- .../nodes/Contentful/GenericFunctions.ts | 100 ++++--- .../nodes/Contentful/LocaleDescription.ts | 109 ++++++-- .../nodes/Contentful/SpaceDescription.ts | 46 ++-- .../nodes/Contentful/resolveResponse.ts | 156 ----------- packages/nodes-base/package.json | 2 +- 11 files changed, 810 insertions(+), 463 deletions(-) create mode 100644 packages/nodes-base/credentials/ContentfulApi.credentials.ts delete mode 100644 packages/nodes-base/credentials/ContentfulDeliveryApi.credentials.ts delete mode 100644 packages/nodes-base/nodes/Contentful/resolveResponse.ts diff --git a/packages/nodes-base/credentials/ContentfulApi.credentials.ts b/packages/nodes-base/credentials/ContentfulApi.credentials.ts new file mode 100644 index 0000000000..d08b97e647 --- /dev/null +++ b/packages/nodes-base/credentials/ContentfulApi.credentials.ts @@ -0,0 +1,36 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +//https://www.contentful.com/developers/docs/references/authentication/ +export class ContentfulApi implements ICredentialType { + name = 'contentfulApi'; + displayName = 'Contenful API'; + properties = [ + { + displayName: 'Space ID', + name: 'spaceId', + type: 'string' as NodePropertyTypes, + default: '', + required: true, + description: 'The id for the Contentful space.' + }, + { + displayName: 'Content Delivery API Access token', + name: 'ContentDeliveryaccessToken', + type: 'string' as NodePropertyTypes, + default: '', + required: true, + description: 'Access token that has access to the space' + }, + { + displayName: 'Content Preview API Access token', + name: 'ContentPreviewaccessToken', + type: 'string' as NodePropertyTypes, + default: '', + required: true, + description: 'Access token that has access to the space' + }, + ]; +} diff --git a/packages/nodes-base/credentials/ContentfulDeliveryApi.credentials.ts b/packages/nodes-base/credentials/ContentfulDeliveryApi.credentials.ts deleted file mode 100644 index c8db4ada74..0000000000 --- a/packages/nodes-base/credentials/ContentfulDeliveryApi.credentials.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ICredentialType, NodePropertyTypes } from 'n8n-workflow'; - -export class ContentfulDeliveryApi implements ICredentialType { - name = 'contentfulDeliveryApi'; - displayName = 'Delivery API'; - properties = [ - { - displayName: 'Space Id', - name: 'space_id', - type: 'string' as NodePropertyTypes, - default: '', - required: true, - description: 'The id for the Contentful space.' - }, - { - displayName: 'Content Delivery API - access token', - name: 'access_token', - type: 'string' as NodePropertyTypes, - default: '', - required: true, - description: 'Access token that has access to the space' - }, - { - displayName: 'Content Preview API - access token', - name: 'access_token_preview', - type: 'string' as NodePropertyTypes, - default: '', - description: 'Access token that has access to the space' - } - ]; -} diff --git a/packages/nodes-base/nodes/Contentful/AssetDescription.ts b/packages/nodes-base/nodes/Contentful/AssetDescription.ts index 18ccba8257..dfd523806e 100644 --- a/packages/nodes-base/nodes/Contentful/AssetDescription.ts +++ b/packages/nodes-base/nodes/Contentful/AssetDescription.ts @@ -1,49 +1,197 @@ -import { INodeProperties, INodePropertyOptions } from "n8n-workflow"; +import { + INodeProperties, + INodePropertyOptions, +} from 'n8n-workflow'; export const resource = { - name: "Asset", - value: "asset", + name: 'Asset', + value: 'asset', } as INodePropertyOptions; export const operations = [ - { - displayName: "Operation", - name: "operation", - type: "options", - displayOptions: { - show: { - resource: [resource.value], - }, - }, - options: [ - { - name: "Get Assets", - value: "get_assets", - }, - { - name: "Get Single Asset", - value: "get_asset", - }, - ], - default: "get_assets", - description: "The operation to perform.", - }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + resource.value, + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + }, + { + name: 'Get All', + value: 'getAll', + }, + ], + default: 'getAll', + description: 'The operation to perform.', + }, ] as INodeProperties[]; export const fields = [ - { - displayName: "Asset Id", - name: "asset_id", - type: "string", - default: "", - placeholder: "", - description: "", - required: true, - displayOptions: { - show: { - resource: [resource.value], - operation: ["get_asset"], - }, - }, - }, + { + displayName: 'Environment ID', + name: 'environmentId', + type: 'string', + displayOptions: { + show: { + resource: [ + resource.value, + ], + operation: [ + 'get', + 'getAll', + ], + }, + }, + default: 'master', + description: 'The id for the Contentful environment (e.g. master, staging, etc.). Depending on your plan, you might not have environments. In that case use "master".' + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + resource.value, + ], + }, + }, + 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: [ + resource.value, + ], + 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: [ + resource.value, + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Equal', + name: 'equal', + type: 'string', + default: '', + placeholder: 'fields.title=n8n', + description: 'Search for all data that matches the condition: {attribute}={value}. Attribute can use dot notation.', + }, + { + displayName: 'Exclude', + name: 'exclude', + type: 'string', + default: '', + placeholder: 'fields.tags[nin]=accessories,flowers', + description: 'Search for all data that matches the condition: {attribute}[nin]={value}. Attribute can use dot notation.', + }, + { + displayName: 'Exist', + name: 'exist', + type: 'string', + default: '', + placeholder: 'fields.tags[exists]=true', + description: 'Search for all data that matches the condition: {attribute}[exists]={value}. Attribute can use dot notation.', + }, + { + displayName: 'Fields', + name: 'select', + type: 'string', + placeholder: 'fields.title', + default: '', + description: 'The select operator allows you to choose what fields to return from an entity. You can choose multiple values by combining comma separated operators.', + }, + { + displayName: 'Include', + name: 'include', + type: 'string', + default: '', + placeholder: 'fields.tags[in]=accessories,flowers', + description: 'Search for all data that matches the condition: {attribute}[in]={value}. Attribute can use dot notation.', + }, + { + displayName: 'Not Equal', + name: 'notEqual', + type: 'string', + default: '', + placeholder: 'fields.title[ne]=n8n', + description: 'Search for all data that matches the condition: {attribute}[ne]={value}. Attribute can use dot notation.', + }, + { + displayName: 'Order', + name: 'order', + type: 'string', + default: '', + placeholder: 'sys.createdAt', + description: 'You can order items in the response by specifying the order search parameter. You can use sys properties (such as sys.createdAt) or field values (such as fields.myCustomDateField) for ordering.', + }, + { + displayName: 'Query', + name: 'query', + type: 'string', + default: '', + description: ' Full-text search is case insensitive and might return more results than expected. A query will only take values with more than 1 character.', + }, + ], + }, + { + displayName: 'Asset ID', + name: 'assetId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + resource.value + ], + operation: [ + 'get', + ], + }, + }, + }, ] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Contentful/ContentTypeDescription.ts b/packages/nodes-base/nodes/Contentful/ContentTypeDescription.ts index 0a2ebfd1e7..6b2fa4eb3f 100644 --- a/packages/nodes-base/nodes/Contentful/ContentTypeDescription.ts +++ b/packages/nodes-base/nodes/Contentful/ContentTypeDescription.ts @@ -1,49 +1,69 @@ -import { INodeProperties, INodePropertyOptions } from "n8n-workflow"; +import { + INodeProperties, + INodePropertyOptions, +} from 'n8n-workflow'; export const resource = { - name: "Content Types", - value: "content_type", + name: 'Content Type', + value: 'contentType', } as INodePropertyOptions; export const operations = [ - { - displayName: "Operation", - name: "operation", - type: "options", - displayOptions: { - show: { - resource: [resource.value], - }, - }, - options: [ - { - name: "Get Content types", - value: "get_content_types", - }, - { - name: "Get Single Content Type", - value: "get_content_type", - }, - ], - default: "get_content_types", - description: "The operation to perform.", - }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + resource.value, + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + }, + ], + default: 'get', + description: 'The operation to perform.', + }, ] as INodeProperties[]; export const fields = [ - { - displayName: "Content Type Id", - name: "content_type_id", - type: "string", - default: "", - placeholder: "", - description: "", - required: true, - displayOptions: { - show: { - resource: [resource.value], - operation: ["get_content_type"], - }, - }, - }, + { + displayName: 'Environment ID', + name: 'environmentId', + type: 'string', + displayOptions: { + show: { + resource: [ + resource.value, + ], + operation: [ + 'get', + ], + }, + }, + default: 'master', + description: 'The id for the Contentful environment (e.g. master, staging, etc.). Depending on your plan, you might not have environments. In that case use "master".' + }, + { + displayName: 'Content Type ID', + name: 'contentTypeId', + type: 'string', + default: '', + required: true, + displayOptions: { + show: { + resource: [ + resource.value, + ], + operation: [ + 'get', + ], + }, + }, + }, ] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Contentful/Contentful.node.ts b/packages/nodes-base/nodes/Contentful/Contentful.node.ts index 930534e5f7..820e8b07a9 100644 --- a/packages/nodes-base/nodes/Contentful/Contentful.node.ts +++ b/packages/nodes-base/nodes/Contentful/Contentful.node.ts @@ -1,24 +1,34 @@ -import { IExecuteFunctions } from 'n8n-core'; -import { IDataObject, INodeExecutionData, INodeType, INodeTypeDescription, NodePropertyTypes } from 'n8n-workflow'; +import { + IExecuteFunctions, +} from 'n8n-core'; -import { contentfulApiRequest } from './GenericFunctions'; -import resolveResponse from './resolveResponse'; +import { + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + contentfulApiRequest, + contenfulApiRequestAllItems, +} from './GenericFunctions'; import * as SpaceDescription from './SpaceDescription'; import * as ContentTypeDescription from './ContentTypeDescription'; import * as EntryDescription from './EntryDescription'; import * as AssetDescription from './AssetDescription'; import * as LocaleDescription from './LocaleDescription'; -import * as SearchParameterDescription from './SearchParameterDescription'; export class Contentful implements INodeType { description: INodeTypeDescription = { displayName: 'Contentful', name: 'contentful', + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', icon: 'file:contentful.png', group: ['input'], version: 1, - description: "Access data through Contentful's Content Delivery API", + description: 'Consume Contenful API', defaults: { name: 'Contentful', color: '#2E75D4' @@ -27,49 +37,39 @@ export class Contentful implements INodeType { outputs: ['main'], credentials: [ { - name: 'contentfulDeliveryApi', + name: 'contentfulApi', required: true - } + }, ], properties: [ - // Common fields: { displayName: 'Source', name: 'source', - type: 'options' as NodePropertyTypes, + type: 'options', default: 'Delivery API', description: 'Pick where your data comes from, delivery or preview API', options: [ { name: 'Delivery API', - value: 'delivery_api' + value: 'deliveryApi' }, { name: 'Preview API', - value: 'preview_api' - } - ] + value: 'previewApi' + }, + ], }, - { - displayName: 'Environment Id', - name: 'environment_id', - type: 'string' as NodePropertyTypes, - default: '', - description: - 'The id for the Contentful environment (e.g. master, staging, etc.). Depending on your plan, you might not have environments. In that case use "master".' - }, - // Resources: { displayName: 'Resource', name: 'resource', type: 'options', options: [ - SpaceDescription.resource, + AssetDescription.resource, ContentTypeDescription.resource, EntryDescription.resource, - AssetDescription.resource, - LocaleDescription.resource + LocaleDescription.resource, + SpaceDescription.resource, ], default: '', description: 'The resource to operate on.' @@ -88,16 +88,13 @@ export class Contentful implements INodeType { ...EntryDescription.fields, ...AssetDescription.fields, ...LocaleDescription.fields, - - // Options: - ...SearchParameterDescription.fields - ] + ], }; async execute(this: IExecuteFunctions): Promise { - const environmentId = this.getNodeParameter('environment_id', 0) as string; const resource = this.getNodeParameter('resource', 0) as string; const operation = this.getNodeParameter('operation', 0) as string; + let responseData; const items = this.getInputData(); const returnData: IDataObject[] = []; @@ -105,54 +102,164 @@ export class Contentful implements INodeType { for (let i = 0; i < items.length; i++) { if (resource === 'space') { - if (operation === 'get_space') { - const res = await contentfulApiRequest(this); - returnData.push(res); + if (operation === 'get') { + + const credentials = this.getCredentials('contentfulApi'); + + responseData = await contentfulApiRequest.call(this, 'GET', `/spaces/${credentials?.spaceId}`); } - } else if (resource === 'content_type') { - if (operation === 'get_content_types') { - const res = await contentfulApiRequest(this, '/content_types', environmentId); - const resolvedData = resolveResponse(res, {}); - returnData.push(...resolvedData); - } else if (operation === 'get_content_type') { - const id = this.getNodeParameter('content_type_id', 0) as string; - const res = await contentfulApiRequest(this, `/content_types/${id}`, environmentId); - returnData.push(...res.items); + } + if (resource === 'contentType') { + if (operation === 'get') { + + const credentials = this.getCredentials('contentfulApi'); + + const env = this.getNodeParameter('environmentId', 0) as string; + + const id = this.getNodeParameter('contentTypeId', 0) as string; + + responseData = await contentfulApiRequest.call(this, 'GET', `/spaces/${credentials?.spaceId}/environments/${env}/content_types/${id}`); } - } else if (resource === 'entry') { - if (operation === 'get_entries') { - const shouldResolve = this.getNodeParameter('resolve', 0) as boolean; - if (shouldResolve) qs.include = this.getNodeParameter('include', 0) as number; - const searchParameters = this.getNodeParameter('search_parameters', 0) as IDataObject; - if (searchParameters.parameters && Array.isArray(searchParameters.parameters)) { - searchParameters.parameters.forEach(parameter => { - const { name, value } = parameter as { name: string; value: string }; - qs[name] = value; - }); + } + if (resource === 'entry') { + + if (operation === 'get') { + + const credentials = this.getCredentials('contentfulApi'); + + const env = this.getNodeParameter('environmentId', 0) as string; + + const id = this.getNodeParameter('entryId', 0) as string; + + responseData = await contentfulApiRequest.call(this, 'GET', `/spaces/${credentials?.spaceId}/environments/${env}/entries/${id}`, {}, qs); + + } else if (operation === 'getAll') { + const credentials = this.getCredentials('contentfulApi'); + + const returnAll = this.getNodeParameter('returnAll', 0) as boolean; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + const env = this.getNodeParameter('environmentId', i) as string; + + Object.assign(qs, additionalFields); + + if (qs.equal) { + const [atribute, value] = (qs.equal as string).split('='); + qs[atribute] = value; + delete qs.equal; + } + + if (qs.notEqual) { + const [atribute, value] = (qs.notEqual as string).split('='); + qs[atribute] = value; + delete qs.notEqual; + } + + if (qs.include) { + const [atribute, value] = (qs.include as string).split('='); + qs[atribute] = value; + delete qs.include; + } + + if (qs.exclude) { + const [atribute, value] = (qs.exclude as string).split('='); + qs[atribute] = value; + delete qs.exclude; + } + + if (returnAll) { + responseData = await contenfulApiRequestAllItems.call(this, 'items', 'GET', `/spaces/${credentials?.spaceId}/environments/${env}/entries`, {}, qs); + } else { + const limit = this.getNodeParameter('limit', 0) as number; + qs.limit = limit; + responseData = await contentfulApiRequest.call(this, 'GET', `/spaces/${credentials?.spaceId}/environments/${env}/entries`, {}, qs); + responseData = responseData.items; } - const res = await contentfulApiRequest(this, '/entries', environmentId, qs); - const resolvedData = shouldResolve ? resolveResponse(res, {}) : res.items; - returnData.push(...resolvedData); - } else if (operation === 'get_entry') { - const id = this.getNodeParameter('entry_id', 0) as string; - const res = await contentfulApiRequest(this, `/entries/${id}`, environmentId); - returnData.push(res); } - } else if (resource === 'asset') { - if (operation === 'get_assets') { - const res = await contentfulApiRequest(this, '/assets', environmentId); - returnData.push(...res.items); - } else if (operation === 'get_asset') { - const id = this.getNodeParameter('asset_id', 0) as string; - const res = await contentfulApiRequest(this, `/assets/${id}`, environmentId); - returnData.push(res); + } + if (resource === 'asset') { + if (operation === 'get') { + + const credentials = this.getCredentials('contentfulApi'); + + const env = this.getNodeParameter('environmentId', 0) as string; + + const id = this.getNodeParameter('assetId', 0) as string; + + responseData = await contentfulApiRequest.call(this, 'GET', `/spaces/${credentials?.spaceId}/environments/${env}/assets/${id}`, {}, qs); + + } else if (operation === 'getAll') { + + const credentials = this.getCredentials('contentfulApi'); + + const returnAll = this.getNodeParameter('returnAll', 0) as boolean; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + const env = this.getNodeParameter('environmentId', i) as string; + + Object.assign(qs, additionalFields); + + if (qs.equal) { + const [atribute, value] = (qs.equal as string).split('='); + qs[atribute] = value; + delete qs.equal; + } + + if (qs.notEqual) { + const [atribute, value] = (qs.notEqual as string).split('='); + qs[atribute] = value; + delete qs.notEqual; + } + + if (qs.include) { + const [atribute, value] = (qs.include as string).split('='); + qs[atribute] = value; + delete qs.include; + } + + if (qs.exclude) { + const [atribute, value] = (qs.exclude as string).split('='); + qs[atribute] = value; + delete qs.exclude; + } + + if (returnAll) { + responseData = await contenfulApiRequestAllItems.call(this, 'items', 'GET', `/spaces/${credentials?.spaceId}/environments/${env}/assets`, {}, qs); + } else { + const limit = this.getNodeParameter('limit', 0) as number; + qs.limit = limit; + responseData = await contentfulApiRequest.call(this, 'GET', `/spaces/${credentials?.spaceId}/environments/${env}/assets`, {}, qs); + responseData = responseData.items; + } } - } else if (resource === 'locale') { - if (operation === 'get_locales') { - const res = await contentfulApiRequest(this, '/locales', environmentId); - returnData.push(res); + } + if (resource === 'locale') { + + if (operation === 'getAll') { + + const credentials = this.getCredentials('contentfulApi'); + + const returnAll = this.getNodeParameter('returnAll', 0) as boolean; + + const env = this.getNodeParameter('environmentId', i) as string; + + if (returnAll) { + responseData = await contenfulApiRequestAllItems.call(this, 'items', 'GET', `/spaces/${credentials?.spaceId}/environments/${env}/locales`, {}, qs); + } else { + const limit = this.getNodeParameter('limit', 0) as number; + qs.limit = limit; + responseData = await contentfulApiRequest.call(this, 'GET', `/spaces/${credentials?.spaceId}/environments/${env}/locales`, {}, qs); + responseData = responseData.items; + } } } + 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/Contentful/EntryDescription.ts b/packages/nodes-base/nodes/Contentful/EntryDescription.ts index 781bcc6fe1..c22e3ba0d6 100644 --- a/packages/nodes-base/nodes/Contentful/EntryDescription.ts +++ b/packages/nodes-base/nodes/Contentful/EntryDescription.ts @@ -1,8 +1,14 @@ -import { INodeProperties, INodePropertyOptions } from 'n8n-workflow'; +import { + INodeProperties, + INodePropertyOptions, +} from 'n8n-workflow'; +import { type } from 'os'; +import { notEqual } from 'assert'; +import { exists } from 'fs'; export const resource = { name: 'Entry', - value: 'entry' + value: 'entry', } as INodePropertyOptions; export const operations = [ @@ -12,72 +18,190 @@ export const operations = [ type: 'options', displayOptions: { show: { - resource: [resource.value] - } + resource: [ + resource.value, + ], + }, }, options: [ { - name: 'Get Entries', - value: 'get_entries' + name: 'Get', + value: 'get', }, { - name: 'Get Single Entry', - value: 'get_entry' - } + name: 'Get All', + value: 'getAll', + }, ], - default: 'get_entries', + default: 'get', description: 'The operation to perform.' } ] as INodeProperties[]; export const fields = [ { - displayName: 'Resolve', - name: 'resolve', - type: 'boolean', - default: false, - description: 'Linked entries can be automatically resolved in the results if you click activate this feature.', + displayName: 'Environment ID', + name: 'environmentId', + type: 'string', displayOptions: { show: { - resource: [resource.value], - operation: ['get_entries'] - } - } - }, - { - displayName: 'Include', - name: 'include', - type: 'number', - default: 1, - placeholder: '', - description: - "When you have related content (e.g. entries with links to image assets) it's possible to include them in the results. Using the include parameter, you can specify the number of levels of entries to include in the results. A lower number might improve performance.", - typeOptions: { - minValue: 0, - maxValue: 10 + resource: [ + resource.value, + ], + operation: [ + 'get', + 'getAll', + ], + }, }, + default: 'master', + description: 'The id for the Contentful environment (e.g. master, staging, etc.). Depending on your plan, you might not have environments. In that case use "master".' + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', displayOptions: { show: { - resource: [resource.value], - operation: ['get_entries'], - resolve: [true] - } - } + operation: [ + 'getAll', + ], + resource: [ + resource.value, + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', }, - { - displayName: 'Entry Id', - name: 'entry_id', + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + resource.value, + ], + 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: [ + resource.value, + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Content Type ID', + name: 'content_type', + type: 'string', + default: '', + description: 'To search for entries with a specific content type', + }, + { + displayName: 'Equal', + name: 'equal', + type: 'string', + default: '', + placeholder: 'fields.title=n8n', + description: 'Search for all data that matches the condition: {attribute}={value}. Attribute can use dot notation.', + }, + { + displayName: 'Exclude', + name: 'exclude', + type: 'string', + default: '', + placeholder: 'fields.tags[nin]=accessories,flowers', + description: 'Search for all data that matches the condition: {attribute}[nin]={value}. Attribute can use dot notation.', + }, + { + displayName: 'Exist', + name: 'exist', + type: 'string', + default: '', + placeholder: 'fields.tags[exists]=true', + description: 'Search for all data that matches the condition: {attribute}[exists]={value}. Attribute can use dot notation.', + }, + { + displayName: 'Fields', + name: 'select', + type: 'string', + placeholder: 'fields.title', + default: '', + description: 'The select operator allows you to choose what fields to return from an entity. You can choose multiple values by combining comma separated operators.', + }, + { + displayName: 'Include', + name: 'include', + type: 'string', + default: '', + placeholder: 'fields.tags[in]=accessories,flowers', + description: 'Search for all data that matches the condition: {attribute}[in]={value}. Attribute can use dot notation.', + }, + { + displayName: 'Not Equal', + name: 'notEqual', + type: 'string', + default: '', + placeholder: 'fields.title[ne]=n8n', + description: 'Search for all data that matches the condition: {attribute}[ne]={value}. Attribute can use dot notation.', + }, + { + displayName: 'Order', + name: 'order', + type: 'string', + default: '', + placeholder: 'sys.createdAt', + description: 'You can order items in the response by specifying the order search parameter. You can use sys properties (such as sys.createdAt) or field values (such as fields.myCustomDateField) for ordering.', + }, + { + displayName: 'Query', + name: 'query', + type: 'string', + default: '', + description: ' Full-text search is case insensitive and might return more results than expected. A query will only take values with more than 1 character.', + }, + ], + }, + { + displayName: 'Entry ID', + name: 'entryId', type: 'string', default: '', - placeholder: '', - description: '', required: true, displayOptions: { show: { - resource: [resource.value], - operation: ['get_entry'] - } - } + resource: [ + resource.value, + ], + operation: [ + 'get', + ], + }, + }, } ] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Contentful/GenericFunctions.ts b/packages/nodes-base/nodes/Contentful/GenericFunctions.ts index 246c43c220..218a85cc71 100644 --- a/packages/nodes-base/nodes/Contentful/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Contentful/GenericFunctions.ts @@ -1,45 +1,75 @@ -import { IExecuteFunctions } from 'n8n-core'; -import { OptionsWithUrl } from 'request'; +import { + IExecuteFunctions, + IExecuteSingleFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; -/** - * @param {IExecuteFunctions} that Reference to the system's execute functions - * @param {string} endpoint? Endpoint of api call - * @param {string} environmentId? Id of contentful environment (eg. master, staging, etc.) - * @param {Record} qs? Query string, can be used for search parameters - */ -export const contentfulApiRequest = async ( - that: IExecuteFunctions, - endpoint?: string, - environmentId?: string, - qs?: Record -) => { - const credentials = that.getCredentials('contentfulDeliveryApi'); +import { + OptionsWithUri, +} from 'request'; + +import { + IDataObject, +} from 'n8n-workflow'; + +export async function contentfulApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const credentials = this.getCredentials('contentfulApi'); if (credentials === undefined) { throw new Error('No credentials got returned!'); } - const source = that.getNodeParameter('source', 0) as string; - const isPreview = source === 'preview_api'; - let accessToken = credentials.access_token as string; + const source = this.getNodeParameter('source', 0) as string; + const isPreview = source === 'previewApi'; + + const options: OptionsWithUri = { + method, + qs, + body, + uri: uri ||`https://${isPreview ? 'preview' : 'cdn'}.contentful.com${resource}`, + json: true + }; + if (isPreview) { - accessToken = credentials.access_token_preview as string; - console.log('accessToken', accessToken); - if (!accessToken) { - throw new Error('No access token for preview API set in credentials!'); - } + qs.access_token = credentials.ContentPreviewaccessToken as string; + } else { + qs.access_token = credentials.ContentDeliveryaccessToken as string; } - let url = `https://${isPreview ? 'preview' : 'cdn'}.contentful.com/spaces/${credentials.space_id}`; - if (environmentId) url = `${url}/environments/${environmentId}`; - if (endpoint) url = `${url}${endpoint}`; - qs = qs || {}; - qs.access_token = accessToken; + try { + return await this.helpers.request!(options); + } catch (error) { - const res = await that.helpers.request!({ - url, - method: 'GET', - qs - } as OptionsWithUrl); + let errorMessage = error; - return JSON.parse(res); -}; + if (error.response && error.response.body && error.response.body.details) { + const details = error.response.body.details; + errorMessage = details.errors.map((e: IDataObject) => e.details).join('|'); + } else if (error.response && error.response.body && error.response.body.message) { + errorMessage = error.response.body.message; + } + + throw new Error(`Contentful error response [${error.statusCode}]: ${errorMessage}`); + } + +} + +export async function contenfulApiRequestAllItems(this: ILoadOptionsFunctions | IExecuteFunctions, propertyName: string, method: string, resource: string, body: any = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any + + const returnData: IDataObject[] = []; + + let responseData; + + query.limit = 100; + query.skip = 0; + + do { + responseData = await contentfulApiRequest.call(this, method, resource, body, query); + query.skip = (query.skip + 1) * query.limit; + returnData.push.apply(returnData, responseData[propertyName]); + } while ( + returnData.length < responseData.total + ); + + return returnData; +} diff --git a/packages/nodes-base/nodes/Contentful/LocaleDescription.ts b/packages/nodes-base/nodes/Contentful/LocaleDescription.ts index 87e7f6b932..45f2872ad2 100644 --- a/packages/nodes-base/nodes/Contentful/LocaleDescription.ts +++ b/packages/nodes-base/nodes/Contentful/LocaleDescription.ts @@ -1,29 +1,94 @@ -import { INodeProperties, INodePropertyOptions } from "n8n-workflow"; +import { + INodeProperties, + INodePropertyOptions +} from 'n8n-workflow'; export const resource = { - name: "Locale", - value: "locale", + name: 'Locale', + value: 'locale', } as INodePropertyOptions; export const operations = [ - { - displayName: "Operation", - name: "operation", - type: "options", - displayOptions: { - show: { - resource: [resource.value], - }, - }, - options: [ - { - name: "Get Locales", - value: "get_locales", - }, - ], - default: "get_locales", - description: "The operation to perform.", - }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + resource.value, + ], + }, + }, + options: [ + { + name: 'Get All', + value: 'getAll', + }, + ], + default: 'getAll', + description: 'The operation to perform.', + }, ] as INodeProperties[]; -export const fields = [] as INodeProperties[]; +export const fields = [ + { + displayName: 'Environment ID', + name: 'environmentId', + type: 'string', + displayOptions: { + show: { + resource: [ + resource.value, + ], + operation: [ + 'get', + 'getAll', + ], + }, + }, + default: 'master', + description: 'The id for the Contentful environment (e.g. master, staging, etc.). Depending on your plan, you might not have environments. In that case use "master".' + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + resource.value, + ], + }, + }, + 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: [ + resource.value, + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return.', + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Contentful/SpaceDescription.ts b/packages/nodes-base/nodes/Contentful/SpaceDescription.ts index 2baabee099..035f3c6e57 100644 --- a/packages/nodes-base/nodes/Contentful/SpaceDescription.ts +++ b/packages/nodes-base/nodes/Contentful/SpaceDescription.ts @@ -1,29 +1,33 @@ -import { INodeProperties } from "n8n-workflow"; +import { + INodeProperties, +} from 'n8n-workflow'; export const resource = { - name: "Space", - value: "space", + name: 'Space', + value: 'space', }; export const operations = [ - { - displayName: "Operation", - name: "operation", - type: "options", - displayOptions: { - show: { - resource: [resource.value], - }, - }, - options: [ - { - name: "Get Space", - value: "get_space", - }, - ], - default: "get_space", - description: "The operation to perform.", - }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + resource.value + ], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + }, + ], + default: 'get', + description: 'The operation to perform.', + }, ] as INodeProperties[]; export const fields = [] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Contentful/resolveResponse.ts b/packages/nodes-base/nodes/Contentful/resolveResponse.ts deleted file mode 100644 index 6542d33595..0000000000 --- a/packages/nodes-base/nodes/Contentful/resolveResponse.ts +++ /dev/null @@ -1,156 +0,0 @@ -// @ts-nocheck -// Code from https://github.com/contentful/contentful-resolve-response/blob/master/index.js -import { cloneDeep } from 'lodash'; - -const UNRESOLVED_LINK = {}; // unique object to avoid polyfill bloat using Symbol() - -/** - * isLink Function - * Checks if the object has sys.type "Link" - * @param object - */ -const isLink = (object: { sys: { type: string } }) => - object && object.sys && object.sys.type === 'Link'; - -/** - * findNormalizableLinkInArray - * - * @param array - * @param predicate - * @return {*} - */ -const findNormalizableLinkInArray = (array, predicate) => { - for (let i = 0, len = array.length; i < len; i++) { - if (predicate(array[i])) { - return array[i]; - } - } - return UNRESOLVED_LINK; -}; - -/** - * getLink Function - * - * @param response - * @param link - * @return {undefined} - */ -const getLink = (allEntries, link) => { - const { linkType: type, id } = link.sys; - - const predicate = ({ sys }) => sys.type === type && sys.id === id; - - return findNormalizableLinkInArray(allEntries, predicate); -}; - -/** - * cleanUpLinks Function - * - Removes unresolvable links from Arrays and Objects - * - * @param {Object[]|Object} input - */ -const cleanUpLinks = input => { - if (Array.isArray(input)) { - return input.filter(val => val !== UNRESOLVED_LINK); - } - for (const key in input) { - if (input[key] === UNRESOLVED_LINK) { - delete input[key]; - } - } - return input; -}; - -/** - * walkMutate Function - * @param input - * @param predicate - * @param mutator - * @return {*} - */ -const walkMutate = (input, predicate, mutator, removeUnresolved) => { - if (predicate(input)) { - return mutator(input); - } - - if (input && typeof input === 'object') { - for (const key in input) { - if (input.hasOwnProperty(key)) { - input[key] = walkMutate( - input[key], - predicate, - mutator, - removeUnresolved - ); - } - } - if (removeUnresolved) { - input = cleanUpLinks(input); - } - } - return input; -}; - -const normalizeLink = (allEntries, link, removeUnresolved) => { - const resolvedLink = getLink(allEntries, link); - if (resolvedLink === UNRESOLVED_LINK) { - return removeUnresolved ? resolvedLink : link; - } - return resolvedLink; -}; - -const makeEntryObject = (item, itemEntryPoints) => { - if (!Array.isArray(itemEntryPoints)) { - return item; - } - - const entryPoints = Object.keys(item).filter( - ownKey => itemEntryPoints.indexOf(ownKey) !== -1 - ); - - return entryPoints.reduce((entryObj, entryPoint) => { - entryObj[entryPoint] = item[entryPoint]; - return entryObj; - }, {}); -}; - -/** - * resolveResponse Function - * Resolves contentful response to normalized form. - * @param {Object} response Contentful response - * @param {Object} options - * @param {Boolean} options.removeUnresolved - Remove unresolved links default:false - * @param {Array} options.itemEntryPoints - Resolve links only in those item properties - * @return {Object} - */ -const resolveResponse = (response, options) => { - options = options || {}; - if (!response.items) { - return []; - } - const responseClone = cloneDeep(response); - const allIncludes = Object.keys(responseClone.includes || {}).reduce( - (all, type) => [...all, ...response.includes[type]], - [] - ); - - const allEntries = [...responseClone.items, ...allIncludes]; - - allEntries.forEach(item => { - const entryObject = makeEntryObject(item, options.itemEntryPoints); - - Object.assign( - item, - walkMutate( - entryObject, - isLink, - link => normalizeLink(allEntries, link, options.removeUnresolved), - options.removeUnresolved - ) - ); - }); - - return responseClone.items; -}; - -export default resolveResponse; diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 45b703b9d8..fed1f13606 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -43,7 +43,7 @@ "dist/credentials/ClockifyApi.credentials.js", "dist/credentials/CockpitApi.credentials.js", "dist/credentials/CodaApi.credentials.js", - "dist/credentials/ContentfulDeliveryApi.credentials.js", + "dist/credentials/ContentfulApi.credentials.js", "dist/credentials/CopperApi.credentials.js", "dist/credentials/CalendlyApi.credentials.js", "dist/credentials/DisqusApi.credentials.js", From 823ae846bfae1ca29b91dd002dadb9075682568d Mon Sep 17 00:00:00 2001 From: Denis Palashevskii Date: Wed, 5 Aug 2020 00:34:22 +0400 Subject: [PATCH 0014/1150] 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 0015/1150] 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 0016/1150] 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 9b15cf50bc1b7e719417a8fa9b8e693bb71f8d84 Mon Sep 17 00:00:00 2001 From: ricardo Date: Wed, 5 Aug 2020 21:55:53 -0400 Subject: [PATCH 0017/1150] :zap: Improvements to Converkit-Node --- .../nodes/ConvertKit/ConvertKit.node.ts | 507 ++++++++++++------ ...scription.ts => CustomFieldDescription.ts} | 61 ++- .../nodes/ConvertKit/FormDescription.ts | 74 ++- .../nodes/ConvertKit/GenericFunctions.ts | 20 +- .../nodes/ConvertKit/SequenceDescription.ts | 82 ++- .../nodes/ConvertKit/TagDescription.ts | 160 +----- .../ConvertKit/TagSubscriberDescription.ts | 219 ++++++++ 7 files changed, 794 insertions(+), 329 deletions(-) rename packages/nodes-base/nodes/ConvertKit/{FieldDescription.ts => CustomFieldDescription.ts} (52%) create mode 100644 packages/nodes-base/nodes/ConvertKit/TagSubscriberDescription.ts diff --git a/packages/nodes-base/nodes/ConvertKit/ConvertKit.node.ts b/packages/nodes-base/nodes/ConvertKit/ConvertKit.node.ts index 71f58a4172..8c9870639e 100644 --- a/packages/nodes-base/nodes/ConvertKit/ConvertKit.node.ts +++ b/packages/nodes-base/nodes/ConvertKit/ConvertKit.node.ts @@ -1,5 +1,6 @@ import { IExecuteFunctions, + ILoadOptionsFunctions, } from 'n8n-core'; import { @@ -7,6 +8,7 @@ import { INodeExecutionData, INodeTypeDescription, INodeType, + INodePropertyOptions, } from 'n8n-workflow'; import { @@ -14,9 +16,9 @@ import { } from './GenericFunctions'; import { - fieldOperations, - fieldFields, -} from './FieldDescription'; + customFieldOperations, + customFieldFields, +} from './CustomFieldDescription'; import { formOperations, @@ -33,6 +35,11 @@ import { tagFields, } from './TagDescription'; +import { + tagSubscriberOperations, + tagSubscriberFields, +} from './TagSubscriberDescription'; + export class ConvertKit implements INodeType { description: INodeTypeDescription = { displayName: 'ConvertKit', @@ -61,8 +68,8 @@ export class ConvertKit implements INodeType { type: 'options', options: [ { - name: 'Field', - value: 'field', + name: 'Custom Field', + value: 'customField', }, { name: 'Form', @@ -76,15 +83,19 @@ export class ConvertKit implements INodeType { name: 'Tag', value: 'tag', }, + { + name: 'Tag Subscriber', + value: 'tagSubscriber', + }, ], - default: 'field', + default: 'customField', description: 'The resource to operate on.' }, //-------------------- // Field Description //-------------------- - ...fieldOperations, - ...fieldFields, + ...customFieldOperations, + ...customFieldFields, //-------------------- // FormDescription //-------------------- @@ -100,187 +111,373 @@ export class ConvertKit implements INodeType { //-------------------- ...tagOperations, ...tagFields, + //-------------------- + // Tag Subscriber Description + //-------------------- + ...tagSubscriberOperations, + ...tagSubscriberFields, ], }; + methods = { + loadOptions: { + // Get all the tags to display them to user so that he can + // select them easily + async getTags(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const { tags } = await convertKitApiRequest.call(this, 'GET', '/tags'); + for (const tag of tags) { + const tagName = tag.name; + const tagId = tag.id; + returnData.push({ + name: tagName, + value: tagId, + }); + } + + return returnData; + }, + // Get all the forms to display them to user so that he can + // select them easily + async getForms(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const { forms } = await convertKitApiRequest.call(this, 'GET', '/forms'); + for (const form of forms) { + const formName = form.name; + const formId = form.id; + returnData.push({ + name: formName, + value: formId, + }); + } + + return returnData; + }, + + // Get all the sequences to display them to user so that he can + // select them easily + async getSequences(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const { courses } = await convertKitApiRequest.call(this, 'GET', '/sequences'); + for (const course of courses) { + const courseName = course.name; + const courseId = course.id; + returnData.push({ + name: courseName, + value: courseId, + }); + } + + return returnData; + }, + } + }; + async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); const returnData: IDataObject[] = []; - let method = ''; - let endpoint = ''; const qs: IDataObject = {}; let responseData; const resource = this.getNodeParameter('resource', 0) as string; const operation = this.getNodeParameter('operation', 0) as string; - const fullOperation = `${resource}/${operation}`; - for (let i = 0; i < items.length; i++) { - //-------------------- - // Field Operations - //-------------------- - if(resource === 'field') { - //--------- - // Update - //--------- - if(operation === 'update') { - qs.label = this.getNodeParameter('label', i) as string; + + if (resource === 'customField') { + if (operation === 'create') { + + const label = this.getNodeParameter('label', i) as string; + + responseData = await convertKitApiRequest.call(this, 'POST', '/custom_fields', { label }, qs); + } + if (operation === 'delete') { const id = this.getNodeParameter('id', i) as string; - method = 'PUT'; - endpoint = `/custom_fields/${id}`; - //--------- - // Get All - //--------- - } else if(operation === 'getAll') { - method = 'GET'; - endpoint = '/custom_fields'; - //--------- - // Create - //--------- - } else if(operation === 'create') { - qs.label = this.getNodeParameter('label', i) as string; + responseData = await convertKitApiRequest.call(this, 'DELETE', `/custom_fields/${id}`); + } + if (operation === 'get') { - method = 'POST'; - endpoint = '/custom_fields'; - //--------- - // Delete - //--------- - } else if(operation === 'delete') { const id = this.getNodeParameter('id', i) as string; - method = 'DELETE'; - endpoint = `/custom_fields/${id}`; - } else { - throw new Error(`The operation "${operation}" is not known!`); + responseData = await convertKitApiRequest.call(this, 'GET', `/custom_fields/${id}`); } - //-------------------------------------------- - // Form, Sequence, and Tag Operations - //-------------------------------------------- - } else if(['form', 'sequence', 'tag'].includes(resource)) { - //----------------- - // Add Subscriber - //----------------- - if(operation === 'addSubscriber') { - qs.email= this.getNodeParameter('email', i) as string; - const id = this.getNodeParameter('id', i); + if (operation === 'getAll') { - const additionalParams = this.getNodeParameter('additionalFields', 0) as IDataObject; + const returnAll = this.getNodeParameter('returnAll', i) as boolean; - if(additionalParams.firstName) { - qs.first_name = additionalParams.firstName; + responseData = await convertKitApiRequest.call(this, 'GET', `/custom_fields`); + + responseData = responseData.custom_fields; + + if (!returnAll) { + + const limit = this.getNodeParameter('limit', i) as number; + + responseData = responseData.slice(0, limit); } - - if(additionalParams.fields !== undefined) { - const fields = {} as IDataObject; - const fieldsParams = additionalParams.fields as IDataObject; - const field = fieldsParams?.field as IDataObject[]; - - for(let j = 0; j < field.length; j++) { - const key = field[j].key as string; - const value = field[j].value as string; - - fields[key] = value; - } - - qs.fields = fields; - } - - if(resource === 'form') { - method = 'POST'; - endpoint = `/forms/${id}/subscribe`; - } else if(resource === 'sequence') { - method = 'POST'; - endpoint = `/sequences/${id}/subscribe`; - } else if(resource === 'tag') { - method = 'POST'; - endpoint = `/tags/${id}/subscribe`; - } - //----------------- - // Get All - //----------------- - } else if(operation === 'getAll') { - method = 'GET'; - if(resource === 'form') { - endpoint = '/forms'; - } else if(resource === 'tag') { - endpoint = '/tags'; - } else if(resource === 'sequence') { - endpoint = '/sequences'; - } - //-------------------- - // Get Subscriptions - //-------------------- - } else if(operation === 'getSubscriptions') { - const id = this.getNodeParameter('id', i); - const additionalParams = this.getNodeParameter('additionalFields', 0) as IDataObject; - if(additionalParams.subscriberState) { - qs.subscriber_state = additionalParams.subscriberState; - } - - method = 'GET'; - if(resource === 'form') { - endpoint = `/forms/${id}/subscriptions`; - } else if(resource === 'tag') { - endpoint = `/tags/${id}/subscriptions`; - } else if(resource === 'sequence') { - endpoint = `/sequences/${id}/subscriptions`; - } - //------------ - // Create Tag - //------------ - } else if(operation === 'create') { - const name = this.getNodeParameter('name', i); - qs.tag = { name, }; - - method = 'POST'; - endpoint = '/tags'; - //------------ - // Remove Tag - //------------ - } else if(operation === 'removeSubscriber') { - const id = this.getNodeParameter('id', i); - - qs.email = this.getNodeParameter('email', i); - - method = 'POST'; - endpoint = `/tags/${id}/unsubscribe`; - } else { - throw new Error(`The operation "${operation}" is not known!`); } - } else { - throw new Error(`The resource "${resource}" is not known!`); + if (operation === 'update') { + + const id = this.getNodeParameter('id', i) as string; + + const label = this.getNodeParameter('label', i) as string; + + responseData = await convertKitApiRequest.call(this, 'PUT', `/custom_fields/${id}`, { label }); + + responseData = { success: true }; + } } - responseData = await convertKitApiRequest.call(this, method, endpoint, {}, qs); + if (resource === 'form') { + if (operation === 'addSubscriber') { - if(fullOperation === 'field/getAll') { - responseData = responseData.custom_fields; - } else if(['form/addSubscriber', 'tag/addSubscriber', 'sequence/addSubscriber'].includes(fullOperation)) { - responseData = responseData.subscription; - } else if(fullOperation === 'form/getAll') { - responseData = responseData.forms; - } else if(['form/getSubscriptions', 'tag/getSubscriptions'].includes(fullOperation)) { - responseData = responseData.subscriptions; - } else if(fullOperation === 'tag/getAll') { - responseData = responseData.tags; - } else if(fullOperation === 'sequence/getAll') { - responseData = responseData.courses; + const email = this.getNodeParameter('email', i) as string; + + const formId = this.getNodeParameter('id', i) as string; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + const body: IDataObject = { + email, + }; + + if (additionalFields.firstName) { + body.first_name = additionalFields.firstName as string; + } + + if (additionalFields.tags) { + body.tags = additionalFields.tags as string[]; + } + + if (additionalFields.fieldsUi) { + const fieldValues = (additionalFields.fieldsUi as IDataObject).fieldsValues as IDataObject[]; + if (fieldValues) { + body.fields = {}; + for (const fieldValue of fieldValues) { + //@ts-ignore + body.fields[fieldValue.key] = fieldValue.value; + } + } + } + + const { subscription } = await convertKitApiRequest.call(this, 'POST', `/forms/${formId}/subscribe`, body); + + responseData = subscription; + } + if (operation === 'getAll') { + + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + responseData = await convertKitApiRequest.call(this, 'GET', `/forms`); + + responseData = responseData.forms; + + if (!returnAll) { + + const limit = this.getNodeParameter('limit', i) as number; + + responseData = responseData.slice(0, limit); + } + } + if (operation === 'getSubscriptions') { + + const formId = this.getNodeParameter('id', i) as string; + + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (additionalFields.subscriberState) { + qs.subscriber_state = additionalFields.subscriberState as string; + } + + responseData = await convertKitApiRequest.call(this, 'GET', `/forms/${formId}/subscriptions`, {}, qs); + + responseData = responseData.subscriptions; + + if (!returnAll) { + + const limit = this.getNodeParameter('limit', i) as number; + + responseData = responseData.slice(0, limit); + } + } + } + + if (resource === 'sequence') { + if (operation === 'addSubscriber') { + + const email = this.getNodeParameter('email', i) as string; + + const sequenceId = this.getNodeParameter('id', i) as string; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + const body: IDataObject = { + email, + }; + + if (additionalFields.firstName) { + body.first_name = additionalFields.firstName as string; + } + + if (additionalFields.tags) { + body.tags = additionalFields.tags as string[]; + } + + if (additionalFields.fieldsUi) { + const fieldValues = (additionalFields.fieldsUi as IDataObject).fieldsValues as IDataObject[]; + if (fieldValues) { + body.fields = {}; + for (const fieldValue of fieldValues) { + //@ts-ignore + body.fields[fieldValue.key] = fieldValue.value; + } + } + } + + const { subscription } = await convertKitApiRequest.call(this, 'POST', `/sequences/${sequenceId}/subscribe`, body); + + responseData = subscription; + } + if (operation === 'getAll') { + + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + responseData = await convertKitApiRequest.call(this, 'GET', `/sequences`); + + responseData = responseData.courses; + + if (!returnAll) { + + const limit = this.getNodeParameter('limit', i) as number; + + responseData = responseData.slice(0, limit); + } + } + if (operation === 'getSubscriptions') { + + const sequenceId = this.getNodeParameter('id', i) as string; + + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + if (additionalFields.subscriberState) { + qs.subscriber_state = additionalFields.subscriberState as string; + } + + responseData = await convertKitApiRequest.call(this, 'GET', `/sequences/${sequenceId}/subscriptions`, {}, qs); + + responseData = responseData.subscriptions; + + if (!returnAll) { + + const limit = this.getNodeParameter('limit', i) as number; + + responseData = responseData.slice(0, limit); + } + } + } + + if (resource === 'tag') { + if (operation === 'create') { + + const names = ((this.getNodeParameter('name', i) as string).split(',') as string[]).map((e) => ({ name: e })); + + const body: IDataObject = { + tag: names + }; + + responseData = await convertKitApiRequest.call(this, 'POST', '/tags', body); + } + + if (operation === 'getAll') { + + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + responseData = await convertKitApiRequest.call(this, 'GET', `/tags`); + + responseData = responseData.tags; + + if (!returnAll) { + + const limit = this.getNodeParameter('limit', i) as number; + + responseData = responseData.slice(0, limit); + } + } + } + + if (resource === 'tagSubscriber') { + + if (operation === 'add') { + + const tagId = this.getNodeParameter('tagId', i) as string; + + const email = this.getNodeParameter('email', i) as string; + + const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject; + + const body: IDataObject = { + email, + }; + + if (additionalFields.firstName) { + body.first_name = additionalFields.firstName as string; + } + + if (additionalFields.fieldsUi) { + const fieldValues = (additionalFields.fieldsUi as IDataObject).fieldsValues as IDataObject[]; + if (fieldValues) { + body.fields = {}; + for (const fieldValue of fieldValues) { + //@ts-ignore + body.fields[fieldValue.key] = fieldValue.value; + } + } + } + + const { subscription } = await convertKitApiRequest.call(this, 'POST', `/tags/${tagId}/subscribe`, body); + + responseData = subscription; + } + + if (operation === 'getAll') { + + const tagId = this.getNodeParameter('tagId', i) as string; + + const returnAll = this.getNodeParameter('returnAll', i) as boolean; + + responseData = await convertKitApiRequest.call(this, 'GET', `/tags/${tagId}/subscriptions`); + + responseData = responseData.subscriptions; + + if (!returnAll) { + + const limit = this.getNodeParameter('limit', i) as number; + + responseData = responseData.slice(0, limit); + } + } + + if (operation === 'delete') { + + const tagId = this.getNodeParameter('tagId', i) as string; + + const email = this.getNodeParameter('email', i) as string; + + responseData= await convertKitApiRequest.call(this, 'POST', `/tags/${tagId}>/unsubscribe`, { email }); + } } if (Array.isArray(responseData)) { returnData.push.apply(returnData, responseData as IDataObject[]); } else if (responseData !== undefined) { returnData.push(responseData as IDataObject); - } else { - if(method === 'GET') { - returnData.push( { } ); - } else { - returnData.push( { success: true } ); - } } } diff --git a/packages/nodes-base/nodes/ConvertKit/FieldDescription.ts b/packages/nodes-base/nodes/ConvertKit/CustomFieldDescription.ts similarity index 52% rename from packages/nodes-base/nodes/ConvertKit/FieldDescription.ts rename to packages/nodes-base/nodes/ConvertKit/CustomFieldDescription.ts index 8966e60efa..a8541c6493 100644 --- a/packages/nodes-base/nodes/ConvertKit/FieldDescription.ts +++ b/packages/nodes-base/nodes/ConvertKit/CustomFieldDescription.ts @@ -1,8 +1,8 @@ import { - INodeProperties + INodeProperties, } from 'n8n-workflow'; -export const fieldOperations = [ +export const customFieldOperations = [ { displayName: 'Operation', name: 'operation', @@ -10,7 +10,7 @@ export const fieldOperations = [ displayOptions: { show: { resource: [ - 'field', + 'customField', ], }, }, @@ -18,22 +18,22 @@ export const fieldOperations = [ { name: 'Create', value: 'create', - description: 'Create a field.', + description: 'Create a field', }, { name: 'Delete', value: 'delete', - description: 'Delete a field.', + description: 'Delete a field', }, { name: 'Get All', value: 'getAll', - description: `List all of your account's custom fields.`, + description: 'Get all fields', }, { name: 'Update', value: 'update', - description: 'Update a field.', + description: 'Update a field', }, ], default: 'update', @@ -41,7 +41,7 @@ export const fieldOperations = [ }, ] as INodeProperties[]; -export const fieldFields = [ +export const customFieldFields = [ { displayName: 'Field ID', name: 'id', @@ -50,7 +50,7 @@ export const fieldFields = [ displayOptions: { show: { resource: [ - 'field', + 'customField', ], operation: [ 'update', @@ -69,7 +69,7 @@ export const fieldFields = [ displayOptions: { show: { resource: [ - 'field', + 'customField', ], operation: [ 'update', @@ -80,4 +80,45 @@ export const fieldFields = [ default: '', description: 'The label of the custom field.', }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'customField', + ], + }, + }, + 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: [ + 'customField', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return.', + }, ] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/ConvertKit/FormDescription.ts b/packages/nodes-base/nodes/ConvertKit/FormDescription.ts index 3e266d18a5..f6e1a6f3e5 100644 --- a/packages/nodes-base/nodes/ConvertKit/FormDescription.ts +++ b/packages/nodes-base/nodes/ConvertKit/FormDescription.ts @@ -18,17 +18,17 @@ export const formOperations = [ { name: 'Add Subscriber', value: 'addSubscriber', - description: 'Add a subscriber.', + description: 'Add a subscriber', }, { name: 'Get All', value: 'getAll', - description: 'Get a list of all the forms for your account.', + description: 'Get all forms', }, { name: 'Get Subscriptions', value: 'getSubscriptions', - description: 'List subscriptions to a form including subscriber data.', + description: 'List subscriptions to a form including subscriber data', }, ], default: 'addSubscriber', @@ -38,7 +38,7 @@ export const formOperations = [ export const formFields = [ { - displayName: 'Email Address', + displayName: 'Email', name: 'email', type: 'string', required: true, @@ -58,7 +58,10 @@ export const formFields = [ { displayName: 'Form ID', name: 'id', - type: 'string', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getForms', + }, required: true, displayOptions: { show: { @@ -91,16 +94,9 @@ export const formFields = [ }, }, options: [ - { - displayName: 'First Name', - name: 'firstName', - type: 'string', - default: '', - description: `The subscriber's first name.`, - }, { displayName: 'Custom Fields', - name: 'fields', + name: 'fieldsUi', placeholder: 'Add Custom Field', description: 'Object of key/value pairs for custom fields (the custom field must exist before you can use it here).', type: 'fixedCollection', @@ -110,7 +106,7 @@ export const formFields = [ default: {}, options: [ { - name: 'field', + name: 'fieldsValues', displayName: 'Custom Field', values: [ { @@ -133,8 +129,58 @@ export const formFields = [ }, ], }, + { + displayName: 'First Name', + name: 'firstName', + type: 'string', + default: '', + description: `The subscriber's first name.`, + }, ], }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + 'getSubscriptions', + ], + resource: [ + 'form', + ], + }, + }, + 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', + 'getSubscriptions', + ], + resource: [ + 'form', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return.', + }, { displayName: 'Additional Fields', name: 'additionalFields', diff --git a/packages/nodes-base/nodes/ConvertKit/GenericFunctions.ts b/packages/nodes-base/nodes/ConvertKit/GenericFunctions.ts index 525aed043a..4844045e35 100644 --- a/packages/nodes-base/nodes/ConvertKit/GenericFunctions.ts +++ b/packages/nodes-base/nodes/ConvertKit/GenericFunctions.ts @@ -15,7 +15,9 @@ import { export async function convertKitApiRequest(this: IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions | IHookFunctions, method: string, endpoint: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any - const credentials = this.getCredentials('convertKitApi'); + + const credentials = this.getCredentials('convertKitApi'); + if (credentials === undefined) { throw new Error('No credentials got returned!'); } @@ -30,15 +32,29 @@ export async function convertKitApiRequest(this: IExecuteFunctions | IExecuteSin uri: uri ||`https://api.convertkit.com/v3${endpoint}`, json: true, }; + options = Object.assign({}, options, option); + if (Object.keys(options.body).length === 0) { delete options.body; } + + console.log(options); + try { + qs.api_secret = credentials.apiSecret; return await this.helpers.request!(options); + } catch (error) { - throw new Error(`ConvertKit error response: ${error.message}`); + + let errorMessage = error; + + if (error.response && error.response.body && error.response.body.message) { + errorMessage = error.response.body.message; + } + + throw new Error(`ConvertKit error response: ${errorMessage}`); } } diff --git a/packages/nodes-base/nodes/ConvertKit/SequenceDescription.ts b/packages/nodes-base/nodes/ConvertKit/SequenceDescription.ts index 1b82b741fa..493b713cda 100644 --- a/packages/nodes-base/nodes/ConvertKit/SequenceDescription.ts +++ b/packages/nodes-base/nodes/ConvertKit/SequenceDescription.ts @@ -1,5 +1,5 @@ import { - INodeProperties + INodeProperties, } from 'n8n-workflow'; export const sequenceOperations = [ @@ -23,7 +23,7 @@ export const sequenceOperations = [ { name: 'Get All', value: 'getAll', - description: 'Returns a list of sequences for the account.', + description: 'Get all sequences.', }, { name: 'Get Subscriptions', @@ -38,7 +38,7 @@ export const sequenceOperations = [ export const sequenceFields = [ { - displayName: 'Email Address', + displayName: 'Email', name: 'email', type: 'string', required: true, @@ -58,7 +58,10 @@ export const sequenceFields = [ { displayName: 'Sequence ID', name: 'id', - type: 'string', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getSequences', + }, required: true, displayOptions: { show: { @@ -74,6 +77,49 @@ export const sequenceFields = [ default: '', description: 'Sequence ID.', }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + 'getSubscriptions', + ], + resource: [ + 'sequence', + ], + }, + }, + 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', + 'getSubscriptions', + ], + resource: [ + 'sequence', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 500, + }, + default: 100, + description: 'How many results to return.', + }, { displayName: 'Additional Fields', name: 'additionalFields', @@ -91,16 +137,9 @@ export const sequenceFields = [ }, }, options: [ - { - displayName: 'First Name', - name: 'firstName', - type: 'string', - default: '', - description: `The subscriber's first name.`, - }, { displayName: 'Custom Fields', - name: 'fields', + name: 'fieldsUi', placeholder: 'Add Custom Field', description: 'Object of key/value pairs for custom fields (the custom field must exist before you can use it here).', type: 'fixedCollection', @@ -110,7 +149,7 @@ export const sequenceFields = [ default: {}, options: [ { - name: 'field', + name: 'fieldsValues', displayName: 'Custom Field', values: [ { @@ -133,6 +172,23 @@ export const sequenceFields = [ }, ], }, + { + displayName: 'First Name', + name: 'firstName', + type: 'string', + default: '', + description: `The subscriber's first name.`, + }, + { + displayName: 'Tag IDs', + name: 'tags', + type: 'multiOptions', + typeOptions: { + loadOptionsMethod: 'getTags', + }, + default: [], + description: 'Tags', + }, ], }, { diff --git a/packages/nodes-base/nodes/ConvertKit/TagDescription.ts b/packages/nodes-base/nodes/ConvertKit/TagDescription.ts index a1a718ba3c..b0d18adcfc 100644 --- a/packages/nodes-base/nodes/ConvertKit/TagDescription.ts +++ b/packages/nodes-base/nodes/ConvertKit/TagDescription.ts @@ -1,5 +1,5 @@ import { - INodeProperties + INodeProperties, } from 'n8n-workflow'; export const tagOperations = [ @@ -18,27 +18,12 @@ export const tagOperations = [ { name: 'Create', value: 'create', - description: 'Create a tag.', + description: 'Create a tag', }, { name: 'Get All', value: 'getAll', - description: 'Returns a list of tags for the account.', - }, - { - name: 'Get Subscriptions', - value: 'getSubscriptions', - description: 'List subscriptions to a tag including subscriber data.', - }, - { - name: 'Remove Subscriber', - value: 'removeSubscriber', - description: 'Remove a tag from a subscriber.', - }, - { - name: 'Add Subscriber', - value: 'addSubscriber', - description: 'Add a tag to a subscriber.', + description: 'Get all tags', }, ], default: 'create', @@ -63,142 +48,47 @@ export const tagFields = [ }, }, default: '', - description: 'Tag name.', + description: 'Tag name, multiple can be added separated by comma', }, { - displayName: 'Email Address', - name: 'email', - type: 'string', - required: true, + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', displayOptions: { show: { + operation: [ + 'getAll', + ], resource: [ 'tag', ], - operation: [ - 'addSubscriber', - 'removeSubscriber', - ], }, }, - default: '', - description: 'Subscriber email address.', + default: false, + description: 'If all results should be returned or only up to a given limit.', }, { - displayName: 'Tag ID', - name: 'id', - type: 'string', - required: true, + displayName: 'Limit', + name: 'limit', + type: 'number', displayOptions: { show: { + operation: [ + 'getAll', + ], resource: [ 'tag', ], - operation: [ - 'addSubscriber', - 'removeSubscriber', - 'getSubscriptions', + returnAll: [ + false, ], }, }, - default: '', - description: 'Tag ID.', - }, - { - displayName: 'Additional Fields', - name: 'additionalFields', - type: 'collection', - placeholder: 'Add Field', - default: {}, - displayOptions: { - show: { - resource: [ - 'tag', - ], - operation: [ - 'addSubscriber', - ], - }, + typeOptions: { + minValue: 1, + maxValue: 500, }, - options: [ - { - displayName: 'First Name', - name: 'firstName', - type: 'string', - default: '', - description: 'Subscriber first name.', - }, - { - displayName: 'Custom Fields', - name: 'fields', - placeholder: 'Add Custom Field', - description: 'Object of key/value pairs for custom fields (the custom field must exist before you can use it here).', - type: 'fixedCollection', - typeOptions: { - multipleValues: true, - }, - default: {}, - options: [ - { - name: 'field', - displayName: 'Custom Field', - values: [ - { - displayName: 'Field Key', - name: 'key', - type: 'string', - default: '', - placeholder: 'last_name', - description: `The field's key.`, - }, - { - displayName: 'Field Value', - name: 'value', - type: 'string', - default: '', - placeholder: 'Doe', - description: 'Value of the field.', - }, - ], - }, - ], - }, - ], - }, - { - displayName: 'Additional Fields', - name: 'additionalFields', - type: 'collection', - placeholder: 'Add Field', - default: {}, - displayOptions: { - show: { - resource: [ - 'tag', - ], - operation: [ - 'getSubscriptions', - ], - }, - }, - options: [ - { - displayName: 'Subscriber State', - name: 'subscriberState', - type: 'options', - options: [ - { - name: 'Active', - value: 'active', - }, - { - name: 'Cancelled', - value: 'cancelled', - }, - ], - default: 'active', - }, - ], - description: 'Receive only active subscribers or cancelled subscribers.', + default: 100, + description: 'How many results to return.', }, ] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/ConvertKit/TagSubscriberDescription.ts b/packages/nodes-base/nodes/ConvertKit/TagSubscriberDescription.ts new file mode 100644 index 0000000000..f625dfed83 --- /dev/null +++ b/packages/nodes-base/nodes/ConvertKit/TagSubscriberDescription.ts @@ -0,0 +1,219 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const tagSubscriberOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: [ + 'tagSubscriber', + ], + }, + }, + options: [ + { + name: 'Add', + value: 'add', + description: 'Add a tag to a subscriber', + }, + { + name: 'Get All', + value: 'getAll', + description: 'List subscriptions to a tag including subscriber data', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete a tag from a subscriber', + }, + ], + default: 'create', + description: 'The operation to perform.', + }, +] as INodeProperties[]; + +export const tagSubscriberFields = [ + { + displayName: 'Tag ID', + name: 'tagId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTags', + }, + required: true, + displayOptions: { + show: { + resource: [ + 'tagSubscriber', + ], + operation: [ + 'add', + 'getAll', + 'delete', + ], + }, + }, + default: '', + }, + { + displayName: 'Email', + name: 'email', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'tagSubscriber', + ], + operation: [ + 'add', + 'delete', + ], + }, + }, + default: '', + description: 'Subscriber email address.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + displayOptions: { + show: { + resource: [ + 'tagSubscriber', + ], + operation: [ + 'add', + ], + }, + }, + options: [ + { + displayName: 'Custom Fields', + name: 'fields', + placeholder: 'Add Custom Field', + description: 'Object of key/value pairs for custom fields (the custom field must exist before you can use it here).', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + options: [ + { + name: 'field', + displayName: 'Custom Field', + values: [ + { + displayName: 'Field Key', + name: 'key', + type: 'string', + default: '', + placeholder: 'last_name', + description: `The field's key.`, + }, + { + displayName: 'Field Value', + name: 'value', + type: 'string', + default: '', + placeholder: 'Doe', + description: 'Value of the field.', + }, + ], + }, + ], + }, + { + displayName: 'First Name', + name: 'firstName', + type: 'string', + default: '', + description: 'Subscriber first name.', + }, + ], + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'tagSubscriber', + ], + }, + }, + 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: [ + 'tagSubscriber', + ], + 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: [ + 'tagSubscriber', + ], + operation: [ + 'getAll', + ], + }, + }, + options: [ + { + displayName: 'Subscriber State', + name: 'subscriberState', + type: 'options', + options: [ + { + name: 'Active', + value: 'active', + }, + { + name: 'Cancelled', + value: 'cancelled', + }, + ], + default: 'active', + }, + ], + description: 'Receive only active subscribers or cancelled subscribers.', + }, +] as INodeProperties[]; From 833001a9e46055fa3a805a390cb90c68b56c5e1a Mon Sep 17 00:00:00 2001 From: ricardo Date: Thu, 6 Aug 2020 13:33:43 -0400 Subject: [PATCH 0018/1150] :zap: Improvements to ConvertKit-Trigger --- .../ConvertKit/ConvertKitTrigger.node.ts | 274 ++++++++++++++++-- .../nodes/ConvertKit/GenericFunctions.ts | 13 +- .../nodes/ConvertKit/SequenceDescription.ts | 6 +- 3 files changed, 255 insertions(+), 38 deletions(-) diff --git a/packages/nodes-base/nodes/ConvertKit/ConvertKitTrigger.node.ts b/packages/nodes-base/nodes/ConvertKit/ConvertKitTrigger.node.ts index 5b1c6c2274..cb5bdbeb2c 100644 --- a/packages/nodes-base/nodes/ConvertKit/ConvertKitTrigger.node.ts +++ b/packages/nodes-base/nodes/ConvertKit/ConvertKitTrigger.node.ts @@ -8,12 +8,17 @@ import { INodeTypeDescription, INodeType, IWebhookResponseData, + ILoadOptionsFunctions, + INodePropertyOptions, } from 'n8n-workflow'; import { convertKitApiRequest, } from './GenericFunctions'; +import { + snakeCase, +} from 'change-case'; export class ConvertKitTrigger implements INodeType { description: INodeTypeDescription = { @@ -34,7 +39,7 @@ export class ConvertKitTrigger implements INodeType { { name: 'convertKitApi', required: true, - } + }, ], webhooks: [ { @@ -50,21 +55,86 @@ export class ConvertKitTrigger implements INodeType { name: 'event', type: 'options', required: true, - default: 'subscriberActivated', + default: '', description: 'The events that can trigger the webhook and whether they are enabled.', options: [ { - name: 'Subscriber Activated', - value: 'subscriberActivated', - description: 'Whether the webhook is triggered when a subscriber is activated.', + name: 'Form Subscribe', + value: 'formSubscribe', }, { - name: 'Link Clicked', - value: 'linkClicked', - description: 'Whether the webhook is triggered when a link is clicked.', + name: 'Link Click', + value: 'linkClick', + }, + { + name: 'Product Purchase', + value: 'productPurchase', + }, + { + name: 'Purchase Created', + value: 'purchaseCreate', + }, + { + name: 'Sequence Complete', + value: 'courseComplete', + }, + { + name: 'Sequence Subscribe', + value: 'courseSubscribe', + }, + { + name: 'Subscriber Activated', + value: 'subscriberActivate', + }, + { + name: 'Subscriber Unsubscribe', + value: 'subscriberUnsubscribe', + }, + { + name: 'Tag Add', + value: 'tagAdd', + }, + { + name: 'Tag Remove', + value: 'tagRemove', }, ], }, + { + displayName: 'Form ID', + name: 'formId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getForms', + }, + required: true, + default: '', + displayOptions: { + show: { + event: [ + 'formSubscribe', + ], + }, + }, + }, + { + displayName: 'Sequence ID', + name: 'courseId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getSequences', + }, + required: true, + default: '', + displayOptions: { + show: { + event: [ + 'courseSubscribe', + 'courseComplete', + ], + }, + }, + }, { displayName: 'Initiating Link', name: 'link', @@ -75,7 +145,39 @@ export class ConvertKitTrigger implements INodeType { displayOptions: { show: { event: [ - 'linkClicked', + 'linkClick', + ], + }, + }, + }, + { + displayName: 'Product ID', + name: 'productId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + event: [ + 'productPurchase', + ], + }, + }, + }, + { + displayName: 'Tag ID', + name: 'tagId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getTags', + }, + required: true, + default: '', + displayOptions: { + show: { + event: [ + 'tagAdd', + 'tagRemove', ], }, }, @@ -83,73 +185,181 @@ export class ConvertKitTrigger implements INodeType { ], }; + methods = { + loadOptions: { + // Get all the tags to display them to user so that he can + // select them easily + async getTags(this: ILoadOptionsFunctions): Promise { + + const returnData: INodePropertyOptions[] = []; + + const { tags } = await convertKitApiRequest.call(this, 'GET', '/tags'); + + for (const tag of tags) { + + const tagName = tag.name; + + const tagId = tag.id; + + returnData.push({ + name: tagName, + value: tagId, + }); + } + + return returnData; + }, + // Get all the forms to display them to user so that he can + // select them easily + async getForms(this: ILoadOptionsFunctions): Promise { + + const returnData: INodePropertyOptions[] = []; + + const { forms } = await convertKitApiRequest.call(this, 'GET', '/forms'); + + for (const form of forms) { + + const formName = form.name; + + const formId = form.id; + + returnData.push({ + name: formName, + value: formId, + }); + } + + return returnData; + }, + + // Get all the sequences to display them to user so that he can + // select them easily + async getSequences(this: ILoadOptionsFunctions): Promise { + + const returnData: INodePropertyOptions[] = []; + + const { courses } = await convertKitApiRequest.call(this, 'GET', '/sequences'); + + for (const course of courses) { + + const courseName = course.name; + + const courseId = course.id; + + returnData.push({ + name: courseName, + value: courseId, + }); + } + + return returnData; + }, + } + }; + // @ts-ignore (because of request) webhookMethods = { default: { async checkExists(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + // THe API does not have an endpoint to list all webhooks + if(webhookData.webhookId) { return true; } + return false; }, async create(this: IHookFunctions): Promise { - let webhook; + const webhookUrl = this.getNodeWebhookUrl('default'); - const event = this.getNodeParameter('event', 0); + + let event = this.getNodeParameter('event', 0) as string; + const endpoint = '/automations/hooks'; - const qs: IDataObject = {}; + if (event === 'purchaseCreate') { - try { - qs.target_url = webhookUrl; + event = `purchase.${snakeCase(event)}`; - if(event === 'subscriberActivated') { - qs.event = { - name: 'subscriber.subscriber_activate', - }; - } else if(event === 'linkClicked') { - const link = this.getNodeParameter('link', 0) as string; - qs.event = { - name: 'subscriber.link_click', - initiator_value: link, - }; - } - webhook = await convertKitApiRequest.call(this, 'POST', endpoint, {}, qs); - } catch (error) { - throw error; + } else { + + event = `subscriber.${snakeCase(event)}`; } + const body: IDataObject = { + target_url: webhookUrl as string, + event: { + name: event + }, + }; + + if (event === 'subscriber.form_subscribe') { + //@ts-ignore + body.event['form_id'] = this.getNodeParameter('formId', 0); + } + + if (event === 'subscriber.course_subscribe' || event === 'subscriber.course_complete') { + //@ts-ignore + body.event['sequence_id'] = this.getNodeParameter('courseId', 0); + } + + if (event === 'subscriber.link_click') { + //@ts-ignore + body.event['initiator_value'] = this.getNodeParameter('link', 0); + } + + if (event === 'subscriber.product_purchase') { + //@ts-ignore + body.event['product_id'] = this.getNodeParameter('productId', 0); + } + + if (event === 'subscriber.tag_add' || event === 'subscriber.tag_remove"') { + //@ts-ignore + body.event['tag_id'] = this.getNodeParameter('tagId', 0); + } + + const webhook = await convertKitApiRequest.call(this, 'POST', endpoint, body); + if (webhook.rule.id === undefined) { return false; } const webhookData = this.getWorkflowStaticData('node'); + webhookData.webhookId = webhook.rule.id as string; - webhookData.events = event; + return true; }, async delete(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + if (webhookData.webhookId !== undefined) { + const endpoint = `/automations/hooks/${webhookData.webhookId}`; + try { - await convertKitApiRequest.call(this, 'DELETE', endpoint, {}, {}); + + await convertKitApiRequest.call(this, 'DELETE', endpoint); + } catch (error) { + return false; } + delete webhookData.webhookId; - delete webhookData.events; } + return true; }, }, }; - async webhook(this: IWebhookFunctions): Promise { const returnData: IDataObject[] = []; returnData.push(this.getBodyData()); diff --git a/packages/nodes-base/nodes/ConvertKit/GenericFunctions.ts b/packages/nodes-base/nodes/ConvertKit/GenericFunctions.ts index 4844045e35..81c8e43b32 100644 --- a/packages/nodes-base/nodes/ConvertKit/GenericFunctions.ts +++ b/packages/nodes-base/nodes/ConvertKit/GenericFunctions.ts @@ -39,12 +39,19 @@ export async function convertKitApiRequest(this: IExecuteFunctions | IExecuteSin delete options.body; } - console.log(options); + // it's a webhook so include the api secret on the body + if ((options.uri as string).includes('/automations/hooks')) { + options.body['api_secret'] = credentials.apiSecret; + } else { + qs.api_secret = credentials.apiSecret; + } + + if (Object.keys(options.qs).length === 0) { + delete options.qs; + } try { - qs.api_secret = credentials.apiSecret; - return await this.helpers.request!(options); } catch (error) { diff --git a/packages/nodes-base/nodes/ConvertKit/SequenceDescription.ts b/packages/nodes-base/nodes/ConvertKit/SequenceDescription.ts index 493b713cda..5f1186cd55 100644 --- a/packages/nodes-base/nodes/ConvertKit/SequenceDescription.ts +++ b/packages/nodes-base/nodes/ConvertKit/SequenceDescription.ts @@ -18,17 +18,17 @@ export const sequenceOperations = [ { name: 'Add Subscriber', value: 'addSubscriber', - description: 'Add a subscriber.', + description: 'Add a subscriber', }, { name: 'Get All', value: 'getAll', - description: 'Get all sequences.', + description: 'Get all sequences', }, { name: 'Get Subscriptions', value: 'getSubscriptions', - description: 'List subscriptions to a sequence including subscriber data.', + description: 'Get all subscriptions to a sequence including subscriber data', }, ], default: 'addSubscriber', From b3a85871068c0df1c143eefbe4cd78a88d70a116 Mon Sep 17 00:00:00 2001 From: ricardo Date: Mon, 10 Aug 2020 09:41:10 -0400 Subject: [PATCH 0019/1150] :zap: Improvements --- .../credentials/ConvertKitApi.credentials.ts | 2 +- .../ConvertKit/ConvertKitTrigger.node.ts | 4 ++- .../nodes/ConvertKit/FormDescription.ts | 36 +++++++++---------- .../nodes/ConvertKit/SequenceDescription.ts | 36 +++++++++---------- 4 files changed, 40 insertions(+), 38 deletions(-) diff --git a/packages/nodes-base/credentials/ConvertKitApi.credentials.ts b/packages/nodes-base/credentials/ConvertKitApi.credentials.ts index 1685c11a24..d7e8697555 100644 --- a/packages/nodes-base/credentials/ConvertKitApi.credentials.ts +++ b/packages/nodes-base/credentials/ConvertKitApi.credentials.ts @@ -6,7 +6,7 @@ import { export class ConvertKitApi implements ICredentialType { name = 'convertKitApi'; - displayName = 'ConvertKit Api'; + displayName = 'ConvertKit API'; properties = [ { displayName: 'API Secret', diff --git a/packages/nodes-base/nodes/ConvertKit/ConvertKitTrigger.node.ts b/packages/nodes-base/nodes/ConvertKit/ConvertKitTrigger.node.ts index cb5bdbeb2c..0332facf72 100644 --- a/packages/nodes-base/nodes/ConvertKit/ConvertKitTrigger.node.ts +++ b/packages/nodes-base/nodes/ConvertKit/ConvertKitTrigger.node.ts @@ -297,6 +297,8 @@ export class ConvertKitTrigger implements INodeType { }, }; + console.log(event); + if (event === 'subscriber.form_subscribe') { //@ts-ignore body.event['form_id'] = this.getNodeParameter('formId', 0); @@ -317,7 +319,7 @@ export class ConvertKitTrigger implements INodeType { body.event['product_id'] = this.getNodeParameter('productId', 0); } - if (event === 'subscriber.tag_add' || event === 'subscriber.tag_remove"') { + if (event === 'subscriber.tag_add' || event === 'subscriber.tag_remove') { //@ts-ignore body.event['tag_id'] = this.getNodeParameter('tagId', 0); } diff --git a/packages/nodes-base/nodes/ConvertKit/FormDescription.ts b/packages/nodes-base/nodes/ConvertKit/FormDescription.ts index f6e1a6f3e5..a8496805bc 100644 --- a/packages/nodes-base/nodes/ConvertKit/FormDescription.ts +++ b/packages/nodes-base/nodes/ConvertKit/FormDescription.ts @@ -37,24 +37,6 @@ export const formOperations = [ ] as INodeProperties[]; export const formFields = [ - { - displayName: 'Email', - name: 'email', - type: 'string', - required: true, - displayOptions: { - show: { - resource: [ - 'form', - ], - operation: [ - 'addSubscriber', - ], - }, - }, - default: '', - description: `The subscriber's email address.`, - }, { displayName: 'Form ID', name: 'id', @@ -77,6 +59,24 @@ export const formFields = [ default: '', description: 'Form ID.', }, + { + displayName: 'Email', + name: 'email', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'form', + ], + operation: [ + 'addSubscriber', + ], + }, + }, + default: '', + description: `The subscriber's email address.`, + }, { displayName: 'Additional Fields', name: 'additionalFields', diff --git a/packages/nodes-base/nodes/ConvertKit/SequenceDescription.ts b/packages/nodes-base/nodes/ConvertKit/SequenceDescription.ts index 5f1186cd55..42437cc155 100644 --- a/packages/nodes-base/nodes/ConvertKit/SequenceDescription.ts +++ b/packages/nodes-base/nodes/ConvertKit/SequenceDescription.ts @@ -37,24 +37,6 @@ export const sequenceOperations = [ ] as INodeProperties[]; export const sequenceFields = [ - { - displayName: 'Email', - name: 'email', - type: 'string', - required: true, - displayOptions: { - show: { - resource: [ - 'sequence', - ], - operation: [ - 'addSubscriber', - ], - }, - }, - default: '', - description: `The subscriber's email address.`, - }, { displayName: 'Sequence ID', name: 'id', @@ -77,6 +59,24 @@ export const sequenceFields = [ default: '', description: 'Sequence ID.', }, + { + displayName: 'Email', + name: 'email', + type: 'string', + required: true, + displayOptions: { + show: { + resource: [ + 'sequence', + ], + operation: [ + 'addSubscriber', + ], + }, + }, + default: '', + description: `The subscriber's email address.`, + }, { displayName: 'Return All', name: 'returnAll', From 4568eb21cc0a43f90725a397a549de95e65bdff9 Mon Sep 17 00:00:00 2001 From: ricardo Date: Mon, 10 Aug 2020 09:43:21 -0400 Subject: [PATCH 0020/1150] :zap: small fix --- packages/nodes-base/nodes/ConvertKit/ConvertKitTrigger.node.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/nodes-base/nodes/ConvertKit/ConvertKitTrigger.node.ts b/packages/nodes-base/nodes/ConvertKit/ConvertKitTrigger.node.ts index 0332facf72..580bab7d56 100644 --- a/packages/nodes-base/nodes/ConvertKit/ConvertKitTrigger.node.ts +++ b/packages/nodes-base/nodes/ConvertKit/ConvertKitTrigger.node.ts @@ -297,8 +297,6 @@ export class ConvertKitTrigger implements INodeType { }, }; - console.log(event); - if (event === 'subscriber.form_subscribe') { //@ts-ignore body.event['form_id'] = this.getNodeParameter('formId', 0); From 77eb0f4955cd81bd3a2af954549c3999cad7d36f Mon Sep 17 00:00:00 2001 From: Denis Palashevskii Date: Tue, 18 Aug 2020 16:56:53 +0400 Subject: [PATCH 0021/1150] 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 0022/1150] 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 05258cc645e85805e10ff4f5e6b1e6283eff73ef Mon Sep 17 00:00:00 2001 From: Rupenieks Date: Thu, 20 Aug 2020 14:47:59 +0200 Subject: [PATCH 0023/1150] :white_check_mark: Credentials doc help link --- .../src/components/CredentialsEdit.vue | 122 +++++++++++++++++- 1 file changed, 120 insertions(+), 2 deletions(-) diff --git a/packages/editor-ui/src/components/CredentialsEdit.vue b/packages/editor-ui/src/components/CredentialsEdit.vue index a69ad78e24..4bea56cc2b 100644 --- a/packages/editor-ui/src/components/CredentialsEdit.vue +++ b/packages/editor-ui/src/components/CredentialsEdit.vue @@ -1,7 +1,29 @@