diff --git a/packages/nodes-base/credentials/FormstackApi.credentials.ts b/packages/nodes-base/credentials/FormstackApi.credentials.ts new file mode 100644 index 0000000000..5578d32488 --- /dev/null +++ b/packages/nodes-base/credentials/FormstackApi.credentials.ts @@ -0,0 +1,19 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class FormstackApi implements ICredentialType { + name = 'formstackApi'; + displayName = 'Formstack API'; + documentationUrl = 'formstack'; + properties = [ + { + displayName: 'Access Token', + name: 'accessToken', + type: 'string' as NodePropertyTypes, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/credentials/FormstackOAuth2Api.credentials.ts b/packages/nodes-base/credentials/FormstackOAuth2Api.credentials.ts new file mode 100644 index 0000000000..add1885abd --- /dev/null +++ b/packages/nodes-base/credentials/FormstackOAuth2Api.credentials.ts @@ -0,0 +1,49 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + +const scopes: string[] = []; + +export class FormstackOAuth2Api implements ICredentialType { + name = 'formstackOAuth2Api'; + extends = [ + 'oAuth2Api', + ]; + displayName = 'Formstack OAuth2 API'; + documentationUrl = 'formstack'; + properties = [ + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://www.formstack.com/api/v2/oauth2/authorize', + required: true, + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden' as NodePropertyTypes, + default: 'https://www.formstack.com/api/v2/oauth2/token', + required: true, + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden' as NodePropertyTypes, + default: scopes.join(' '), + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden' as NodePropertyTypes, + default: 'header', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Formstack/FormstackTrigger.node.ts b/packages/nodes-base/nodes/Formstack/FormstackTrigger.node.ts new file mode 100644 index 0000000000..1ac3d82ec6 --- /dev/null +++ b/packages/nodes-base/nodes/Formstack/FormstackTrigger.node.ts @@ -0,0 +1,199 @@ +import { + IHookFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeType, + INodeTypeDescription, + IWebhookResponseData, +} from 'n8n-workflow'; + +import { + apiRequest, + getFields, + getForms, + getSubmission, + IFormstackWebhookResponseBody +} from './GenericFunctions'; + +export class FormstackTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Formstack Trigger', + name: 'formstackTrigger', + icon: 'file:formstack.svg', + group: ['trigger'], + version: 1, + subtitle: '=Form ID: {{$parameter["formId"]}}', + description: 'Starts the workflow on a Formstack form submission.', + defaults: { + name: 'Formstack Trigger', + color: '#21b573', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'formstackApi', + required: true, + displayOptions: { + show: { + authentication: [ + 'accessToken', + ], + }, + }, + }, + { + name: 'formstackOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: [ + 'oAuth2', + ], + }, + }, + }, + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Access Token', + value: 'accessToken', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'accessToken', + description: '', + }, + { + displayName: 'Form Name/ID', + name: 'formId', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getForms', + }, + default: '', + required: true, + description: 'The Formstack form to monitor for new submissions', + }, + { + displayName: 'Simplify Response', + name: 'simple', + type: 'boolean', + default: true, + description: 'When set to true a simplify version of the response will be used else the raw data.', + }, + ], + }; + + methods = { + loadOptions: { + getForms, + }, + }; + + // @ts-ignore + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default'); + const webhookData = this.getWorkflowStaticData('node'); + + const formId = this.getNodeParameter('formId') as string; + + const endpoint = `form/${formId}/webhook.json`; + + const { webhooks } = await apiRequest.call(this, 'GET', endpoint); + + for (const webhook of webhooks) { + if (webhook.url === webhookUrl) { + webhookData.webhookId = webhook.id; + return true; + } + } + + return false; + }, + async create(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default'); + + const formId = this.getNodeParameter('formId') as string; + + const endpoint = `form/${formId}/webhook.json`; + + // TODO: Add handshake key support + const body = { + url: webhookUrl, + standardize_field_values: true, + include_field_type: true, + content_type: 'json', + }; + + const response = await apiRequest.call(this, 'POST', endpoint, body); + + const webhookData = this.getWorkflowStaticData('node'); + webhookData.webhookId = response.id; + return true; + }, + async delete(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + + if (webhookData.webhookId !== undefined) { + const endpoint = `webhook/${webhookData.webhookId}.json`; + + try { + const body = {}; + await apiRequest.call(this, 'DELETE', endpoint, body); + } catch (e) { + return false; + } + // Remove from the static workflow data so that it is clear + // that no webhooks are registred anymore + delete webhookData.webhookId; + } + + return true; + }, + }, + }; + + // @ts-ignore + async webhook(this: IWebhookFunctions): Promise { + const bodyData = (this.getBodyData() as unknown) as IFormstackWebhookResponseBody; + const simple = this.getNodeParameter('simple') as string; + + const response = bodyData as unknown as IDataObject; + + if (simple) { + for (const key of Object.keys(response)) { + if ((response[key] as IDataObject).hasOwnProperty('value')) { + response[key] = (response[key] as IDataObject).value; + } + } + } + + return { + workflowData: [ + this.helpers.returnJsonArray([response as unknown as IDataObject]), + ], + }; + } +} diff --git a/packages/nodes-base/nodes/Formstack/GenericFunctions.ts b/packages/nodes-base/nodes/Formstack/GenericFunctions.ts new file mode 100644 index 0000000000..7c741741cf --- /dev/null +++ b/packages/nodes-base/nodes/Formstack/GenericFunctions.ts @@ -0,0 +1,206 @@ +import { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodePropertyOptions, + NodeApiError, +} from 'n8n-workflow'; + +import { + OptionsWithUri, +} from 'request'; + +export interface IFormstackFieldDefinitionType { + id: string; + label: string; + description: string; + name: string; + type: string; + options: unknown; + required: string; + uniq: string; + hidden: string; + readonly: string; + colspan: string; + label_position: string; + num_columns: string; + date_format: string; + time_format: string; +} + +export interface IFormstackWebhookResponseBody { + FormID: string; + UniqueID: string; +} + +export interface IFormstackSubmissionFieldContainer { + field: string; + value: string; +} + +export enum FormstackFieldFormat { + ID = 'id', + Label = 'label', + Name = 'name', +} + +/** + * Make an API request to Formstack + * + * @param {IHookFunctions} this + * @param {string} method + * @param {string} url + * @param {object} body + * @returns {Promise} + */ +export async function apiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, method: string, endpoint: string, body: IDataObject = {}, query: IDataObject = {}): Promise { // tslint:disable-line:no-any + const authenticationMethod = this.getNodeParameter('authentication', 0); + + const options: OptionsWithUri = { + headers: {}, + method, + body, + qs: query || {}, + uri: `https://www.formstack.com/api/v2/${endpoint}`, + json: true, + }; + + if (!Object.keys(body).length) { + delete options.body; + } + + try { + if (authenticationMethod === 'accessToken') { + const credentials = await this.getCredentials('formstackApi') as IDataObject; + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + options.headers!['Authorization'] = `Bearer ${credentials.accessToken}`; + return await this.helpers.request!(options); + } else { + return await this.helpers.requestOAuth2!.call(this, 'formstackOAuth2Api', options); + } + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } +} + + +/** + * Make an API request to paginated Formstack endpoint + * and return all results + * + * @export + * @param {(IHookFunctions | IExecuteFunctions)} this + * @param {string} method + * @param {string} endpoint + * @param {IDataObject} body + * @param {IDataObject} [query] + * @returns {Promise} + */ +export async function apiRequestAllItems(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, method: string, endpoint: string, body: IDataObject, dataKey: string, query?: IDataObject): Promise { // tslint:disable-line:no-any + + if (query === undefined) { + query = {}; + } + + query.per_page = 200; + query.page = 0; + + const returnData = { + items: [] as IDataObject[], + }; + + let responseData; + + do { + query.page += 1; + + responseData = await apiRequest.call(this, method, endpoint, body, query); + returnData.items.push.apply(returnData.items, responseData[dataKey]); + } while ( + responseData.total !== undefined && + Math.ceil(responseData.total / query.per_page) > query.page + ); + + return returnData; +} + + +/** + * Returns all the available forms + * + * @export + * @param {ILoadOptionsFunctions} this + * @returns {Promise} + */ +export async function getForms(this: ILoadOptionsFunctions): Promise { + const endpoint = 'form.json'; + const responseData = await apiRequestAllItems.call(this, 'GET', endpoint, {}, 'forms', { folders: false }); + + if (responseData.items === undefined) { + throw new Error('No data got returned'); + } + const returnData: INodePropertyOptions[] = []; + for (const baseData of responseData.items) { + returnData.push({ + name: baseData.name, + value: baseData.id, + }); + } + return returnData; +} + + +/** + * Returns all the fields of a form + * + * @export + * @param {ILoadOptionsFunctions} this + * @param {string} formID + * @returns {Promise} + */ +export async function getFields(this: IWebhookFunctions, formID: string): Promise> { + const endpoint = `form/${formID}.json`; + const responseData = await apiRequestAllItems.call(this, 'GET', endpoint, {}, 'fields'); + + if (responseData.items === undefined) { + throw new Error('No form fields meta data got returned'); + } + + const fields = responseData.items as IFormstackFieldDefinitionType[]; + const fieldMap: Record = {}; + + fields.forEach(field => { + fieldMap[field.id] = field; + }); + + return fieldMap; +} + + +/** + * Returns all the fields of a form + * + * @export + * @param {ILoadOptionsFunctions} this + * @param {string} uniqueId + * @returns {Promise} + */ +export async function getSubmission(this: ILoadOptionsFunctions | IWebhookFunctions, uniqueId: string): Promise { + const endpoint = `submission/${uniqueId}.json`; + const responseData = await apiRequestAllItems.call(this, 'GET', endpoint, {}, 'data'); + + if (responseData.items === undefined) { + throw new Error('No form fields meta data got returned'); + } + + return responseData.items as IFormstackSubmissionFieldContainer[]; +} diff --git a/packages/nodes-base/nodes/Formstack/formstack.svg b/packages/nodes-base/nodes/Formstack/formstack.svg new file mode 100644 index 0000000000..7d6860f60a --- /dev/null +++ b/packages/nodes-base/nodes/Formstack/formstack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/nodes-base/nodes/Typeform/GenericFunctions.ts b/packages/nodes-base/nodes/Typeform/GenericFunctions.ts index 688a0d715c..9bf7cc148a 100644 --- a/packages/nodes-base/nodes/Typeform/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Typeform/GenericFunctions.ts @@ -115,6 +115,12 @@ export async function apiRequestAllItems(this: IHookFunctions | IExecuteFunction responseData = await apiRequest.call(this, method, endpoint, body, query); + console.log({ + endpoint, + method, + responseData, + }); + returnData.items.push.apply(returnData.items, responseData.items); } while ( responseData.page_count !== undefined && diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 39aae4cfa0..8b968e3eb1 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -89,6 +89,8 @@ "dist/credentials/FreshworksCrmApi.credentials.js", "dist/credentials/FileMaker.credentials.js", "dist/credentials/FlowApi.credentials.js", + "dist/credentials/FormstackApi.credentials.js", + "dist/credentials/FormstackOAuth2Api.credentials.js", "dist/credentials/Ftp.credentials.js", "dist/credentials/FormIoApi.credentials.js", "dist/credentials/GetResponseApi.credentials.js", @@ -386,6 +388,7 @@ "dist/nodes/FormIo/FormIoTrigger.node.js", "dist/nodes/Flow/Flow.node.js", "dist/nodes/Flow/FlowTrigger.node.js", + "dist/nodes/Formstack/FormstackTrigger.node.js", "dist/nodes/Function.node.js", "dist/nodes/FunctionItem.node.js", "dist/nodes/GetResponse/GetResponse.node.js",