diff --git a/packages/nodes-base/credentials/BubbleApi.credentials.ts b/packages/nodes-base/credentials/BubbleApi.credentials.ts new file mode 100644 index 0000000000..dbeb7bb655 --- /dev/null +++ b/packages/nodes-base/credentials/BubbleApi.credentials.ts @@ -0,0 +1,70 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +export class BubbleApi implements ICredentialType { + name = 'bubbleApi'; + displayName = 'Bubble API'; + documentationUrl = 'bubble'; + properties = [ + { + displayName: 'API Token', + name: 'apiToken', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'App Name', + name: 'appName', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Environment', + name: 'environment', + type: 'options' as NodePropertyTypes, + default: 'live', + options: [ + { + name: 'Development', + value: 'development', + }, + { + name: 'Live', + value: 'live', + }, + ], + }, + { + displayName: 'Hosting', + name: 'hosting', + type: 'options' as NodePropertyTypes, + default: 'bubbleHosted', + options: [ + { + name: 'Bubble-hosted', + value: 'bubbleHosted', + }, + { + name: 'Self-hosted', + value: 'selfHosted', + }, + ], + }, + { + displayName: 'Domain', + name: 'domain', + type: 'string' as NodePropertyTypes, + placeholder: 'mydomain.com', + default: '', + displayOptions: { + show: { + hosting: [ + 'selfHosted', + ], + }, + }, + }, + ]; +} diff --git a/packages/nodes-base/nodes/Bubble/Bubble.node.ts b/packages/nodes-base/nodes/Bubble/Bubble.node.ts new file mode 100644 index 0000000000..433657bb82 --- /dev/null +++ b/packages/nodes-base/nodes/Bubble/Bubble.node.ts @@ -0,0 +1,206 @@ +import { + IExecuteFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeExecutionData, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + +import { + bubbleApiRequest, + bubbleApiRequestAllItems, + validateJSON, +} from './GenericFunctions'; + +import { + objectFields, + objectOperations, +} from './ObjectDescription'; + +export class Bubble implements INodeType { + description: INodeTypeDescription = { + displayName: 'Bubble', + name: 'bubble', + icon: 'file:bubble.svg', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume the Bubble Data API', + defaults: { + name: 'Bubble', + color: '#0205d3', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'bubbleApi', + required: true, + }, + ], + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + options: [ + { + name: 'Object', + value: 'object', + }, + ], + default: 'object', + description: 'Resource to consume', + }, + ...objectOperations, + ...objectFields, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + + const resource = this.getNodeParameter('resource', 0) as string; + const operation = this.getNodeParameter('operation', 0) as string; + + let responseData; + const qs: IDataObject = {}; + const returnData: IDataObject[] = []; + + for (let i = 0; i < items.length; i++) { + + if (resource === 'object') { + + // ********************************************************************* + // object + // ********************************************************************* + + // https://bubble.io/reference#API + + if (operation === 'create') { + + // ---------------------------------- + // object: create + // ---------------------------------- + + const typeNameInput = this.getNodeParameter('typeName', i) as string; + const typeName = typeNameInput.replace(/\s/g, '').toLowerCase(); + + const { property } = this.getNodeParameter('properties', i) as { + property: [ + { key: string; value: string; }, + ], + }; + + const body = {} as IDataObject; + + property.forEach(data => body[data.key] = data.value); + + responseData = await bubbleApiRequest.call(this, 'POST', `/obj/${typeName}`, body, {}); + + } else if (operation === 'delete') { + + // ---------------------------------- + // object: delete + // ---------------------------------- + + const typeNameInput = this.getNodeParameter('typeName', i) as string; + const typeName = typeNameInput.replace(/\s/g, '').toLowerCase(); + const objectId = this.getNodeParameter('objectId', i) as string; + + const endpoint = `/obj/${typeName}/${objectId}`; + responseData = await bubbleApiRequest.call(this, 'DELETE', endpoint, {}, {}); + responseData = { success: true }; + + } else if (operation === 'get') { + + // ---------------------------------- + // object: get + // ---------------------------------- + + const typeNameInput = this.getNodeParameter('typeName', i) as string; + const typeName = typeNameInput.replace(/\s/g, '').toLowerCase(); + const objectId = this.getNodeParameter('objectId', i) as string; + + const endpoint = `/obj/${typeName}/${objectId}`; + responseData = await bubbleApiRequest.call(this, 'GET', endpoint, {}, {}); + responseData = responseData.response; + + } else if (operation === 'getAll') { + + // ---------------------------------- + // object: getAll + // ---------------------------------- + + const returnAll = this.getNodeParameter('returnAll', 0) as boolean; + const typeNameInput = this.getNodeParameter('typeName', i) as string; + const typeName = typeNameInput.replace(/\s/g, '').toLowerCase(); + + const endpoint = `/obj/${typeName}`; + + const jsonParameters = this.getNodeParameter('jsonParameters', 0) as boolean; + const options = this.getNodeParameter('options', i) as IDataObject; + + if (jsonParameters === false) { + if (options.filters) { + const { filter } = options.filters as IDataObject; + qs.constraints = JSON.stringify(filter); + } + } else { + const filter = options.filtersJson as string; + const data = validateJSON(filter); + if (data === undefined) { + throw new Error('Filters must be a valid JSON'); + } + qs.constraints = JSON.stringify(data); + } + + if (options.sort) { + const { sortValue } = options.sort as IDataObject; + Object.assign(qs, sortValue); + } + + if (returnAll === true) { + responseData = await bubbleApiRequestAllItems.call(this, 'GET', endpoint, {}, qs); + } else { + qs.limit = this.getNodeParameter('limit', 0) as number; + responseData = await bubbleApiRequest.call(this, 'GET', endpoint, {}, qs); + responseData = responseData.response.results; + } + + } else if (operation === 'update') { + + // ---------------------------------- + // object: update + // ---------------------------------- + + const typeNameInput = this.getNodeParameter('typeName', i) as string; + const typeName = typeNameInput.replace(/\s/g, '').toLowerCase(); + const objectId = this.getNodeParameter('objectId', i) as string; + const endpoint = `/obj/${typeName}/${objectId}`; + const { property } = this.getNodeParameter('properties', i) as { + property: [ + { key: string; value: string; }, + ], + }; + + const body = {} as IDataObject; + + property.forEach(data => body[data.key] = data.value); + responseData = await bubbleApiRequest.call(this, 'PATCH', endpoint, body, {}); + responseData = { sucess: true }; + } + } + + Array.isArray(responseData) + ? returnData.push(...responseData) + : returnData.push(responseData); + } + + return [this.helpers.returnJsonArray(returnData)]; + + } +} diff --git a/packages/nodes-base/nodes/Bubble/GenericFunctions.ts b/packages/nodes-base/nodes/Bubble/GenericFunctions.ts new file mode 100644 index 0000000000..72cedfb8b7 --- /dev/null +++ b/packages/nodes-base/nodes/Bubble/GenericFunctions.ts @@ -0,0 +1,101 @@ +import { + IExecuteFunctions, + IHookFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, +} from 'n8n-workflow'; + +import { + OptionsWithUri, +} from 'request'; + +/** + * Make an authenticated API request to Bubble. + */ +export async function bubbleApiRequest( + this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, + method: string, + endpoint: string, + body: IDataObject, + qs: IDataObject, +) { + + const { apiToken, appName, domain, environment, hosting } = this.getCredentials('bubbleApi') as { + apiToken: string, + appName: string, + domain: string, + environment: 'development' | 'live', + hosting: 'bubbleHosted' | 'selfHosted', + }; + + const rootUrl = hosting === 'bubbleHosted' ? `https://${appName}.bubbleapps.io` : domain; + const urlSegment = environment === 'development' ? '/version-test/api/1.1' : '/api/1.1'; + + const options: OptionsWithUri = { + headers: { + 'user-agent': 'n8n', + 'Authorization': `Bearer ${apiToken}`, + }, + method, + uri: `${rootUrl}${urlSegment}${endpoint}`, + qs, + body, + json: true, + }; + + if (!Object.keys(body).length) { + delete options.body; + } + + if (!Object.keys(qs).length) { + delete options.qs; + } + + try { + return await this.helpers.request!(options); + } catch (error) { + if (error?.response?.body?.body?.message) { + const errorMessage = error.response.body.body.message; + throw new Error(`Bubble.io error response [${error.statusCode}]: ${errorMessage}`); + } + throw error; + } +} + +/** + * Make an authenticated API request to Bubble and return all results. + */ +export async function bubbleApiRequestAllItems( + this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, + method: string, + endpoint: string, + body: IDataObject, + qs: IDataObject, +) { + const returnData: IDataObject[] = []; + + let responseData; + qs.limit = 100; + do { + responseData = await bubbleApiRequest.call(this, method, endpoint, body, qs); + qs.cursor = responseData.cursor; + returnData.push.apply(returnData, responseData['response']['results']); + } while ( + responseData.response.remaining !== 0 + ); + + return returnData; +} + +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/Bubble/ObjectDescription.ts b/packages/nodes-base/nodes/Bubble/ObjectDescription.ts new file mode 100644 index 0000000000..74c5870606 --- /dev/null +++ b/packages/nodes-base/nodes/Bubble/ObjectDescription.ts @@ -0,0 +1,517 @@ +import { + INodeProperties, +} from 'n8n-workflow'; + +export const objectOperations = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + default: 'get', + description: 'Operation to perform', + options: [ + { + name: 'Create', + value: 'create', + }, + { + name: 'Delete', + value: 'delete', + }, + { + name: 'Get', + value: 'get', + }, + { + name: 'Get All', + value: 'getAll', + }, + { + name: 'Update', + value: 'update', + }, + ], + displayOptions: { + show: { + resource: [ + 'object', + ], + }, + }, + }, +] as INodeProperties[]; + +export const objectFields = [ + // ---------------------------------- + // object: create + // ---------------------------------- + { + displayName: 'Type Name', + name: 'typeName', + type: 'string', + required: true, + description: 'Name of data type of the object to create.', + default: '', + displayOptions: { + show: { + resource: [ + 'object', + ], + operation: [ + 'create', + ], + }, + }, + }, + { + displayName: 'Properties', + name: 'properties', + placeholder: 'Add Property', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + displayOptions: { + show: { + resource: [ + 'object', + ], + operation: [ + 'create', + ], + }, + }, + options: [ + { + displayName: 'Property', + name: 'property', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + default: '', + description: 'Field to set for the object to create.', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: 'Value to set for the object to create.', + }, + ], + }, + ], + }, + + // ---------------------------------- + // object: get + // ---------------------------------- + { + displayName: 'Type Name', + name: 'typeName', + type: 'string', + required: true, + description: 'Name of data type of the object to retrieve.', + default: '', + displayOptions: { + show: { + resource: [ + 'object', + ], + operation: [ + 'get', + 'delete', + ], + }, + }, + }, + { + displayName: 'Object ID', + name: 'objectId', + type: 'string', + required: true, + description: 'ID of the object to retrieve.', + default: '', + displayOptions: { + show: { + resource: [ + 'object', + ], + operation: [ + 'get', + 'delete', + ], + }, + }, + }, + + // ---------------------------------- + // object: update + // ---------------------------------- + { + displayName: 'Type Name', + name: 'typeName', + type: 'string', + required: true, + description: 'Name of data type of the object to update.', + default: '', + displayOptions: { + show: { + resource: [ + 'object', + ], + operation: [ + 'update', + ], + }, + }, + }, + { + displayName: 'Object ID', + name: 'objectId', + type: 'string', + required: true, + description: 'ID of the object to update.', + default: '', + displayOptions: { + show: { + resource: [ + 'object', + ], + operation: [ + 'update', + ], + }, + }, + }, + { + displayName: 'Properties', + name: 'properties', + placeholder: 'Add Property', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + default: {}, + displayOptions: { + show: { + resource: [ + 'object', + ], + operation: [ + 'update', + ], + }, + }, + options: [ + { + displayName: 'Property', + name: 'property', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + default: '', + description: 'Field to set for the object to create.', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + description: 'Value to set for the object to create.', + }, + ], + }, + ], + }, + + // ---------------------------------- + // object:getAll + // ---------------------------------- + { + displayName: 'Type Name', + name: 'typeName', + type: 'string', + required: true, + description: 'Name of data type of the object to create.', + default: '', + displayOptions: { + show: { + resource: [ + 'object', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + displayOptions: { + show: { + resource: [ + 'object', + ], + operation: [ + 'getAll', + ], + }, + }, + default: false, + description: 'If all results should be returned or only up to a given limit.', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + displayOptions: { + show: { + resource: [ + 'object', + ], + operation: [ + 'getAll', + ], + returnAll: [ + false, + ], + }, + }, + typeOptions: { + minValue: 1, + maxValue: 100, + }, + default: 100, + description: 'How many results to return.', + }, + { + displayName: 'JSON Parameters', + name: 'jsonParameters', + type: 'boolean', + default: false, + description: '', + displayOptions: { + show: { + resource: [ + 'object', + ], + operation: [ + 'getAll', + ], + }, + }, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + displayOptions: { + show: { + operation: [ + 'getAll', + ], + resource: [ + 'object', + ], + }, + }, + default: {}, + placeholder: 'Add Field', + options: [ + { + displayName: 'Filters', + name: 'filters', + placeholder: 'Add Filter', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + '/jsonParameters': [ + false, + ], + }, + }, + default: {}, + options: [ + { + displayName: 'Filter', + name: 'filter', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + default: '', + description: 'Field to set for the object to create.', + }, + { + displayName: 'Constrain', + name: 'constraint_type', + type: 'options', + options: [ + { + name: 'Equals', + value: 'equals', + description: 'Use to test strict equality, for all field types.', + }, + { + name: 'Not Equal', + value: 'not equal', + description: 'Use to test strict equality, for all field types', + }, + { + name: 'Is Empty', + value: 'is_empty', + description: `Use to test whether a thing's given field is empty, for all field types.`, + }, + { + name: 'Is Not Empty', + value: 'is_not_empty', + description: `Use to test whether a thing's given field is not empty, for all field types.`, + }, + { + name: 'Text Contains', + value: 'text contains', + description: 'Use to test if a text field contains a string, for text fields only', + }, + { + name: 'Not Text Contains', + value: 'not text contains', + description: 'Use to test if a text field does not contain a string, for text fields only', + }, + { + name: 'Greater Than', + value: 'greater than', + description: `Use to compare a thing's field value relative to a string or number, for text, number, and date fields`, + }, + { + name: 'Less Than', + value: 'less than', + description: `Use to compare a thing's field value relative to a string or number, for text, number, and date fields`, + }, + { + name: 'In', + value: 'in', + description: `Use to test whether a thing's field is in a list, for all field types.`, + }, + { + name: 'Not In', + value: 'not in', + description: `Use to test whether a thing's field is not in a list, for all field types.`, + }, + { + name: 'Contains', + value: 'contains', + description: `Use to test whether a list field contains an entry, for list fields only`, + }, + { + name: 'Not Contains', + value: 'not contains', + description: `Use to test whether a list field does not contains an entry, for list fields only`, + }, + { + name: 'Empty', + value: 'empty', + description: `Use to test whether a list field is empty, for list fields only`, + }, + { + name: 'Not Empty', + value: 'not empty', + description: `Use to test whether a list field is not empty, for list fields only`, + }, + { + name: 'Geographic Search', + value: 'geographic_search', + description: `Use to test if the current thing is within a radius from a central address. To use this, the value sent with the constraint must have an address and a range. See link.`, + }, + ], + default: '', + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + displayOptions: { + hide: { + constraint_type: [ + 'is_empty', + 'is_not_empty', + 'empty', + 'not empty', + ], + }, + }, + default: '', + description: 'Value to set for the object to create.', + }, + ], + }, + ], + }, + { + displayName: 'Filters (JSON)', + name: 'filtersJson', + type: 'json', + default: '', + displayOptions: { + show: { + '/jsonParameters': [ + true, + ], + }, + }, + placeholder: `[ { "key": "name", "constraint_type": "text contains", "value": "cafe" } , { "key": "address", "constraint_type": "geographic_search", "value": { "range":10, "origin_address":"New York" } } ]`, + description: 'Refine the list that is returned by the Data API with search constraints, exactly as you define a search in Bubble. See link', + }, + { + displayName: 'Sort', + name: 'sort', + placeholder: 'Add Sort', + type: 'fixedCollection', + typeOptions: { + multipleValues: false, + }, + default: {}, + options: [ + { + displayName: 'Sort', + name: 'sortValue', + values: [ + { + displayName: 'Sort Field', + name: 'sort_field', + type: 'string', + default: '', + description: `Specify the field to use for sorting. Either use a fielddefined for
+ the current typeor use _random_sorting to get the entries in a random order`, + }, + { + displayName: 'Descending', + name: 'descending', + type: 'boolean', + default: false, + }, + { + displayName: 'Geo Reference', + name: 'geo_reference', + type: 'string', + default: '', + description: `When the field's type is geographic address, you need to add another parameter geo_reference and provide an address as a string`, + }, + ], + }, + ], + }, + ], + }, +] as INodeProperties[]; diff --git a/packages/nodes-base/nodes/Bubble/bubble.svg b/packages/nodes-base/nodes/Bubble/bubble.svg new file mode 100644 index 0000000000..005a8e5820 --- /dev/null +++ b/packages/nodes-base/nodes/Bubble/bubble.svg @@ -0,0 +1 @@ + diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 1fbde4bdda..79f355abf3 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -47,6 +47,7 @@ "dist/credentials/BitwardenApi.credentials.js", "dist/credentials/BoxOAuth2Api.credentials.js", "dist/credentials/BrandfetchApi.credentials.js", + "dist/credentials/BubbleApi.credentials.js", "dist/credentials/ChargebeeApi.credentials.js", "dist/credentials/CircleCiApi.credentials.js", "dist/credentials/ClearbitApi.credentials.js", @@ -288,6 +289,7 @@ "dist/nodes/Box/Box.node.js", "dist/nodes/Box/BoxTrigger.node.js", "dist/nodes/Brandfetch/Brandfetch.node.js", + "dist/nodes/Bubble/Bubble.node.js", "dist/nodes/Calendly/CalendlyTrigger.node.js", "dist/nodes/Chargebee/Chargebee.node.js", "dist/nodes/Chargebee/ChargebeeTrigger.node.js",