From f18fc9d9102bb6a334c898cb07bacaf2067c6250 Mon Sep 17 00:00:00 2001 From: Romain Dunand Date: Sun, 10 Nov 2019 01:54:25 +0100 Subject: [PATCH] Auth & get records management --- .../credentials/FileMaker.credentials.ts | 39 ++ .../nodes/FileMaker/FileMaker.node.ts | 421 ++++++++++++++++++ .../nodes/FileMaker/GenericFunctions.ts | 185 ++++++++ .../nodes-base/nodes/FileMaker/filemaker.png | Bin 0 -> 16244 bytes packages/nodes-base/nodes/HttpRequest.node.ts | 1 - packages/nodes-base/package.json | 2 + 6 files changed, 647 insertions(+), 1 deletion(-) create mode 100644 packages/nodes-base/credentials/FileMaker.credentials.ts create mode 100644 packages/nodes-base/nodes/FileMaker/FileMaker.node.ts create mode 100644 packages/nodes-base/nodes/FileMaker/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/FileMaker/filemaker.png diff --git a/packages/nodes-base/credentials/FileMaker.credentials.ts b/packages/nodes-base/credentials/FileMaker.credentials.ts new file mode 100644 index 000000000..3d0e67b7c --- /dev/null +++ b/packages/nodes-base/credentials/FileMaker.credentials.ts @@ -0,0 +1,39 @@ +import { + ICredentialType, + NodePropertyTypes, +} from 'n8n-workflow'; + + +export class FileMaker implements ICredentialType { + name = 'FileMaker'; + displayName = 'FileMaker'; + properties = [ + { + displayName: 'Host', + name: 'host', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Database', + name: 'db', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Login', + name: 'login', + type: 'string' as NodePropertyTypes, + default: '', + }, + { + displayName: 'Password', + name: 'password', + type: 'string' as NodePropertyTypes, + typeOptions: { + password: true, + }, + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/FileMaker/FileMaker.node.ts b/packages/nodes-base/nodes/FileMaker/FileMaker.node.ts new file mode 100644 index 000000000..8caaca47d --- /dev/null +++ b/packages/nodes-base/nodes/FileMaker/FileMaker.node.ts @@ -0,0 +1,421 @@ +import {IExecuteFunctions} from 'n8n-core'; +import { + IDataObject, + ILoadOptionsFunctions, + INodeExecutionData, INodePropertyOptions, + INodeType, + INodeTypeDescription, +} from 'n8n-workflow'; + + +import {OptionsWithUri} from 'request'; +import {layoutsApiRequest, getFields, getToken, logout} from "./GenericFunctions"; + +export class FileMaker implements INodeType { + description: INodeTypeDescription = { + displayName: 'FileMaker', + name: 'filemaker', + icon: 'file:filemaker.png', + group: ['input'], + version: 1, + description: 'Retrieve data from FileMaker data API.', + defaults: { + name: 'FileMaker', + color: '#665533', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'FileMaker', + required: true, + }, + ], + properties: [ + { + displayName: 'Action', + name: 'action', + type: 'options', + options: [ + /*{ + name: 'Login', + value: 'login', + }, + { + name: 'Logout', + value: 'logout', + },*/ + { + name: 'Find Records', + value: 'find', + }, + { + name: 'get Records', + value: 'records', + }, + { + name: 'Get Records By Id', + value: 'record', + }, + { + name: 'Perform Script', + value: 'performscript', + }, + { + name: 'Create Record', + value: 'create', + }, + { + name: 'Edit Record', + value: 'edit', + }, + { + name: 'Duplicate Record', + value: 'duplicate', + }, + { + name: 'Delete Record', + value: 'delete', + }, + ], + default: 'login', + description: 'Action to perform.', + }, + + // ---------------------------------- + // shared + // ---------------------------------- + { + displayName: 'Layout', + name: 'layout', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getLayouts', + }, + options: [], + default: '', + required: true, + displayOptions: { + hide: { + action: [ + 'performscript' + ], + }, + }, + placeholder: 'Layout Name', + description: 'FileMaker Layout Name.', + }, + { + displayName: 'Record Id', + name: 'recid', + type: 'number', + default: '', + required: true, + displayOptions: { + show: { + action: [ + 'record', + 'edit', + 'delete', + 'duplicate', + ], + }, + }, + placeholder: 'Record ID', + description: 'Internal Record ID returned by get (recordid)', + }, + // ---------------------------------- + // find/records + // ---------------------------------- + { + displayName: 'offset', + name: 'offset', + placeholder: '0', + description: 'The record number of the first record in the range of records.', + type: 'number', + default: '1', + displayOptions: { + show: { + action: [ + 'find', + 'records', + ], + }, + } + }, + { + displayName: 'limit', + name: 'limit', + placeholder: '100', + description: 'The maximum number of records that should be returned. If not specified, the default value is 100.', + type: 'number', + default: '100', + displayOptions: { + show: { + action: [ + 'find', + 'records', + ], + }, + } + }, + { + displayName: 'Sort', + name: 'sortParametersUi', + placeholder: 'Add Sort Rules', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + action: [ + 'find', + 'records', + ], + }, + }, + description: 'Sort rules', + default: {}, + options: [ + { + name: 'rules', + displayName: 'Rules', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'options', + default: '', + typeOptions: { + loadOptionsMethod: 'getFields', + }, + options: [], + description: 'Field Name.', + }, + { + displayName: 'Value', + name: 'value', + type: 'options', + default: 'ascend', + options: [ + { + name: 'Ascend', + value: 'ascend' + }, + { + name: 'Descend', + value: 'descend' + }, + ], + description: 'Sort order.', + }, + ] + }, + ], + }, + // ---------------------------------- + // create/edit + // ---------------------------------- + { + displayName: 'fieldData', + name: 'fieldData', + placeholder: '{"field1": "value", "field2": "value", ...}', + description: 'Additional fields to add.', + type: 'string', + default: '{}', + displayOptions: { + show: { + action: [ + 'create', + 'edit', + ], + }, + } + }, + { + displayName: 'Fields', + name: 'Fields', + type: 'collection', + typeOptions: { + loadOptionsMethod: 'getFields', + }, + options: [], + default: '', + required: true, + displayOptions: { + show: { + action: [ + 'create', + 'edit', + ], + }, + }, + placeholder: 'Layout Name', + description: 'FileMaker Layout Name.', + }, + // ---------------------------------- + // performscript + // ---------------------------------- + { + displayName: 'Script Name', + name: 'script', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getScripts', + }, + options: [], + default: '', + required: true, + displayOptions: { + show: { + action: [ + 'performscript' + ], + }, + }, + placeholder: 'Layout Name', + description: 'FileMaker Layout Name.', + }, + ] + }; + + methods = { + loadOptions: { + // Get all the available topics to display them to user so that he can + // select them easily + async getLayouts(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + let layouts; + try { + layouts = await layoutsApiRequest.call(this); + } catch (err) { + throw new Error(`FileMaker Error: ${err}`); + } + for (const layout of layouts) { + returnData.push({ + name: layout.name, + value: layout.name, + }); + } + return returnData; + }, + + async getFields(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + + let fields; + try { + fields = await getFields.call(this); + } catch (err) { + throw new Error(`FileMaker Error: ${err}`); + } + for (const field of fields) { + returnData.push({ + name: field.name, + value: field.name, + }); + } + return returnData; + }, + }, + }; + + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + const returnData: INodeExecutionData[] = []; + + const credentials = this.getCredentials('FileMaker'); + + const action = this.getNodeParameter('action', 0) as string; + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + const staticData = this.getWorkflowStaticData('global'); + // Operations which overwrite the returned data + const overwriteDataOperations = []; + // Operations which overwrite the returned data and return arrays + // and has so to be merged with the data of other items + const overwriteDataOperationsArray = []; + + let requestOptions: OptionsWithUri; + + const host = credentials.host as string; + const database = credentials.db as string; + + //const layout = this.getNodeParameter('layout', 0, null) as string; + //const recid = this.getNodeParameter('recid', 0, null) as number; + + const url = `https://${host}/fmi/data/v1`; + //const fullOperation = `${resource}:${operation}`; + + for (let i = 0; i < items.length; i++) { + // Reset all values + requestOptions = { + uri: '', + headers: {}, + method: 'GET', + json: true + //rejectUnauthorized: !this.getNodeParameter('allowUnauthorizedCerts', itemIndex, false) as boolean, + }; + + const layout = this.getNodeParameter('layout', 0) as string; + const token = await getToken.call(this); + + if (action === 'record') { + const recid = this.getNodeParameter('recid', 0) as string; + + requestOptions.uri = url + `/databases/${database}/layouts/${layout}/records/${recid}`; + requestOptions.method = 'GET'; + requestOptions.headers = { + 'Authorization': `Bearer ${token}`, + }; + } else if (action === 'records') { + requestOptions.uri = url + `/databases/${database}/layouts/${layout}/records`; + requestOptions.method = 'GET'; + requestOptions.headers = { + 'Authorization': `Bearer ${token}`, + }; + + const sort = []; + const sortParametersUi = this.getNodeParameter('sortParametersUi', 0, {}) as IDataObject; + if (sortParametersUi.parameter !== undefined) { + // @ts-ignore + for (const parameterData of sortParametersUi!.rules as IDataObject[]) { + // @ts-ignore + sort.push({ + 'fieldName': parameterData!.name as string, + 'sortOrder': parameterData!.value + }); + } + } + requestOptions.qs = { + '_offset': this.getNodeParameter('offset', 0), + '_limit': this.getNodeParameter('limit', 0), + '_sort': JSON.stringify(sort), + }; + } else { + throw new Error(`The action "${action}" is not implemented yet!`); + } + + // Now that the options are all set make the actual http request + let response; + try { + response = await this.helpers.request(requestOptions); + } catch (error) { + response = error.response.body; + } + + if (typeof response === 'string') { + throw new Error('Response body is not valid JSON. Change "Response Format" to "String"'); + } + await logout.call(this, token); + + returnData.push({json: response}); + } + + return this.prepareOutputData(returnData); + } +} \ No newline at end of file diff --git a/packages/nodes-base/nodes/FileMaker/GenericFunctions.ts b/packages/nodes-base/nodes/FileMaker/GenericFunctions.ts new file mode 100644 index 000000000..c76350e1f --- /dev/null +++ b/packages/nodes-base/nodes/FileMaker/GenericFunctions.ts @@ -0,0 +1,185 @@ +import { + IExecuteFunctions, + ILoadOptionsFunctions, + IExecuteSingleFunctions +} from 'n8n-core'; + +import { + ICredentialDataDecryptedObject, + IDataObject, +} from 'n8n-workflow'; + +import { OptionsWithUri } from 'request'; + + +/** + * Make an API request to ActiveCampaign + * + * @param {IHookFunctions} this + * @param {string} method + * @returns {Promise} + */ +export async function layoutsApiRequest(this: ILoadOptionsFunctions | IExecuteFunctions | IExecuteSingleFunctions): Promise { // tslint:disable-line:no-any + const token = await getToken.call(this); + const credentials = this.getCredentials('FileMaker'); + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + const host = credentials.host as string; + const db = credentials.db as string; + + const url = `https://${host}/fmi/data/v1/databases/${db}/layouts`; + const options: OptionsWithUri = { + headers: { + 'Authorization': `Bearer ${token}`, + }, + method: 'GET', + uri: url, + json: true + }; + + try { + const responseData = await this.helpers.request!(options); + return responseData.response.layouts; + + } catch (error) { + // If that data does not exist for some reason return the actual error + throw error; + } +} + +/** + * Make an API request to ActiveCampaign + * + * @returns {Promise} + * @param layout + */ +export async function getFields(this: ILoadOptionsFunctions | IExecuteFunctions | IExecuteSingleFunctions): Promise { // tslint:disable-line:no-any + const token = await getToken.call(this); + const credentials = this.getCredentials('FileMaker'); + const layout = this.getCurrentNodeParameter('layout') as string; + + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + const host = credentials.host as string; + const db = credentials.db as string; + + const url = `https://${host}/fmi/data/v1/databases/${db}/layouts/${layout}`; + const options: OptionsWithUri = { + headers: { + 'Authorization': `Bearer ${token}`, + }, + method: 'GET', + uri: url, + json: true + }; + + try { + const responseData = await this.helpers.request!(options); + return responseData.response.fieldMetaData; + + } catch (error) { + // If that data does not exist for some reason return the actual error + throw error; + } +} + +export async function getToken(this: ILoadOptionsFunctions | IExecuteFunctions | IExecuteSingleFunctions): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('FileMaker'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + const host = credentials.host as string; + const db = credentials.db as string; + const login = credentials.login as string; + const password = credentials.password as string; + + const url = `https://${host}/fmi/data/v1/databases/${db}/sessions`; + + let requestOptions: OptionsWithUri; + // Reset all values + requestOptions = { + uri: url, + headers: {}, + method: 'POST', + json: true + //rejectUnauthorized: !this.getNodeParameter('allowUnauthorizedCerts', itemIndex, false) as boolean, + }; + requestOptions.auth = { + user: login as string, + pass: password as string, + }; + requestOptions.body = { + "fmDataSource": [ + { + "database": host, + "username": login as string, + "password": password as string + } + ] + }; + + try { + const response = await this.helpers.request!(requestOptions); + + if (typeof response === 'string') { + throw new Error('Response body is not valid JSON. Change "Response Format" to "String"'); + } + + return response.response.token; + } catch (error) { + console.error(error); + + const errorMessage = error.response.body.messages[0].message + '(' + error.response.body.messages[0].message + ')'; + + if (errorMessage !== undefined) { + throw errorMessage; + } + throw error.response.body; + } +} + +export async function logout(this: ILoadOptionsFunctions | IExecuteFunctions | IExecuteSingleFunctions, token: string): Promise { // tslint:disable-line:no-any + const credentials = this.getCredentials('FileMaker'); + if (credentials === undefined) { + throw new Error('No credentials got returned!'); + } + + const host = credentials.host as string; + const db = credentials.db as string; + + const url = `https://${host}/fmi/data/v1/databases/${db}/sessions/${token}`; + + let requestOptions: OptionsWithUri; + // Reset all values + requestOptions = { + uri: url, + headers: {}, + method: 'DELETE', + json: true + //rejectUnauthorized: !this.getNodeParameter('allowUnauthorizedCerts', itemIndex, false) as boolean, + }; + + try { + const response = await this.helpers.request!(requestOptions); + + if (typeof response === 'string') { + throw new Error('Response body is not valid JSON. Change "Response Format" to "String"'); + } + + return response; + } catch (error) { + console.error(error); + + const errorMessage = error.response.body.messages[0].message + '(' + error.response.body.messages[0].message + ')'; + + if (errorMessage !== undefined) { + throw errorMessage; + } + throw error.response.body; + } +} + diff --git a/packages/nodes-base/nodes/FileMaker/filemaker.png b/packages/nodes-base/nodes/FileMaker/filemaker.png new file mode 100644 index 0000000000000000000000000000000000000000..ec691433dac024e2f3596fc5f9c310e5acc4c3a1 GIT binary patch literal 16244 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