Add Bubble.io Node (#1493)

* 🎉 Register node and credentials

* 🎨 Add SVG icon

* 🎨 Fix SVG icon size and positioning

*  Add API credentials

*  Add description for object

*  Add generic functions

*  Add preliminary node

*  Improvements

*  Minor improvements

Co-authored-by: ricardo <ricardoespinoza105@gmail.com>
Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
This commit is contained in:
Iván Ovejero
2021-03-03 21:09:31 -03:00
committed by GitHub
parent 66a345ea94
commit c49fcdeed6
6 changed files with 897 additions and 0 deletions

View File

@@ -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',
],
},
},
},
];
}

View File

@@ -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<INodeExecutionData[][]> {
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)];
}
}

View File

@@ -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;
}

View File

@@ -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 <a href="https://manual.bubble.io/core-resources/api/data-api" target="_blank">link</a>.`,
},
],
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 <a href="https://manual.bubble.io/core-resources/api/data-api#search-constraints" target="_blank">link</a>',
},
{
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</br>
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[];

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="-16 -10 85 85" fill="#fff" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round"><use xlink:href="#A" x=".5" y=".5"/><symbol id="A" overflow="visible"><path d="M145.432 17.494c-6.051 0-12.034 2.596-16.584 7.719V0h-9.017v42.455c0 13.786 11.174 24.961 24.961 24.961s24.961-11.174 24.961-24.961-10.534-24.961-24.32-24.961zm-.657 40.298a15.34 15.34 0 0 1-15.337-15.337c0-8.461 6.876-15.337 15.337-15.337a15.34 15.34 0 0 1 15.337 15.337c0 8.461-6.86 15.337-15.337 15.337zm56.562-40.298c-6.051 0-12.034 2.596-16.584 7.719V0h-9.017v42.455c0 13.786 11.174 24.961 24.961 24.961s24.961-11.174 24.961-24.961-10.551-24.961-24.32-24.961zm-.657 40.298a15.34 15.34 0 0 1-15.337-15.337c0-8.461 6.876-15.337 15.337-15.337a15.34 15.34 0 0 1 15.337 15.337 15.34 15.34 0 0 1-15.337 15.337zM69.067 46.821V18.843h9.674v27.893c0 7.129 4.129 11.697 11.09 11.697 7.045 0 11.09-4.399 11.09-11.697V18.843h9.758v27.978c0 12.927-7.837 20.579-20.848 20.579-12.674.017-20.764-7.905-20.764-20.579zM243.101 66.27h-9.674V0h9.674v66.27zm54.775-19.972h-37.028c1.416 7.382 6.59 12.404 15.741 12.404 5.36 0 11.882-2.022 16.096-5.107l4.045 7.129c-4.837 3.607-12.489 6.59-20.494 6.59-17.764 0-25.416-12.404-25.416-24.893 0-7.129 2.191-13.011 6.691-17.68 4.483-4.669 10.382-7.045 17.511-7.045 6.775 0 12.405 2.107 16.719 6.421s6.506 10.112 6.506 17.511c-.034 1.331-.118 2.916-.371 4.669zm-37.045-7.921h27.624c-.792-7.483-6.337-12.32-13.466-12.32-7.298 0-12.742 4.938-14.157 12.32zM38.343 17.494c-6.051 0-12.034 2.596-16.584 7.719V0h-9.034v42.455c0 13.786 11.174 24.961 24.961 24.961s24.961-11.174 24.961-24.961-10.534-24.961-24.303-24.961zm-.657 40.298a15.34 15.34 0 0 1-15.337-15.337c0-8.461 6.876-15.337 15.337-15.337a15.34 15.34 0 0 1 15.337 15.337 15.34 15.34 0 0 1-15.337 15.337z" fill="#000" fill-rule="nonzero" stroke="none"/><path d="M0 61.231c0-3.417 2.751-6.169 6.169-6.169s6.168 2.751 6.168 6.169-2.751 6.169-6.168 6.169S0 64.648 0 61.231z" fill="#00f" fill-rule="nonzero" stroke="none"/></symbol></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -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",