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

*  Add preAuthentication method to credentials

* Revert "Added new credential for metabse, added custom auth for metabase"

This reverts commit 5f1b7607ad.

* Revert "Added new credential for metabse, added custom auth for metabase"

This reverts commit 5f1b7607ad.

* 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

*  Simplify and cleanup

Co-authored-by: ricardo <ricardoespinoza105@gmail.com>
Co-authored-by: Omar Ajoue <krynble@gmail.com>
Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
This commit is contained in:
agobrech 2022-07-26 14:43:36 +02:00 committed by GitHub
parent b4525d06ea
commit 81b5828558
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 773 additions and 8 deletions

View file

@ -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<INodeCredentialTestResult> {
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 {

View file

@ -4,6 +4,7 @@ import {
ICredentialTestRequest,
ICredentialType,
IHttpRequestHelper,
IHttpRequestOptions,
INodeProperties,
} from 'n8n-workflow';

View file

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

View file

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

View file

@ -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/"
}
]
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="256px" height="324px" viewBox="0 0 256 324" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<g fill="#509EE3">
<ellipse cx="19.3939396" cy="82.7565395" rx="19.3939394" ry="19.703938"></ellipse>
<ellipse cx="19.3939396" cy="137.927566" rx="19.3939394" ry="19.703938"></ellipse>
<ellipse opacity="0.2" cx="73.6969698" cy="82.7565395" rx="19.3939394" ry="19.703938"></ellipse>
<ellipse cx="73.6969698" cy="138.463513" rx="19.3939394" ry="19.703938"></ellipse>
<ellipse opacity="0.2" cx="128" cy="82.7565395" rx="19.3939394" ry="19.703938"></ellipse>
<ellipse opacity="0.2" cx="128" cy="19.703938" rx="19.3939394" ry="19.703938"></ellipse>
<ellipse opacity="0.2" cx="128" cy="138.463513" rx="19.3939394" ry="19.703938"></ellipse>
<ellipse opacity="0.2" cx="182.30303" cy="82.7565395" rx="19.3939394" ry="19.703938"></ellipse>
<ellipse cx="236.60606" cy="82.7565395" rx="19.3939394" ry="19.703938"></ellipse>
<ellipse cx="182.30303" cy="138.463513" rx="19.3939394" ry="19.703938"></ellipse>
<ellipse cx="236.60606" cy="138.463513" rx="19.3939394" ry="19.703938"></ellipse>
<ellipse cx="19.3939396" cy="193.098592" rx="19.3939394" ry="19.703938"></ellipse>
<ellipse opacity="0.2" cx="73.6969698" cy="193.634539" rx="19.3939394" ry="19.703938"></ellipse>
<ellipse cx="128" cy="193.634539" rx="19.3939394" ry="19.703938"></ellipse>
<ellipse opacity="0.2" cx="182.30303" cy="193.634539" rx="19.3939394" ry="19.703938"></ellipse>
<ellipse cx="236.60606" cy="193.634539" rx="19.3939394" ry="19.703938"></ellipse>
<ellipse cx="19.3939396" cy="248.269618" rx="19.3939394" ry="19.703938"></ellipse>
<ellipse opacity="0.2" cx="73.6969698" cy="248.805565" rx="19.3939394" ry="19.703938"></ellipse>
<ellipse opacity="0.2" cx="128" cy="248.805565" rx="19.3939394" ry="19.703938"></ellipse>
<ellipse opacity="0.2" cx="128" cy="303.976591" rx="19.3939394" ry="19.703938"></ellipse>
<ellipse opacity="0.2" cx="182.30303" cy="248.805565" rx="19.3939394" ry="19.703938"></ellipse>
<ellipse cx="236.60606" cy="248.805565" rx="19.3939394" ry="19.703938"></ellipse>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

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

View file

@ -1136,6 +1136,7 @@ export interface INodeRequestSend {
export interface IPostReceiveBase {
type: string;
enabled?: boolean | string;
properties: {
[key: string]: string | number | IDataObject;
};

View file

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