From 81b58285588f142c0b1cc148f0092c462eefdd73 Mon Sep 17 00:00:00 2001 From: agobrech <45268029+agobrech@users.noreply.github.com> Date: Tue, 26 Jul 2022 14:43:36 +0200 Subject: [PATCH] feat(Metabase Node): Add Metabase Node (#3033) * Boilerplate with new node's version for metabse * Metabases MVP features * Added new credential for metabse, added custom auth for metabase * Fixed bug with one enpoint not working * Clean up code * Uniformised the renovate token * Made two example of responses for review * Fixed lint issues * Feature add datasources * Changed output from databases * Changed questions data output * Fixed issue when testing credentials with new node format * Add the possibility to get raw data * Removed handle for the metabase meta results, changed export's name * Add binary extraction for the result data * Fixed binary download issue * :zap: Add preAuthentication method to credentials * Revert "Added new credential for metabse, added custom auth for metabase" This reverts commit 5f1b7607adb85d6ec897b184853bdfdbae77df6d. * Revert "Added new credential for metabse, added custom auth for metabase" This reverts commit 5f1b7607adb85d6ec897b184853bdfdbae77df6d. * Added preAuth and fixed autfixable linting rules * Fixed linting errors * Linting fixes * Remove / at the end of url, and add placeholder for cred url * Make export to Json retun only json and no binary * Fix lint issues * Add action and exception for lint rule * Remove unnecessary credential file * :zap: Simplify and cleanup Co-authored-by: ricardo Co-authored-by: Omar Ajoue Co-authored-by: Jan Oberhauser --- packages/cli/src/CredentialsHelper.ts | 8 +- .../credentials/MetabaseApi.credentials.ts | 1 + .../nodes/Metabase/AlertsDescription.ts | 59 +++ .../nodes/Metabase/DatabasesDescription.ts | 355 ++++++++++++++++++ .../nodes/Metabase/Metabase.node.json | 18 + .../nodes/Metabase/Metabase.node.ts | 73 ++++ .../nodes/Metabase/MetricsDescription.ts | 60 +++ .../nodes/Metabase/QuestionsDescription.ts | 145 +++++++ .../nodes-base/nodes/Metabase/metabase.svg | 27 ++ packages/nodes-base/package.json | 2 + packages/workflow/src/Interfaces.ts | 1 + packages/workflow/src/RoutingNode.ts | 32 +- 12 files changed, 773 insertions(+), 8 deletions(-) create mode 100644 packages/nodes-base/nodes/Metabase/AlertsDescription.ts create mode 100644 packages/nodes-base/nodes/Metabase/DatabasesDescription.ts create mode 100644 packages/nodes-base/nodes/Metabase/Metabase.node.json create mode 100644 packages/nodes-base/nodes/Metabase/Metabase.node.ts create mode 100644 packages/nodes-base/nodes/Metabase/MetricsDescription.ts create mode 100644 packages/nodes-base/nodes/Metabase/QuestionsDescription.ts create mode 100644 packages/nodes-base/nodes/Metabase/metabase.svg diff --git a/packages/cli/src/CredentialsHelper.ts b/packages/cli/src/CredentialsHelper.ts index 6da9e2c64d762..f5423ae09f4e3 100644 --- a/packages/cli/src/CredentialsHelper.ts +++ b/packages/cli/src/CredentialsHelper.ts @@ -92,7 +92,6 @@ export class CredentialsHelper extends ICredentialsHelper { if (credentialType.authenticate) { if (typeof credentialType.authenticate === 'function') { // Special authentication function is defined - // eslint-disable-next-line @typescript-eslint/no-unsafe-call return credentialType.authenticate(credentials, requestOptions as IHttpRequestOptions); } @@ -559,7 +558,6 @@ export class CredentialsHelper extends ICredentialsHelper { nodeToTestWith?: string, ): Promise { const credentialTestFunction = this.getCredentialTestFunction(credentialType, nodeToTestWith); - if (credentialTestFunction === undefined) { return Promise.resolve({ status: 'Error', @@ -690,7 +688,6 @@ export class CredentialsHelper extends ICredentialsHelper { statusCode: error.cause.response.status, statusMessage: error.cause.response.statusText, }; - if (credentialTestFunction.testRequest.rules) { // Special testing rules are defined so check all in order for (const rule of credentialTestFunction.testRequest.rules) { @@ -716,6 +713,11 @@ export class CredentialsHelper extends ICredentialsHelper { `Received HTTP status code: ${errorResponseData.statusCode}`, }; } + } else if (error.cause.code) { + return { + status: 'Error', + message: error.cause.code, + }; } Logger.debug('Credential test failed', error); return { diff --git a/packages/nodes-base/credentials/MetabaseApi.credentials.ts b/packages/nodes-base/credentials/MetabaseApi.credentials.ts index 6de56d47649fa..f6fe0880df981 100644 --- a/packages/nodes-base/credentials/MetabaseApi.credentials.ts +++ b/packages/nodes-base/credentials/MetabaseApi.credentials.ts @@ -4,6 +4,7 @@ import { ICredentialTestRequest, ICredentialType, IHttpRequestHelper, + IHttpRequestOptions, INodeProperties, } from 'n8n-workflow'; diff --git a/packages/nodes-base/nodes/Metabase/AlertsDescription.ts b/packages/nodes-base/nodes/Metabase/AlertsDescription.ts new file mode 100644 index 0000000000000..49c6c1e919abe --- /dev/null +++ b/packages/nodes-base/nodes/Metabase/AlertsDescription.ts @@ -0,0 +1,59 @@ +import { INodeProperties } from 'n8n-workflow'; + +export const alertsOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['alerts'], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get specific alert', + routing: { + request: { + method: 'GET', + url: '={{"/api/alert/" + $parameter.alertId}}', + }, + }, + action: 'Get an alert', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all the alerts', + routing: { + request: { + method: 'GET', + url: '/api/alert/', + }, + }, + action: 'Get all alerts', + }, + ], + default: 'getAll', + }, +]; + +export const alertsFields: INodeProperties[] = [ + { + displayName: 'Alert ID', + name: 'alertId', + type: 'string', + required: true, + placeholder: '0', + displayOptions: { + show: { + resource: ['alerts'], + operation: ['get'], + }, + }, + default: '', + }, +]; diff --git a/packages/nodes-base/nodes/Metabase/DatabasesDescription.ts b/packages/nodes-base/nodes/Metabase/DatabasesDescription.ts new file mode 100644 index 0000000000000..bdd62a2ada205 --- /dev/null +++ b/packages/nodes-base/nodes/Metabase/DatabasesDescription.ts @@ -0,0 +1,355 @@ +import { IN8nHttpFullResponse, INodeProperties } from 'n8n-workflow'; + +export const databasesOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['databases'], + }, + }, + options: [ + { + name: 'Add', + value: 'addNewDatasource', + description: 'Add a new datasource to the metabase instance', + routing: { + request: { + method: 'POST', + url: '/api/database', + }, + }, + action: 'Add a databases', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all the databases', + routing: { + request: { + method: 'GET', + url: '/api/database/', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'data', + }, + }, + ], + }, + }, + action: 'Get all databases', + }, + { + name: 'Get Fields', + value: 'getFields', + description: 'Get fields from database', + routing: { + request: { + method: 'GET', + url: '={{"/api/database/" + $parameter.databaseId + "/fields"}}', + }, + }, + action: 'Get Fields a databases', + }, + ], + default: 'getAll', + }, +]; + +export const databasesFields: INodeProperties[] = [ + { + displayName: 'Database ID', + name: 'databaseId', + type: 'string', + required: true, + placeholder: '0', + displayOptions: { + show: { + resource: ['databases'], + operation: ['getFields'], + }, + }, + default: '', + }, + { + displayName: 'Engine', + name: 'engine', + type: 'options', + required: true, + placeholder: 'PostgreSQL', + options: [ + { + name: 'H2', + value: 'h2', + }, + { + name: 'MongoDB', + value: 'mongo', + }, + { + name: 'Mysql', + value: 'mysql', + }, + { + name: 'PostgreSQL', + value: 'postgres', + }, + { + name: 'Redshift', + value: 'redshift', + }, + { + name: 'Sqlite', + value: 'sqlite', + }, + ], + default: 'postgres', + displayOptions: { + show: { + resource: ['databases'], + operation: ['addNewDatasource'], + }, + }, + routing: { + send: { + property: 'engine', + type: 'body', + }, + }, + }, + { + displayName: 'Host', + name: 'host', + type: 'string', + required: true, + placeholder: 'localhost:5432', + displayOptions: { + show: { + resource: ['databases'], + operation: ['addNewDatasource'], + engine: ['postgres', 'redshift', 'mysql', 'mongo'], + }, + }, + routing: { + send: { + property: 'details.host', + type: 'body', + }, + }, + default: '', + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + required: true, + placeholder: 'Database 1', + displayOptions: { + show: { + resource: ['databases'], + operation: ['addNewDatasource'], + }, + }, + routing: { + send: { + property: 'name', + type: 'body', + }, + }, + default: '', + }, + { + displayName: 'Port', + name: 'port', + type: 'number', + required: true, + placeholder: '5432', + displayOptions: { + show: { + resource: ['databases'], + operation: ['addNewDatasource'], + engine: ['postgres', 'redshift', 'mysql', 'mongo'], + }, + }, + routing: { + send: { + property: 'details.port', + type: 'body', + }, + }, + default: 5432, + }, + { + displayName: 'User', + name: 'user', + type: 'string', + required: true, + placeholder: 'Admin', + displayOptions: { + show: { + resource: ['databases'], + operation: ['addNewDatasource'], + engine: ['postgres', 'redshift', 'mysql', 'mongo'], + }, + }, + routing: { + send: { + property: 'details.user', + type: 'body', + }, + }, + default: '', + }, + { + displayName: 'Password', + name: 'password', + type: 'string', + required: true, + placeholder: 'password', + displayOptions: { + show: { + resource: ['databases'], + operation: ['addNewDatasource'], + engine: ['postgres', 'redshift', 'mysql', 'mongo'], + }, + }, + routing: { + send: { + property: 'details.password', + type: 'body', + }, + }, + default: '', + }, + { + displayName: 'Database Name', + name: 'dbName', + type: 'string', + placeholder: 'Users', + displayOptions: { + show: { + resource: ['databases'], + operation: ['addNewDatasource'], + engine: ['postgres', 'redshift', 'mysql', 'mongo'], + }, + }, + routing: { + send: { + property: 'details.db', + type: 'body', + }, + }, + default: '', + }, + { + displayName: 'File Path', + name: 'filePath', + type: 'string', + required: true, + placeholder: 'file:/Users/admin/Desktop/Users', + displayOptions: { + show: { + resource: ['databases'], + operation: ['addNewDatasource'], + engine: ['h2', 'sqlite'], + }, + }, + routing: { + send: { + property: 'details.db', + type: 'body', + }, + }, + default: '', + }, + { + displayName: 'Full Sync', + name: 'fullSync', + type: 'boolean', + required: true, + default: true, + displayOptions: { + show: { + resource: ['databases'], + operation: ['addNewDatasource'], + }, + }, + routing: { + send: { + property: 'is_full_sync', + type: 'body', + }, + }, + }, + { + displayName: 'Simplify', + name: 'simple', + type: 'boolean', + description: 'Whether to return a simplified version of the response instead of the raw data', + displayOptions: { + show: { + resource: ['databases'], + operation: ['getAll'], + }, + }, + routing: { + output: { + postReceive: [ + { + type: 'setKeyValue', + enabled: '={{$value}}', + properties: { + id: '={{$responseItem.id}}', + name: '={{$responseItem.name}}', + description: '={{$responseItem.description}}', + engine: '={{$responseItem.engine}}', + creator_id: '={{$responseItem.creator_id}}', + timezone: '={{$responseItem.timezone}}', + created_at: '={{$responseItem.created_at}}', + updated_at: '={{$responseItem.updated_at}}', + db: '={{$responseItem.details.db}}', + user: '={{$responseItem.details.user}}', + host: '={{$responseItem.details.host}}', + port: '={{$responseItem.details.port}}', + ssl: '={{$responseItem.details.ssl}}', + is_full_sync: '={{$responseItem.details.is_full_sync}}', + }, + }, + ], + }, + }, + default: true, + }, +]; + +type MetabaseDatabaseResult = IN8nHttpFullResponse & { + body: Array<{ + data: Array<{ + id: number; + name: string; + description: string; + details: MetabaseDatabaseDetail; + timezone: string; + creator_id: number; + created_at: string; + updated_at: string; + engine: string; + is_full_sync: string; + }>; + }>; +}; + +type MetabaseDatabaseDetail = { + host?: string; + port?: number; + user?: string; + ssl?: boolean; + db?: string; +}; diff --git a/packages/nodes-base/nodes/Metabase/Metabase.node.json b/packages/nodes-base/nodes/Metabase/Metabase.node.json new file mode 100644 index 0000000000000..da32bf963c186 --- /dev/null +++ b/packages/nodes-base/nodes/Metabase/Metabase.node.json @@ -0,0 +1,18 @@ +{ + "node": "n8n-nodes-base.metabase", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Development", "Data & Storage"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/credentials/metabase" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/nodes/n8n-nodes-base.metabse/" + } + ] + } +} diff --git a/packages/nodes-base/nodes/Metabase/Metabase.node.ts b/packages/nodes-base/nodes/Metabase/Metabase.node.ts new file mode 100644 index 0000000000000..c8f5a98f3da20 --- /dev/null +++ b/packages/nodes-base/nodes/Metabase/Metabase.node.ts @@ -0,0 +1,73 @@ +import { INodeType, INodeTypeDescription } from 'n8n-workflow'; + +import { questionsFields, questionsOperations } from './QuestionsDescription'; + +import { metricsFields, metricsOperations } from './MetricsDescription'; + +import { databasesFields, databasesOperations } from './DatabasesDescription'; + +import { alertsFields, alertsOperations } from './AlertsDescription'; + +export class Metabase implements INodeType { + description: INodeTypeDescription = { + displayName: 'Metabase', + name: 'metabase', + icon: 'file:metabase.svg', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Use the Metabase API', + defaults: { + name: 'Metabase', + color: '#ff0000', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'metabaseApi', + required: true, + }, + ], + requestDefaults: { + returnFullResponse: true, + baseURL: '={{$credentials.url.replace(new RegExp("/$"), "")}}', + headers: {}, + }, + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Alert', + value: 'alerts', + }, + { + name: 'Database', + value: 'databases', + }, + { + name: 'Metric', + value: 'metrics', + }, + { + name: 'Question', + value: 'questions', + }, + ], + default: 'questions', + }, + ...questionsOperations, + ...questionsFields, + ...metricsOperations, + ...metricsFields, + ...databasesOperations, + ...databasesFields, + ...alertsOperations, + ...alertsFields, + ], + }; +} diff --git a/packages/nodes-base/nodes/Metabase/MetricsDescription.ts b/packages/nodes-base/nodes/Metabase/MetricsDescription.ts new file mode 100644 index 0000000000000..b1cf7af9ddd6a --- /dev/null +++ b/packages/nodes-base/nodes/Metabase/MetricsDescription.ts @@ -0,0 +1,60 @@ +import { INodeProperties } from 'n8n-workflow'; + +export const metricsOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['metrics'], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get a specific metric', + routing: { + request: { + method: 'GET', + url: '={{"/api/metric/" + $parameter.metricId}}', + returnFullResponse: true, + }, + }, + action: 'Get a metric', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all the metrics', + routing: { + request: { + method: 'GET', + url: '/api/metric/', + }, + }, + action: 'Get all metrics', + }, + ], + default: 'getAll', + }, +]; + +export const metricsFields: INodeProperties[] = [ + { + displayName: 'Metric ID', + name: 'metricId', + type: 'string', + required: true, + placeholder: '0', + displayOptions: { + show: { + resource: ['metrics'], + operation: ['get'], + }, + }, + default: '', + }, +]; diff --git a/packages/nodes-base/nodes/Metabase/QuestionsDescription.ts b/packages/nodes-base/nodes/Metabase/QuestionsDescription.ts new file mode 100644 index 0000000000000..b62f127a68297 --- /dev/null +++ b/packages/nodes-base/nodes/Metabase/QuestionsDescription.ts @@ -0,0 +1,145 @@ +import { + IDataObject, + IExecuteSingleFunctions, + IN8nHttpFullResponse, + INodeExecutionData, + INodeProperties, +} from 'n8n-workflow'; + +export const questionsOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['questions'], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + description: 'Get a specific question', + routing: { + request: { + method: 'GET', + url: '={{"/api/card/" + $parameter.questionId}}', + }, + }, + action: 'Get a questions', + }, + { + name: 'Get All', + value: 'getAll', + description: 'Get all the questions', + routing: { + request: { + method: 'GET', + url: '/api/card/', + }, + }, + action: 'Get all questions', + }, + { + name: 'Result Data', + value: 'resultData', + description: 'Return the result of the question to a specific file format', + routing: { + request: { + method: 'POST', + url: '={{"/api/card/" + $parameter.questionId + "/query/" + $parameter.format}}', + returnFullResponse: true, + encoding: 'arraybuffer', + }, + output: { + postReceive: [ + // @ts-ignore + async function ( + this: IExecuteSingleFunctions, + _items: INodeExecutionData[], + response: IN8nHttpFullResponse, + ): Promise { + const items = _items; + const result: INodeExecutionData[] = []; + for (let i = 0; i < items.length; i++) { + const newItem: INodeExecutionData = { + json: items[i].json, + binary: {}, + }; + + if (items[i].binary !== undefined) { + Object.assign(newItem.binary, items[i].binary); + } + items[i] = newItem; + if (this.getNode().parameters.format === 'json') { + items[i].json = JSON.parse( + items[i].json as unknown as string, + )[0] as unknown as IDataObject; + console.log(items[i].json); + delete items[i].binary; + } else { + items[i].binary!['data'] = await this.helpers.prepareBinaryData( + response.body as Buffer, + 'data', + response.headers['content-type'], + ); + } + result.push(items[i]); + } + return result; + }, + ], + }, + }, + action: 'Result Data a questions', + }, + ], + default: 'getAll', + }, +]; + +export const questionsFields: INodeProperties[] = [ + { + displayName: 'Question ID', + name: 'questionId', + type: 'string', + required: true, + placeholder: '0', + displayOptions: { + show: { + resource: ['questions'], + operation: ['get', 'resultData'], + }, + }, + default: '', + }, + { + displayName: 'Format', + name: 'format', + type: 'options', + required: true, + options: [ + { + name: 'CSV', + value: 'csv', + }, + { + name: 'JSON', + value: 'json', + }, + { + name: 'XLSX', + value: 'xlsx', + }, + ], + default: 'csv', + displayOptions: { + show: { + resource: ['questions'], + operation: ['resultData'], + }, + }, + }, +]; diff --git a/packages/nodes-base/nodes/Metabase/metabase.svg b/packages/nodes-base/nodes/Metabase/metabase.svg new file mode 100644 index 0000000000000..6771aaff68ab0 --- /dev/null +++ b/packages/nodes-base/nodes/Metabase/metabase.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index de665abf6da7b..d057db2255680 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -182,6 +182,7 @@ "dist/credentials/MauticOAuth2Api.credentials.js", "dist/credentials/MediumApi.credentials.js", "dist/credentials/MediumOAuth2Api.credentials.js", + "dist/credentials/MetabaseApi.credentials.js", "dist/credentials/MessageBirdApi.credentials.js", "dist/credentials/MetabaseApi.credentials.js", "dist/credentials/MicrosoftDynamicsOAuth2Api.credentials.js", @@ -526,6 +527,7 @@ "dist/nodes/Medium/Medium.node.js", "dist/nodes/Merge/Merge.node.js", "dist/nodes/MessageBird/MessageBird.node.js", + "dist/nodes/Metabase/Metabase.node.js", "dist/nodes/Microsoft/Dynamics/MicrosoftDynamicsCrm.node.js", "dist/nodes/Microsoft/Excel/MicrosoftExcel.node.js", "dist/nodes/Microsoft/GraphSecurity/MicrosoftGraphSecurity.node.js", diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 6f5fc6c71fa1d..d0249a68d0533 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1136,6 +1136,7 @@ export interface INodeRequestSend { export interface IPostReceiveBase { type: string; + enabled?: boolean | string; properties: { [key: string]: string | number | IDataObject; }; diff --git a/packages/workflow/src/RoutingNode.ts b/packages/workflow/src/RoutingNode.ts index e945a5ba300b2..f21a99c8fcda0 100644 --- a/packages/workflow/src/RoutingNode.ts +++ b/packages/workflow/src/RoutingNode.ts @@ -746,12 +746,34 @@ export class RoutingNode { } if (nodeProperties.routing.output.postReceive) { - returnData.postReceive.push({ - data: { - parameterValue, - }, - actions: nodeProperties.routing.output.postReceive, + const postReceiveActions = nodeProperties.routing.output.postReceive.filter((action) => { + if (typeof action === 'function') { + return true; + } + + if (typeof action.enabled === 'string' && action.enabled.charAt(0) === '=') { + // If the propertyName is an expression resolve it + return this.getParameterValue( + action.enabled, + itemIndex, + runIndex, + executeSingleFunctions.getExecuteData(), + { ...additionalKeys, $value: parameterValue }, + true, + ) as boolean; + } + + return action.enabled !== false; }); + + if (postReceiveActions.length) { + returnData.postReceive.push({ + data: { + parameterValue, + }, + actions: postReceiveActions, + }); + } } } }