Merge master

This commit is contained in:
Omar Ajoue 2021-04-08 15:33:30 +02:00
commit 2a6d98b4ba
445 changed files with 36881 additions and 2528 deletions

View file

@ -4,6 +4,12 @@ on:
push: push:
tags: tags:
- n8n@* - n8n@*
workflow_dispatch:
inputs:
version:
description: 'n8n version to build docker image for.'
required: true
default: '0.112.0'
jobs: jobs:
armv7_job: armv7_job:
@ -28,7 +34,7 @@ jobs:
run: | run: |
docker buildx build \ docker buildx build \
--platform linux/arm/v7 \ --platform linux/arm/v7 \
--build-arg N8N_VERSION=${{steps.vars.outputs.tag}} \ --build-arg N8N_VERSION=${{github.event.inputs.version || steps.vars.outputs.tag}} \
-t ${{ secrets.DOCKER_USERNAME }}/n8n:${{steps.vars.outputs.tag}}-rpi \ -t ${{ secrets.DOCKER_USERNAME }}/n8n:${{github.event.inputs.version || steps.vars.outputs.tag}}-rpi \
-t ${{ secrets.DOCKER_USERNAME }}/n8n:latest-rpi \ -t ${{ secrets.DOCKER_USERNAME }}/n8n:latest-rpi \
--output type=image,push=true docker/images/n8n-rpi --output type=image,push=true docker/images/n8n-rpi

View file

@ -32,3 +32,7 @@ jobs:
run: docker build --build-arg N8N_VERSION=${{steps.vars.outputs.tag}} -t n8nio/n8n:${{steps.vars.outputs.tag}}-debian docker/images/n8n-debian run: docker build --build-arg N8N_VERSION=${{steps.vars.outputs.tag}} -t n8nio/n8n:${{steps.vars.outputs.tag}}-debian docker/images/n8n-debian
- name: Push Docker image of version (Debian) - name: Push Docker image of version (Debian)
run: docker push n8nio/n8n:${{steps.vars.outputs.tag}}-debian run: docker push n8nio/n8n:${{steps.vars.outputs.tag}}-debian
- name: Tag Docker image with latest (Debian)
run: docker tag n8nio/n8n:${{steps.vars.outputs.tag}}-debian n8nio/n8n:latest-debian
- name: Push docker images of latest (Debian)
run: docker push n8nio/n8n:latest-debian

View file

@ -9,7 +9,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node-version: [10.x, 12.x, 14.x] node-version: [12.x, 14.x]
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1

View file

@ -139,7 +139,7 @@ automatically build your code, restart the backend and refresh the frontend
``` ```
npm run dev npm run dev
``` ```
1. hack, hack, hack 1. Hack, hack, hack
1. Check if everything still runs in production mode 1. Check if everything still runs in production mode
``` ```
npm run build npm run build
@ -168,61 +168,28 @@ tests of all packages.
## Create Custom Nodes ## Create Custom Nodes
It is very straightforward to create your own nodes for n8n. More information about that can Learn about [using the node dev CLI](https://docs.n8n.io/nodes/creating-nodes/node-dev-cli.html) to create custom nodes for n8n.
be found in the documentation of "n8n-node-dev" which is a small CLI which
helps with n8n-node-development.
[To n8n-node-dev](https://github.com/n8n-io/n8n/tree/master/packages/node-dev) More information can
be found in the documentation of [n8n-node-dev](https://github.com/n8n-io/n8n/tree/master/packages/node-dev), which is a small CLI which
helps with n8n-node-development.
## Create a new node to contribute to n8n ## Create a new node to contribute to n8n
If you want to create a node which should be added to n8n follow these steps: Follow this tutorial on [creating your first node](https://docs.n8n.io/nodes/creating-nodes/create-node.html) for n8n.
1. Read the information in the [n8n-node-dev](https://github.com/n8n-io/n8n/tree/master/packages/node-dev) package as it contains a lot of generic information about node development.
1. Create the n8n development setup like described above and start n8n in develoment mode `npm run dev`
1. Create a new folder for the new node. For a service named "Example" the folder would be called: `/packages/nodes-base/nodes/Example`
1. If there is already a similar node, copy the existing one in the new folder and rename it. If none exists yet, create a boilerplate node with [n8n-node-dev](https://github.com/n8n-io/n8n/tree/master/packages/node-dev) and copy that one in the folder.
1. If the node needs credentials because it has to authenticate with an API or similar create new ones. Existing ones can be found in folder `/packages/nodes-base/credentials`. Also there it is the easiest to copy existing similar ones.
1. Add the path to the new node (and optionally credentials) to package.json of `nodes-base`. It already contains a property `n8n` with its own keys `credentials` and `nodes`.
1. Add icon for the node (60x60 PNG)
1. Start n8n. The new node will then be available via the editor UI and can be tested.
When developing n8n must get restarted and the browser reloaded every time parameters of a node change (like new ones added, removed or changed). Only then will the new data be loaded and the node displayed correctly.
If only the code of the node changes (the execute method) than it is not needed as each workflow automatically starts a new process and so will always load the latest code.
## Checklist before submitting a new node ## Checklist before submitting a new node
If you'd like to submit a new node, please go through the following checklist. This will help us be quicker to review and merge your PR. There are several things to keep in mind when creating a node. To help you, we prepared a [checklist](https://docs.n8n.io/nodes/creating-nodes/node-review-checklist.html) that covers the requirements for creating nodes, from preparation to submission. This will help us be quicker to review and merge your PR.
- [ ] Make failing requests to the API to ensure that the errors get displayed correctly (like malformed requests or requests with invalid credentials)
- [ ] Ensure that the default values do not change and that the parameters do not get renamed, as it would break the existing workflows of the users
- [ ] Ensure that all the top-level parameters use camelCase
- [ ] Ensure that all the options are ordered alphabetically, unless a different order is needed for a specific reason
- [ ] Ensure that the parameters have the correct type
- [ ] Make sure that the file-name and the Class name are identical (case sensitive). The name under "description" in the node-code should also be identical (except that it starts with a lower-case letter and that it will never have a space)
- [ ] Names of Trigger-Nodes always have to end with "Trigger"
- [ ] Add credentials and nodes to the `package.json` file in alphanumerical order
- [ ] Use tabs in all the files except in the `package.json` file, where 4-spaces have to get used
- [ ] To make it as simple as possible for the users, check other similar nodes to ensure that they all behave similarly
- [ ] Try to add as few parameters as possible on the main level to ensure that the node doesn't appear overwhelming. It should only contain the required parameters. All the other ones should be hidden on lower levels as "Additional Parameters" or "Options"
- [ ] Create only one node per service which can do everything via "Resource" and "Options" and not a separate one for each possible operation.
## Extend Documentation ## Extend Documentation
The repository for the n8n documentation on https://docs.n8n.io can be found [here](https://github.com/n8n-io/n8n-docs). The repository for the n8n documentation on [docs.n8n.io](https://docs.n8n.io) can be found [here](https://github.com/n8n-io/n8n-docs).
## Contributor License Agreement ## Contributor License Agreement

View file

@ -15,6 +15,7 @@ ENV NODE_ENV production
WORKDIR /data WORKDIR /data
USER node USER root
CMD n8n CMD chown -R node:node /home/node/.n8n \
&& gosu node n8n

View file

@ -2,6 +2,36 @@
This list shows all the versions which include breaking changes and how to upgrade. This list shows all the versions which include breaking changes and how to upgrade.
## 0.113.0
### What changed?
In the Dropbox node, both credential types (Access Token & OAuth2) have a new parameter called "APP Access Type".
### When is action necessary?
If you are using a Dropbox APP with permission type, "App Folder".
### How to upgrade:
Open your Dropbox node's credentials and set the "APP Access Type" parameter to "App Folder".
## 0.111.0
### What changed?
In the Dropbox node, now all operations are performed relative to the user's root directory.
### When is action necessary?
If you are using any resource/operation with OAuth2 authentication.
If you are using the `folder:list` operation with the parameter `Folder Path` empty (root path) and have a Team Space in your Dropbox account.
### How to upgrade:
Open the Dropbox node, go to the OAuth2 credential you are using and reconnect it again.
Also, if you are using the `folder:list` operation, make sure your logic is taking into account the team folders in the response.
## 0.105.0 ## 0.105.0
### What changed? ### What changed?

View file

@ -21,7 +21,7 @@ import {
WorkflowCredentials, WorkflowCredentials,
WorkflowHelpers, WorkflowHelpers,
WorkflowRunner, WorkflowRunner,
} from "../src"; } from '../src';
export class Execute extends Command { export class Execute extends Command {
@ -130,7 +130,7 @@ export class Execute extends Command {
// Check if the workflow contains the required "Start" node // Check if the workflow contains the required "Start" node
// "requiredNodeTypes" are also defined in editor-ui/views/NodeView.vue // "requiredNodeTypes" are also defined in editor-ui/views/NodeView.vue
const requiredNodeTypes = ['n8n-nodes-base.start']; const requiredNodeTypes = ['n8n-nodes-base.start'];
let startNode: INode | undefined= undefined; let startNode: INode | undefined = undefined;
for (const node of workflowData!.nodes) { for (const node of workflowData!.nodes) {
if (requiredNodeTypes.includes(node.type)) { if (requiredNodeTypes.includes(node.type)) {
startNode = node; startNode = node;

View file

@ -3,6 +3,11 @@ import {
flags, flags,
} from '@oclif/command'; } from '@oclif/command';
import {
Credentials,
UserSettings,
} from 'n8n-core';
import { import {
IDataObject IDataObject
} from 'n8n-workflow'; } from 'n8n-workflow';
@ -10,6 +15,7 @@ import {
import { import {
Db, Db,
GenericHelpers, GenericHelpers,
ICredentialsDecryptedDb,
} from '../../src'; } from '../../src';
import * as fs from 'fs'; import * as fs from 'fs';
@ -21,8 +27,9 @@ export class ExportCredentialsCommand extends Command {
static examples = [ static examples = [
`$ n8n export:credentials --all`, `$ n8n export:credentials --all`,
`$ n8n export:credentials --id=5 --output=file.json`, `$ n8n export:credentials --id=5 --output=file.json`,
`$ n8n export:credentials --all --output=backups/latest/`, `$ n8n export:credentials --all --output=backups/latest.json`,
`$ n8n export:credentials --backup --output=backups/latest/`, `$ n8n export:credentials --backup --output=backups/latest/`,
`$ n8n export:credentials --all --decrypted --output=backups/decrypted.json`,
]; ];
static flags = { static flags = {
@ -46,6 +53,9 @@ export class ExportCredentialsCommand extends Command {
separate: flags.boolean({ separate: flags.boolean({
description: 'Exports one file per credential (useful for versioning). Must inform a directory via --output.', description: 'Exports one file per credential (useful for versioning). Must inform a directory via --output.',
}), }),
decrypted: flags.boolean({
description: 'Exports data decrypted / in plain text. ALL SENSITIVE INFORMATION WILL BE VISIBLE IN THE FILES. Use to migrate from a installation to another that have a different secret key (in the config file).',
}),
}; };
async run() { async run() {
@ -108,6 +118,20 @@ export class ExportCredentialsCommand extends Command {
const credentials = await Db.collections.Credentials!.find(findQuery); const credentials = await Db.collections.Credentials!.find(findQuery);
if (flags.decrypted) {
const encryptionKey = await UserSettings.getEncryptionKey();
if (encryptionKey === undefined) {
throw new Error('No encryption key got found to decrypt the credentials!');
}
for (let i = 0; i < credentials.length; i++) {
const { name, type, nodesAccess, data } = credentials[i];
const credential = new Credentials(name, type, nodesAccess, data);
const plainData = credential.getData(encryptionKey);
(credentials[i] as ICredentialsDecryptedDb).data = plainData;
}
}
if (credentials.length === 0) { if (credentials.length === 0) {
throw new Error('No credentials found with specified filters.'); throw new Error('No credentials found with specified filters.');
} }
@ -116,7 +140,7 @@ export class ExportCredentialsCommand extends Command {
let fileContents: string, i: number; let fileContents: string, i: number;
for (i = 0; i < credentials.length; i++) { for (i = 0; i < credentials.length; i++) {
fileContents = JSON.stringify(credentials[i], null, flags.pretty ? 2 : undefined); fileContents = JSON.stringify(credentials[i], null, flags.pretty ? 2 : undefined);
const filename = (flags.output!.endsWith(path.sep) ? flags.output! : flags.output + path.sep) + credentials[i].id + ".json"; const filename = (flags.output!.endsWith(path.sep) ? flags.output! : flags.output + path.sep) + credentials[i].id + '.json';
fs.writeFileSync(filename, fileContents); fs.writeFileSync(filename, fileContents);
} }
console.log('Successfully exported', i, 'credentials.'); console.log('Successfully exported', i, 'credentials.');

View file

@ -116,7 +116,7 @@ export class ExportWorkflowsCommand extends Command {
let fileContents: string, i: number; let fileContents: string, i: number;
for (i = 0; i < workflows.length; i++) { for (i = 0; i < workflows.length; i++) {
fileContents = JSON.stringify(workflows[i], null, flags.pretty ? 2 : undefined); fileContents = JSON.stringify(workflows[i], null, flags.pretty ? 2 : undefined);
const filename = (flags.output!.endsWith(path.sep) ? flags.output! : flags.output + path.sep) + workflows[i].id + ".json"; const filename = (flags.output!.endsWith(path.sep) ? flags.output! : flags.output + path.sep) + workflows[i].id + '.json';
fs.writeFileSync(filename, fileContents); fs.writeFileSync(filename, fileContents);
} }
console.log('Successfully exported', i, 'workflows.'); console.log('Successfully exported', i, 'workflows.');

View file

@ -3,6 +3,11 @@ import {
flags, flags,
} from '@oclif/command'; } from '@oclif/command';
import {
Credentials,
UserSettings,
} from 'n8n-core';
import { import {
Db, Db,
GenericHelpers, GenericHelpers,
@ -51,10 +56,22 @@ export class ImportCredentialsCommand extends Command {
try { try {
await Db.init(); await Db.init();
let i; let i;
const encryptionKey = await UserSettings.getEncryptionKey();
if (encryptionKey === undefined) {
throw new Error('No encryption key got found to encrypt the credentials!');
}
if (flags.separate) { if (flags.separate) {
const files = await glob((flags.input.endsWith(path.sep) ? flags.input : flags.input + path.sep) + '*.json'); const files = await glob((flags.input.endsWith(path.sep) ? flags.input : flags.input + path.sep) + '*.json');
for (i = 0; i < files.length; i++) { for (i = 0; i < files.length; i++) {
const credential = JSON.parse(fs.readFileSync(files[i], { encoding: 'utf8' })); const credential = JSON.parse(fs.readFileSync(files[i], { encoding: 'utf8' }));
if (typeof credential.data === 'object') {
// plain data / decrypted input. Should be encrypted first.
Credentials.prototype.setData.call(credential, credential.data, encryptionKey);
}
await Db.collections.Credentials!.save(credential); await Db.collections.Credentials!.save(credential);
} }
} else { } else {
@ -65,6 +82,10 @@ export class ImportCredentialsCommand extends Command {
} }
for (i = 0; i < fileContents.length; i++) { for (i = 0; i < fileContents.length; i++) {
if (typeof fileContents[i].data === 'object') {
// plain data / decrypted input. Should be encrypted first.
Credentials.prototype.setData.call(fileContents[i], fileContents[i].data, encryptionKey);
}
await Db.collections.Credentials!.save(fileContents[i]); await Db.collections.Credentials!.save(fileContents[i]);
} }
} }

View file

@ -17,11 +17,12 @@ import {
Db, Db,
ExternalHooks, ExternalHooks,
GenericHelpers, GenericHelpers,
IExecutionsCurrentSummary,
LoadNodesAndCredentials, LoadNodesAndCredentials,
NodeTypes, NodeTypes,
Server, Server,
TestWebhooks, TestWebhooks,
} from "../src"; } from '../src';
import { IDataObject } from 'n8n-workflow'; import { IDataObject } from 'n8n-workflow';
@ -97,12 +98,15 @@ export class Start extends Command {
// Wait for active workflow executions to finish // Wait for active workflow executions to finish
const activeExecutionsInstance = ActiveExecutions.getInstance(); const activeExecutionsInstance = ActiveExecutions.getInstance();
let executingWorkflows = activeExecutionsInstance.getActiveExecutions(); let executingWorkflows = activeExecutionsInstance.getActiveExecutions() as IExecutionsCurrentSummary[];
let count = 0; let count = 0;
while (executingWorkflows.length !== 0) { while (executingWorkflows.length !== 0) {
if (count++ % 4 === 0) { if (count++ % 4 === 0) {
console.log(`Waiting for ${executingWorkflows.length} active executions to finish...`); console.log(`Waiting for ${executingWorkflows.length} active executions to finish...`);
executingWorkflows.map(execution => {
console.log(` - Execution ID ${execution.id}, workflow ID: ${execution.workflowId}`);
});
} }
await new Promise((resolve) => { await new Promise((resolve) => {
setTimeout(resolve, 500); setTimeout(resolve, 500);
@ -129,7 +133,7 @@ export class Start extends Command {
await (async () => { await (async () => {
try { try {
// Start directly with the init of the database to improve startup time // Start directly with the init of the database to improve startup time
const startDbInitPromise = Db.init().catch(error => { const startDbInitPromise = Db.init().catch((error: Error) => {
console.error(`There was an error initializing DB: ${error.message}`); console.error(`There was an error initializing DB: ${error.message}`);
processExistCode = 1; processExistCode = 1;
@ -168,7 +172,7 @@ export class Start extends Command {
const redisDB = config.get('queue.bull.redis.db'); const redisDB = config.get('queue.bull.redis.db');
const redisConnectionTimeoutLimit = config.get('queue.bull.redis.timeoutThreshold'); const redisConnectionTimeoutLimit = config.get('queue.bull.redis.timeoutThreshold');
let lastTimer = 0, cumulativeTimeout = 0; let lastTimer = 0, cumulativeTimeout = 0;
const settings = { const settings = {
retryStrategy: (times: number): number | null => { retryStrategy: (times: number): number | null => {
const now = Date.now(); const now = Date.now();
@ -180,7 +184,7 @@ export class Start extends Command {
cumulativeTimeout += now - lastTimer; cumulativeTimeout += now - lastTimer;
lastTimer = now; lastTimer = now;
if (cumulativeTimeout > redisConnectionTimeoutLimit) { if (cumulativeTimeout > redisConnectionTimeoutLimit) {
console.error('Unable to connect to Redis after ' + redisConnectionTimeoutLimit + ". Exiting process."); console.error('Unable to connect to Redis after ' + redisConnectionTimeoutLimit + '. Exiting process.');
process.exit(1); process.exit(1);
} }
} }
@ -200,7 +204,7 @@ export class Start extends Command {
if (redisDB) { if (redisDB) {
settings.db = redisDB; settings.db = redisDB;
} }
// This connection is going to be our heartbeat // This connection is going to be our heartbeat
// IORedis automatically pings redis and tries to reconnect // IORedis automatically pings redis and tries to reconnect
// We will be using the retryStrategy above // We will be using the retryStrategy above
@ -215,13 +219,13 @@ export class Start extends Command {
} }
}); });
} }
const dbType = await GenericHelpers.getConfigValue('database.type') as DatabaseType; const dbType = await GenericHelpers.getConfigValue('database.type') as DatabaseType;
if (dbType === 'sqlite') { if (dbType === 'sqlite') {
const shouldRunVacuum = config.get('database.sqlite.executeVacuumOnStartup') as number; const shouldRunVacuum = config.get('database.sqlite.executeVacuumOnStartup') as number;
if (shouldRunVacuum) { if (shouldRunVacuum) {
Db.collections.Execution!.query("VACUUM;"); Db.collections.Execution!.query('VACUUM;');
} }
} }
@ -280,7 +284,7 @@ export class Start extends Command {
Start.openBrowser(); Start.openBrowser();
} }
this.log(`\nPress "o" to open in Browser.`); this.log(`\nPress "o" to open in Browser.`);
process.stdin.on("data", (key : string) => { process.stdin.on('data', (key: string) => {
if (key === 'o') { if (key === 'o') {
Start.openBrowser(); Start.openBrowser();
inputText = ''; inputText = '';

View file

@ -9,7 +9,7 @@ import {
import { import {
Db, Db,
GenericHelpers, GenericHelpers,
} from "../../src"; } from '../../src';
export class UpdateWorkflowCommand extends Command { export class UpdateWorkflowCommand extends Command {

View file

@ -17,7 +17,7 @@ import {
NodeTypes, NodeTypes,
TestWebhooks, TestWebhooks,
WebhookServer, WebhookServer,
} from "../src"; } from '../src';
import { IDataObject } from 'n8n-workflow'; import { IDataObject } from 'n8n-workflow';
@ -98,7 +98,7 @@ export class Webhook extends Command {
// Wrap that the process does not close but we can still use async // Wrap that the process does not close but we can still use async
await (async () => { await (async () => {
if (config.get('executions.mode') !== 'queue') { if (config.get('executions.mode') !== 'queue') {
/** /**
* It is technically possible to run without queues but * It is technically possible to run without queues but
* there are 2 known bugs when running in this mode: * there are 2 known bugs when running in this mode:
* - Executions list will be problematic as the main process * - Executions list will be problematic as the main process
@ -154,7 +154,7 @@ export class Webhook extends Command {
const redisDB = config.get('queue.bull.redis.db'); const redisDB = config.get('queue.bull.redis.db');
const redisConnectionTimeoutLimit = config.get('queue.bull.redis.timeoutThreshold'); const redisConnectionTimeoutLimit = config.get('queue.bull.redis.timeoutThreshold');
let lastTimer = 0, cumulativeTimeout = 0; let lastTimer = 0, cumulativeTimeout = 0;
const settings = { const settings = {
retryStrategy: (times: number): number | null => { retryStrategy: (times: number): number | null => {
const now = Date.now(); const now = Date.now();
@ -166,7 +166,7 @@ export class Webhook extends Command {
cumulativeTimeout += now - lastTimer; cumulativeTimeout += now - lastTimer;
lastTimer = now; lastTimer = now;
if (cumulativeTimeout > redisConnectionTimeoutLimit) { if (cumulativeTimeout > redisConnectionTimeoutLimit) {
console.error('Unable to connect to Redis after ' + redisConnectionTimeoutLimit + ". Exiting process."); console.error('Unable to connect to Redis after ' + redisConnectionTimeoutLimit + '. Exiting process.');
process.exit(1); process.exit(1);
} }
} }
@ -186,7 +186,7 @@ export class Webhook extends Command {
if (redisDB) { if (redisDB) {
settings.db = redisDB; settings.db = redisDB;
} }
// This connection is going to be our heartbeat // This connection is going to be our heartbeat
// IORedis automatically pings redis and tries to reconnect // IORedis automatically pings redis and tries to reconnect
// We will be using the retryStrategy above // We will be using the retryStrategy above
@ -201,7 +201,7 @@ export class Webhook extends Command {
} }
}); });
} }
await WebhookServer.start(); await WebhookServer.start();
// Start to get active workflows and run their triggers // Start to get active workflows and run their triggers

View file

@ -35,7 +35,7 @@ import {
ResponseHelper, ResponseHelper,
WorkflowCredentials, WorkflowCredentials,
WorkflowExecuteAdditionalData, WorkflowExecuteAdditionalData,
} from "../src"; } from '../src';
import * as config from '../config'; import * as config from '../config';
import * as Bull from 'bull'; import * as Bull from 'bull';
@ -132,7 +132,7 @@ export class Worker extends Command {
const credentials = await WorkflowCredentials(currentExecutionDb.workflowData.nodes); const credentials = await WorkflowCredentials(currentExecutionDb.workflowData.nodes);
const additionalData = await WorkflowExecuteAdditionalData.getBase(credentials); const additionalData = await WorkflowExecuteAdditionalData.getBase(credentials);
additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksIntegrated(currentExecutionDb.mode, job.data.executionId, currentExecutionDb.workflowData, { retryOf: currentExecutionDb.retryOf as string }); additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter(currentExecutionDb.mode, job.data.executionId, currentExecutionDb.workflowData, { retryOf: currentExecutionDb.retryOf as string });
let workflowExecute: WorkflowExecute; let workflowExecute: WorkflowExecute;
let workflowRun: PCancelable<IRun>; let workflowRun: PCancelable<IRun>;
@ -241,7 +241,7 @@ export class Worker extends Command {
cumulativeTimeout += now - lastTimer; cumulativeTimeout += now - lastTimer;
lastTimer = now; lastTimer = now;
if (cumulativeTimeout > redisConnectionTimeoutLimit) { if (cumulativeTimeout > redisConnectionTimeoutLimit) {
console.error('Unable to connect to Redis after ' + redisConnectionTimeoutLimit + ". Exiting process."); console.error('Unable to connect to Redis after ' + redisConnectionTimeoutLimit + '. Exiting process.');
process.exit(1); process.exit(1);
} }
} }

View file

@ -446,6 +446,20 @@ const config = convict({
}, },
endpoints: { endpoints: {
metrics: {
enable: {
format: 'Boolean',
default: false,
env: 'N8N_METRICS',
doc: 'Enable metrics endpoint',
},
prefix: {
format: String,
default: 'n8n_',
env: 'N8N_METRICS_PREFIX',
doc: 'An optional prefix for metric names. Default: n8n_',
},
},
rest: { rest: {
format: String, format: String,
default: 'rest', default: 'rest',
@ -471,7 +485,7 @@ const config = convict({
doc: 'Disable production webhooks from main process. This helps ensures no http traffic load to main process when using webhook-specific processes.', doc: 'Disable production webhooks from main process. This helps ensures no http traffic load to main process when using webhook-specific processes.',
}, },
skipWebhoooksDeregistrationOnShutdown: { skipWebhoooksDeregistrationOnShutdown: {
/** /**
* Longer explanation: n8n deregisters webhooks on shutdown / deactivation * Longer explanation: n8n deregisters webhooks on shutdown / deactivation
* and registers on startup / activation. If we skip * and registers on startup / activation. If we skip
* deactivation on shutdown, webhooks will remain active on 3rd party services. * deactivation on shutdown, webhooks will remain active on 3rd party services.
@ -483,10 +497,10 @@ const config = convict({
* WARNING: Trigger nodes (like Cron) will cause duplication * WARNING: Trigger nodes (like Cron) will cause duplication
* of work, so be aware when using. * of work, so be aware when using.
*/ */
doc: 'Deregister webhooks on external services only when workflows are deactivated. Useful for blue/green deployments.', doc: 'Deregister webhooks on external services only when workflows are deactivated.',
format: Boolean, format: Boolean,
default: false, default: false,
env: 'N8N_SKIP_WEBHOOK_DEREGISTRATION_STARTUP_SHUTDOWN', env: 'N8N_SKIP_WEBHOOK_DEREGISTRATION_SHUTDOWN',
}, },
}, },

View file

@ -1,139 +1,140 @@
{ {
"name": "n8n", "name": "n8n",
"version": "0.107.0", "version": "0.114.0",
"description": "n8n Workflow Automation Tool", "description": "n8n Workflow Automation Tool",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",
"author": { "author": {
"name": "Jan Oberhauser", "name": "Jan Oberhauser",
"email": "jan@n8n.io" "email": "jan@n8n.io"
},
"repository": {
"type": "git",
"url": "git+https://github.com/n8n-io/n8n.git"
},
"main": "dist/index",
"types": "dist/src/index.d.ts",
"oclif": {
"commands": "./dist/commands",
"bin": "n8n"
},
"scripts": {
"build": "tsc",
"dev": "concurrently -k -n \"TypeScript,Node\" -c \"yellow.bold,cyan.bold\" \"npm run watch\" \"nodemon\"",
"postpack": "rm -f oclif.manifest.json",
"prepack": "echo \"Building project...\" && rm -rf dist && tsc -b && oclif-dev manifest",
"start": "run-script-os",
"start:default": "cd bin && ./n8n",
"start:windows": "cd bin && n8n",
"test": "jest",
"tslint": "tslint -p tsconfig.json -c tslint.json",
"tslintfix": "tslint --fix -p tsconfig.json -c tslint.json",
"watch": "tsc --watch",
"typeorm": "ts-node ./node_modules/typeorm/cli.js"
},
"bin": {
"n8n": "./bin/n8n"
},
"keywords": [
"automate",
"automation",
"IaaS",
"iPaaS",
"n8n",
"workflow"
],
"engines": {
"node": ">=12.0.0"
},
"files": [
"bin",
"templates",
"dist",
"oclif.manifest.json"
],
"devDependencies": {
"@oclif/dev-cli": "^1.22.2",
"@types/basic-auth": "^1.1.2",
"@types/bcryptjs": "^2.4.1",
"@types/bull": "^3.3.10",
"@types/compression": "1.0.1",
"@types/connect-history-api-fallback": "^1.3.1",
"@types/convict": "^4.2.1",
"@types/dotenv": "^8.2.0",
"@types/express": "^4.17.6",
"@types/jest": "^26.0.13",
"@types/localtunnel": "^1.9.0",
"@types/lodash.get": "^4.4.6",
"@types/node": "14.0.27",
"@types/open": "^6.1.0",
"@types/parseurl": "^1.3.1",
"@types/request-promise-native": "~1.0.15",
"concurrently": "^5.1.0",
"jest": "^26.4.2",
"nodemon": "^2.0.2",
"p-cancelable": "^2.0.0",
"run-script-os": "^1.0.7",
"ts-jest": "^26.3.0",
"ts-node": "^8.9.1",
"tslint": "^6.1.2",
"typescript": "~3.9.7"
},
"dependencies": {
"@oclif/command": "^1.5.18",
"@oclif/errors": "^1.2.2",
"@types/json-diff": "^0.5.1",
"@types/jsonwebtoken": "^8.3.4",
"basic-auth": "^2.0.1",
"bcryptjs": "^2.4.3",
"body-parser": "^1.18.3",
"body-parser-xml": "^1.1.0",
"bull": "^3.19.0",
"client-oauth2": "^4.2.5",
"compression": "^1.7.4",
"connect-history-api-fallback": "^1.6.0",
"convict": "^6.0.1",
"csrf": "^3.1.0",
"dotenv": "^8.0.0",
"express": "^4.16.4",
"flatted": "^2.0.0",
"glob-promise": "^3.4.0",
"google-timezones-json": "^1.0.2",
"inquirer": "^7.0.1",
"json-diff": "^0.5.4",
"jsonwebtoken": "^8.5.1",
"jwks-rsa": "~1.12.1",
"localtunnel": "^2.0.0",
"lodash.get": "^4.4.2",
"mysql2": "~2.1.0",
"n8n-core": "~0.67.0",
"n8n-editor-ui": "~0.84.0",
"n8n-nodes-base": "~0.111.0",
"n8n-workflow": "~0.55.0",
"oauth-1.0a": "^2.2.6",
"open": "^7.0.0",
"pg": "^8.3.0",
"prom-client": "^13.1.0",
"request-promise-native": "^1.0.7",
"sqlite3": "^5.0.1",
"sse-channel": "^3.1.1",
"tslib": "1.11.2",
"typeorm": "^0.2.30"
},
"jest": {
"transform": {
"^.+\\.tsx?$": "ts-jest"
}, },
"repository": { "testURL": "http://localhost/",
"type": "git", "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
"url": "git+https://github.com/n8n-io/n8n.git" "testPathIgnorePatterns": [
}, "/dist/",
"main": "dist/index", "/node_modules/"
"types": "dist/src/index.d.ts",
"oclif": {
"commands": "./dist/commands",
"bin": "n8n"
},
"scripts": {
"build": "tsc",
"dev": "concurrently -k -n \"TypeScript,Node\" -c \"yellow.bold,cyan.bold\" \"npm run watch\" \"nodemon\"",
"postpack": "rm -f oclif.manifest.json",
"prepack": "echo \"Building project...\" && rm -rf dist && tsc -b && oclif-dev manifest",
"start": "run-script-os",
"start:default": "cd bin && ./n8n",
"start:windows": "cd bin && n8n",
"test": "jest",
"tslint": "tslint -p tsconfig.json -c tslint.json",
"tslintfix": "tslint --fix -p tsconfig.json -c tslint.json",
"watch": "tsc --watch",
"typeorm": "ts-node ./node_modules/typeorm/cli.js"
},
"bin": {
"n8n": "./bin/n8n"
},
"keywords": [
"automate",
"automation",
"IaaS",
"iPaaS",
"n8n",
"workflow"
], ],
"engines": { "moduleFileExtensions": [
"node": ">=12.0.0" "ts",
}, "tsx",
"files": [ "js",
"bin", "json"
"templates", ]
"dist", }
"oclif.manifest.json"
],
"devDependencies": {
"@oclif/dev-cli": "^1.22.2",
"@types/basic-auth": "^1.1.2",
"@types/bcryptjs": "^2.4.1",
"@types/bull": "^3.3.10",
"@types/compression": "1.0.1",
"@types/connect-history-api-fallback": "^1.3.1",
"@types/convict": "^4.2.1",
"@types/dotenv": "^8.2.0",
"@types/express": "^4.17.6",
"@types/jest": "^26.0.13",
"@types/localtunnel": "^1.9.0",
"@types/lodash.get": "^4.4.6",
"@types/node": "14.0.27",
"@types/open": "^6.1.0",
"@types/parseurl": "^1.3.1",
"@types/request-promise-native": "~1.0.15",
"concurrently": "^5.1.0",
"jest": "^26.4.2",
"nodemon": "^2.0.2",
"p-cancelable": "^2.0.0",
"run-script-os": "^1.0.7",
"ts-jest": "^26.3.0",
"ts-node": "^8.9.1",
"tslint": "^6.1.2",
"typescript": "~3.9.7"
},
"dependencies": {
"@oclif/command": "^1.5.18",
"@oclif/errors": "^1.2.2",
"@types/json-diff": "^0.5.1",
"@types/jsonwebtoken": "^8.3.4",
"basic-auth": "^2.0.1",
"bcryptjs": "^2.4.3",
"body-parser": "^1.18.3",
"body-parser-xml": "^1.1.0",
"bull": "^3.19.0",
"client-oauth2": "^4.2.5",
"compression": "^1.7.4",
"connect-history-api-fallback": "^1.6.0",
"convict": "^5.0.0",
"csrf": "^3.1.0",
"dotenv": "^8.0.0",
"express": "^4.16.4",
"flatted": "^2.0.0",
"glob-promise": "^3.4.0",
"google-timezones-json": "^1.0.2",
"inquirer": "^7.0.1",
"json-diff": "^0.5.4",
"jsonwebtoken": "^8.5.1",
"jwks-rsa": "~1.9.0",
"localtunnel": "^2.0.0",
"lodash.get": "^4.4.2",
"mysql2": "~2.1.0",
"n8n-core": "~0.62.0",
"n8n-editor-ui": "~0.77.0",
"n8n-nodes-base": "~0.104.0",
"n8n-workflow": "~0.51.0",
"oauth-1.0a": "^2.2.6",
"open": "^7.0.0",
"pg": "^8.3.0",
"request-promise-native": "^1.0.7",
"sqlite3": "^5.0.1",
"sse-channel": "^3.1.1",
"tslib": "1.11.2",
"typeorm": "^0.2.30"
},
"jest": {
"transform": {
"^.+\\.tsx?$": "ts-jest"
},
"testURL": "http://localhost/",
"testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
"testPathIgnorePatterns": [
"/dist/",
"/node_modules/"
],
"moduleFileExtensions": [
"ts",
"tsx",
"js",
"json"
]
}
} }

View file

@ -31,6 +31,7 @@ import {
NodeHelpers, NodeHelpers,
WebhookHttpMethod, WebhookHttpMethod,
Workflow, Workflow,
WorkflowActivateMode,
WorkflowExecuteMode, WorkflowExecuteMode,
} from 'n8n-workflow'; } from 'n8n-workflow';
@ -66,7 +67,7 @@ export class ActiveWorkflowRunner {
for (const workflowData of workflowsData) { for (const workflowData of workflowsData) {
console.log(` - ${workflowData.name}`); console.log(` - ${workflowData.name}`);
try { try {
await this.add(workflowData.id.toString(), workflowData); await this.add(workflowData.id.toString(), 'init', workflowData);
console.log(` => Started`); console.log(` => Started`);
} catch (error) { } catch (error) {
console.log(` => ERROR: Workflow could not be activated:`); console.log(` => ERROR: Workflow could not be activated:`);
@ -273,7 +274,7 @@ export class ActiveWorkflowRunner {
* @returns {Promise<void>} * @returns {Promise<void>}
* @memberof ActiveWorkflowRunner * @memberof ActiveWorkflowRunner
*/ */
async addWorkflowWebhooks(workflow: Workflow, additionalData: IWorkflowExecuteAdditionalDataWorkflow, mode: WorkflowExecuteMode): Promise<void> { async addWorkflowWebhooks(workflow: Workflow, additionalData: IWorkflowExecuteAdditionalDataWorkflow, mode: WorkflowExecuteMode, activation: WorkflowActivateMode): Promise<void> {
const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData); const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData);
let path = '' as string | undefined; let path = '' as string | undefined;
@ -319,10 +320,10 @@ export class ActiveWorkflowRunner {
await Db.collections.Webhook?.insert(webhook); await Db.collections.Webhook?.insert(webhook);
const webhookExists = await workflow.runWebhookMethod('checkExists', webhookData, NodeExecuteFunctions, mode, false); const webhookExists = await workflow.runWebhookMethod('checkExists', webhookData, NodeExecuteFunctions, mode, activation, false);
if (webhookExists !== true) { if (webhookExists !== true) {
// If webhook does not exist yet create it // If webhook does not exist yet create it
await workflow.runWebhookMethod('create', webhookData, NodeExecuteFunctions, mode, false); await workflow.runWebhookMethod('create', webhookData, NodeExecuteFunctions, mode, activation, false);
} }
} catch (error) { } catch (error) {
@ -378,7 +379,7 @@ export class ActiveWorkflowRunner {
const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData); const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData);
for (const webhookData of webhooks) { for (const webhookData of webhooks) {
await workflow.runWebhookMethod('delete', webhookData, NodeExecuteFunctions, mode, false); await workflow.runWebhookMethod('delete', webhookData, NodeExecuteFunctions, mode, 'update', false);
} }
await WorkflowHelpers.saveStaticData(workflow); await WorkflowHelpers.saveStaticData(workflow);
@ -446,9 +447,9 @@ export class ActiveWorkflowRunner {
* @returns {IGetExecutePollFunctions} * @returns {IGetExecutePollFunctions}
* @memberof ActiveWorkflowRunner * @memberof ActiveWorkflowRunner
*/ */
getExecutePollFunctions(workflowData: IWorkflowDb, additionalData: IWorkflowExecuteAdditionalDataWorkflow, mode: WorkflowExecuteMode): IGetExecutePollFunctions { getExecutePollFunctions(workflowData: IWorkflowDb, additionalData: IWorkflowExecuteAdditionalDataWorkflow, mode: WorkflowExecuteMode, activation: WorkflowActivateMode): IGetExecutePollFunctions {
return ((workflow: Workflow, node: INode) => { return ((workflow: Workflow, node: INode) => {
const returnFunctions = NodeExecuteFunctions.getExecutePollFunctions(workflow, node, additionalData, mode); const returnFunctions = NodeExecuteFunctions.getExecutePollFunctions(workflow, node, additionalData, mode, activation);
returnFunctions.__emit = (data: INodeExecutionData[][]): void => { returnFunctions.__emit = (data: INodeExecutionData[][]): void => {
this.runWorkflow(workflowData, node, data, additionalData, mode); this.runWorkflow(workflowData, node, data, additionalData, mode);
}; };
@ -467,9 +468,9 @@ export class ActiveWorkflowRunner {
* @returns {IGetExecuteTriggerFunctions} * @returns {IGetExecuteTriggerFunctions}
* @memberof ActiveWorkflowRunner * @memberof ActiveWorkflowRunner
*/ */
getExecuteTriggerFunctions(workflowData: IWorkflowDb, additionalData: IWorkflowExecuteAdditionalDataWorkflow, mode: WorkflowExecuteMode): IGetExecuteTriggerFunctions{ getExecuteTriggerFunctions(workflowData: IWorkflowDb, additionalData: IWorkflowExecuteAdditionalDataWorkflow, mode: WorkflowExecuteMode, activation: WorkflowActivateMode): IGetExecuteTriggerFunctions{
return ((workflow: Workflow, node: INode) => { return ((workflow: Workflow, node: INode) => {
const returnFunctions = NodeExecuteFunctions.getExecuteTriggerFunctions(workflow, node, additionalData, mode); const returnFunctions = NodeExecuteFunctions.getExecuteTriggerFunctions(workflow, node, additionalData, mode, activation);
returnFunctions.emit = (data: INodeExecutionData[][]): void => { returnFunctions.emit = (data: INodeExecutionData[][]): void => {
WorkflowHelpers.saveStaticData(workflow); WorkflowHelpers.saveStaticData(workflow);
this.runWorkflow(workflowData, node, data, additionalData, mode).catch((err) => console.error(err)); this.runWorkflow(workflowData, node, data, additionalData, mode).catch((err) => console.error(err));
@ -486,7 +487,7 @@ export class ActiveWorkflowRunner {
* @returns {Promise<void>} * @returns {Promise<void>}
* @memberof ActiveWorkflowRunner * @memberof ActiveWorkflowRunner
*/ */
async add(workflowId: string, workflowData?: IWorkflowDb): Promise<void> { async add(workflowId: string, activation: WorkflowActivateMode, workflowData?: IWorkflowDb): Promise<void> {
if (this.activeWorkflows === null) { if (this.activeWorkflows === null) {
throw new Error(`The "activeWorkflows" instance did not get initialized yet.`); throw new Error(`The "activeWorkflows" instance did not get initialized yet.`);
} }
@ -511,15 +512,15 @@ export class ActiveWorkflowRunner {
const mode = 'trigger'; const mode = 'trigger';
const credentials = await WorkflowCredentials(workflowData.nodes); const credentials = await WorkflowCredentials(workflowData.nodes);
const additionalData = await WorkflowExecuteAdditionalData.getBase(credentials); const additionalData = await WorkflowExecuteAdditionalData.getBase(credentials);
const getTriggerFunctions = this.getExecuteTriggerFunctions(workflowData, additionalData, mode); const getTriggerFunctions = this.getExecuteTriggerFunctions(workflowData, additionalData, mode, activation);
const getPollFunctions = this.getExecutePollFunctions(workflowData, additionalData, mode); const getPollFunctions = this.getExecutePollFunctions(workflowData, additionalData, mode, activation);
// Add the workflows which have webhooks defined // Add the workflows which have webhooks defined
await this.addWorkflowWebhooks(workflowInstance, additionalData, mode); await this.addWorkflowWebhooks(workflowInstance, additionalData, mode, activation);
if (workflowInstance.getTriggerNodes().length !== 0 if (workflowInstance.getTriggerNodes().length !== 0
|| workflowInstance.getPollNodes().length !== 0) { || workflowInstance.getPollNodes().length !== 0) {
await this.activeWorkflows.add(workflowId, workflowInstance, additionalData, getTriggerFunctions, getPollFunctions); await this.activeWorkflows.add(workflowId, workflowInstance, additionalData, mode, activation, getTriggerFunctions, getPollFunctions);
} }
if (this.activationErrors[workflowId] !== undefined) { if (this.activationErrors[workflowId] !== undefined) {

View file

@ -95,14 +95,15 @@ export async function getConfigValue(configKey: string): Promise<string | boolea
// Get the environment variable // Get the environment variable
const configSchema = config.getSchema(); const configSchema = config.getSchema();
let currentSchema = configSchema.properties as IDataObject; // @ts-ignore
let currentSchema = configSchema._cvtProperties as IDataObject;
for (const key of configKeyParts) { for (const key of configKeyParts) {
if (currentSchema[key] === undefined) { if (currentSchema[key] === undefined) {
throw new Error(`Key "${key}" of ConfigKey "${configKey}" does not exist`); throw new Error(`Key "${key}" of ConfigKey "${configKey}" does not exist`);
} else if ((currentSchema[key]! as IDataObject).properties === undefined) { } else if ((currentSchema[key]! as IDataObject)._cvtProperties === undefined) {
currentSchema = currentSchema[key] as IDataObject; currentSchema = currentSchema[key] as IDataObject;
} else { } else {
currentSchema = (currentSchema[key] as IDataObject).properties as IDataObject; currentSchema = (currentSchema[key] as IDataObject)._cvtProperties as IDataObject;
} }
} }

View file

@ -179,8 +179,7 @@ export interface IExecutionsStopData {
} }
export interface IExecutionsSummary { export interface IExecutionsSummary {
id?: string; // executionIdDb id: string;
idActive?: string; // executionIdActive
finished?: boolean; finished?: boolean;
mode: WorkflowExecuteMode; mode: WorkflowExecuteMode;
retryOf?: string; retryOf?: string;
@ -327,8 +326,7 @@ export type IPushDataType = 'executionFinished' | 'executionStarted' | 'nodeExec
export interface IPushDataExecutionFinished { export interface IPushDataExecutionFinished {
data: IRun; data: IRun;
executionIdActive: string; executionId: string;
executionIdDb?: string;
retryOf?: string; retryOf?: string;
} }

View file

@ -23,6 +23,7 @@ import * as csrf from 'csrf';
import * as requestPromise from 'request-promise-native'; import * as requestPromise from 'request-promise-native';
import { createHmac } from 'crypto'; import { createHmac } from 'crypto';
import { compare } from 'bcryptjs'; import { compare } from 'bcryptjs';
import * as promClient from 'prom-client';
import { import {
ActiveExecutions, ActiveExecutions,
@ -91,9 +92,7 @@ import {
import { import {
FindManyOptions, FindManyOptions,
FindOneOptions, FindOneOptions,
LessThan,
LessThanOrEqual, LessThanOrEqual,
MoreThanOrEqual,
Not, Not,
} from 'typeorm'; } from 'typeorm';
@ -108,6 +107,7 @@ import * as parseUrl from 'parseurl';
import * as querystring from 'querystring'; import * as querystring from 'querystring';
import * as Queue from '../src/Queue'; import * as Queue from '../src/Queue';
import { OptionsWithUrl } from 'request-promise-native'; import { OptionsWithUrl } from 'request-promise-native';
import { Registry } from 'prom-client';
class App { class App {
@ -197,6 +197,16 @@ class App {
async config(): Promise<void> { async config(): Promise<void> {
const enableMetrics = config.get('endpoints.metrics.enable') as boolean;
let register: Registry;
if (enableMetrics === true) {
const prefix = config.get('endpoints.metrics.prefix') as string;
register = new promClient.Registry();
register.setDefaultLabels({ prefix });
promClient.collectDefaultMetrics({ register });
}
this.versions = await GenericHelpers.getVersions(); this.versions = await GenericHelpers.getVersions();
this.frontendSettings.versionCli = this.versions.cli; this.frontendSettings.versionCli = this.versions.cli;
@ -204,7 +214,7 @@ class App {
const excludeEndpoints = config.get('security.excludeEndpoints') as string; const excludeEndpoints = config.get('security.excludeEndpoints') as string;
const ignoredEndpoints = ['healthz', this.endpointWebhook, this.endpointWebhookTest, this.endpointPresetCredentials]; const ignoredEndpoints = ['healthz', 'metrics', this.endpointWebhook, this.endpointWebhookTest, this.endpointPresetCredentials];
ignoredEndpoints.push.apply(ignoredEndpoints, excludeEndpoints.split(':')); ignoredEndpoints.push.apply(ignoredEndpoints, excludeEndpoints.split(':'));
const authIgnoreRegex = new RegExp(`^\/(${_(ignoredEndpoints).compact().join('|')})\/?.*$`); const authIgnoreRegex = new RegExp(`^\/(${_(ignoredEndpoints).compact().join('|')})\/?.*$`);
@ -386,7 +396,7 @@ class App {
this.app.use(history({ this.app.use(history({
rewrites: [ rewrites: [
{ {
from: new RegExp(`^\/(${this.restEndpoint}|healthz|css|js|${this.endpointWebhook}|${this.endpointWebhookTest})\/?.*$`), from: new RegExp(`^\/(${this.restEndpoint}|healthz|metrics|css|js|${this.endpointWebhook}|${this.endpointWebhookTest})\/?.*$`),
to: (context) => { to: (context) => {
return context.parsedUrl!.pathname!.toString(); return context.parsedUrl!.pathname!.toString();
}, },
@ -395,7 +405,8 @@ class App {
})); }));
//support application/x-www-form-urlencoded post data //support application/x-www-form-urlencoded post data
this.app.use(bodyParser.urlencoded({ extended: false, this.app.use(bodyParser.urlencoded({
extended: false,
verify: (req, res, buf) => { verify: (req, res, buf) => {
// @ts-ignore // @ts-ignore
req.rawBody = buf; req.rawBody = buf;
@ -453,7 +464,16 @@ class App {
ResponseHelper.sendSuccessResponse(res, responseData, true, 200); ResponseHelper.sendSuccessResponse(res, responseData, true, 200);
}); });
// ----------------------------------------
// Metrics
// ----------------------------------------
if (enableMetrics === true) {
this.app.get('/metrics', async (req: express.Request, res: express.Response) => {
const response = await register.metrics();
res.setHeader('Content-Type', register.contentType);
ResponseHelper.sendSuccessResponse(res, response, true, 200);
});
}
// ---------------------------------------- // ----------------------------------------
// Workflow // Workflow
@ -602,7 +622,7 @@ class App {
try { try {
await this.externalHooks.run('workflow.activate', [responseData]); await this.externalHooks.run('workflow.activate', [responseData]);
await this.activeWorkflowRunner.add(id); await this.activeWorkflowRunner.add(id, isActive ? 'update' : 'activate');
} catch (error) { } catch (error) {
// If workflow could not be activated set it again to inactive // If workflow could not be activated set it again to inactive
newWorkflowData.active = false; newWorkflowData.active = false;
@ -648,6 +668,7 @@ class App {
const startNodes: string[] | undefined = req.body.startNodes; const startNodes: string[] | undefined = req.body.startNodes;
const destinationNode: string | undefined = req.body.destinationNode; const destinationNode: string | undefined = req.body.destinationNode;
const executionMode = 'manual'; const executionMode = 'manual';
const activationMode = 'manual';
const sessionId = GenericHelpers.getSessionId(req); const sessionId = GenericHelpers.getSessionId(req);
@ -657,7 +678,7 @@ class App {
const additionalData = await WorkflowExecuteAdditionalData.getBase(credentials); const additionalData = await WorkflowExecuteAdditionalData.getBase(credentials);
const nodeTypes = NodeTypes(); const nodeTypes = NodeTypes();
const workflowInstance = new Workflow({ id: workflowData.id, name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, active: false, nodeTypes, staticData: undefined, settings: workflowData.settings }); const workflowInstance = new Workflow({ id: workflowData.id, name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, active: false, nodeTypes, staticData: undefined, settings: workflowData.settings });
const needsWebhook = await this.testWebhooks.needsWebhookData(workflowData, workflowInstance, additionalData, executionMode, sessionId, destinationNode); const needsWebhook = await this.testWebhooks.needsWebhookData(workflowData, workflowInstance, additionalData, executionMode, activationMode, sessionId, destinationNode);
if (needsWebhook === true) { if (needsWebhook === true) {
return { return {
waitingForWebhook: true, waitingForWebhook: true,
@ -725,7 +746,7 @@ class App {
// Make a copy of the object. If we don't do this, then when // Make a copy of the object. If we don't do this, then when
// The method below is called the properties are removed for good // The method below is called the properties are removed for good
// This happens because nodes are returned as reference. // This happens because nodes are returned as reference.
const nodeInfo: INodeTypeDescription = {...nodeData.description}; const nodeInfo: INodeTypeDescription = { ...nodeData.description };
if (req.query.includeProperties !== 'true') { if (req.query.includeProperties !== 'true') {
// @ts-ignore // @ts-ignore
delete nodeInfo.properties; delete nodeInfo.properties;
@ -1310,6 +1331,8 @@ class App {
// Verify and store app code. Generate access tokens and store for respective credential. // Verify and store app code. Generate access tokens and store for respective credential.
this.app.get(`/${this.restEndpoint}/oauth2-credential/callback`, async (req: express.Request, res: express.Response) => { this.app.get(`/${this.restEndpoint}/oauth2-credential/callback`, async (req: express.Request, res: express.Response) => {
// realmId it's currently just use for the quickbook OAuth2 flow
const { code, state: stateEncoded } = req.query; const { code, state: stateEncoded } = req.query;
if (code === undefined || stateEncoded === undefined) { if (code === undefined || stateEncoded === undefined) {
@ -1384,6 +1407,10 @@ class App {
const oauthToken = await oAuthObj.code.getToken(`${oAuth2Parameters.redirectUri}?${queryParameters}`, options); const oauthToken = await oAuthObj.code.getToken(`${oAuth2Parameters.redirectUri}?${queryParameters}`, options);
if (Object.keys(req.query).length > 2) {
_.set(oauthToken.data, 'callbackQueryString', _.omit(req.query, 'state', 'code'));
}
if (oauthToken === undefined) { if (oauthToken === undefined) {
const errorResponse = new ResponseHelper.ResponseError('Unable to get access tokens!', undefined, 404); const errorResponse = new ResponseHelper.ResponseError('Unable to get access tokens!', undefined, 404);
return ResponseHelper.sendErrorResponse(res, errorResponse); return ResponseHelper.sendErrorResponse(res, errorResponse);
@ -1430,14 +1457,14 @@ class App {
limit = parseInt(req.query.limit as string, 10); limit = parseInt(req.query.limit as string, 10);
} }
let executingWorkflowIds; const executingWorkflowIds: string[] = [];
if (config.get('executions.mode') === 'queue') { if (config.get('executions.mode') === 'queue') {
const currentJobs = await Queue.getInstance().getJobs(['active', 'waiting']); const currentJobs = await Queue.getInstance().getJobs(['active', 'waiting']);
executingWorkflowIds = currentJobs.map(job => job.data.executionId) as string[]; executingWorkflowIds.push(...currentJobs.map(job => job.data.executionId) as string[]);
} else {
executingWorkflowIds = this.activeExecutionsInstance.getActiveExecutions().map(execution => execution.id.toString()) as string[];
} }
// We may have manual executions even with queue so we must account for these.
executingWorkflowIds.push(...this.activeExecutionsInstance.getActiveExecutions().map(execution => execution.id.toString()) as string[]);
const countFilter = JSON.parse(JSON.stringify(filter)); const countFilter = JSON.parse(JSON.stringify(filter));
countFilter.select = ['id']; countFilter.select = ['id'];
@ -1472,7 +1499,7 @@ class App {
} }
const resultsPromise = resultsQuery.getMany(); const resultsPromise = resultsQuery.getMany();
const countPromise = Db.collections.Execution!.count(countFilter); const countPromise = Db.collections.Execution!.count(countFilter);
const results: IExecutionFlattedDb[] = await resultsPromise; const results: IExecutionFlattedDb[] = await resultsPromise;
@ -1510,7 +1537,7 @@ class App {
} }
if (req.query.unflattedResponse === 'true') { if (req.query.unflattedResponse === 'true') {
const fullExecutionData = ResponseHelper.unflattenExecutionData(result); const fullExecutionData = ResponseHelper.unflattenExecutionData(result);
return fullExecutionData as IExecutionResponse; return fullExecutionData as IExecutionResponse;
} else { } else {
// Convert to response format in which the id is a string // Convert to response format in which the id is a string
@ -1557,7 +1584,7 @@ class App {
delete data!.executionData!.resultData.error; delete data!.executionData!.resultData.error;
const length = data!.executionData!.resultData.runData[lastNodeExecuted].length; const length = data!.executionData!.resultData.runData[lastNodeExecuted].length;
if (length > 0 && data!.executionData!.resultData.runData[lastNodeExecuted][length - 1].error !== undefined) { if (length > 0 && data!.executionData!.resultData.runData[lastNodeExecuted][length - 1].error !== undefined) {
// Remove results only if it is an error. // Remove results only if it is an error.
// If we are retrying due to a crash, the information is simply success info from last node // If we are retrying due to a crash, the information is simply success info from last node
data!.executionData!.resultData.runData[lastNodeExecuted].pop(); data!.executionData!.resultData.runData[lastNodeExecuted].pop();
// Stack will determine what to run next // Stack will determine what to run next
@ -1638,7 +1665,16 @@ class App {
if (config.get('executions.mode') === 'queue') { if (config.get('executions.mode') === 'queue') {
const currentJobs = await Queue.getInstance().getJobs(['active', 'waiting']); const currentJobs = await Queue.getInstance().getJobs(['active', 'waiting']);
const currentlyRunningExecutionIds = currentJobs.map(job => job.data.executionId); const currentlyRunningQueueIds = currentJobs.map(job => job.data.executionId);
const currentlyRunningManualExecutions = this.activeExecutionsInstance.getActiveExecutions();
const manualExecutionIds = currentlyRunningManualExecutions.map(execution => execution.id);
const currentlyRunningExecutionIds = currentlyRunningQueueIds.concat(manualExecutionIds);
if (currentlyRunningExecutionIds.length === 0) {
return [];
}
const resultsQuery = await Db.collections.Execution! const resultsQuery = await Db.collections.Execution!
.createQueryBuilder("execution") .createQueryBuilder("execution")
@ -1651,7 +1687,7 @@ class App {
]) ])
.orderBy('execution.id', 'DESC') .orderBy('execution.id', 'DESC')
.andWhere(`execution.id IN (:...ids)`, {ids: currentlyRunningExecutionIds}); .andWhere(`execution.id IN (:...ids)`, {ids: currentlyRunningExecutionIds});
if (req.query.filter) { if (req.query.filter) {
const filter = JSON.parse(req.query.filter as string); const filter = JSON.parse(req.query.filter as string);
if (filter.workflowId !== undefined) { if (filter.workflowId !== undefined) {
@ -1663,7 +1699,7 @@ class App {
return results.map(result => { return results.map(result => {
return { return {
idActive: result.id, id: result.id,
workflowId: result.workflowId, workflowId: result.workflowId,
mode: result.mode, mode: result.mode,
retryOf: result.retryOf !== null ? result.retryOf : undefined, retryOf: result.retryOf !== null ? result.retryOf : undefined,
@ -1674,27 +1710,27 @@ class App {
const executingWorkflows = this.activeExecutionsInstance.getActiveExecutions(); const executingWorkflows = this.activeExecutionsInstance.getActiveExecutions();
const returnData: IExecutionsSummary[] = []; const returnData: IExecutionsSummary[] = [];
let filter: any = {}; // tslint:disable-line:no-any let filter: any = {}; // tslint:disable-line:no-any
if (req.query.filter) { if (req.query.filter) {
filter = JSON.parse(req.query.filter as string); filter = JSON.parse(req.query.filter as string);
} }
for (const data of executingWorkflows) { for (const data of executingWorkflows) {
if (filter.workflowId !== undefined && filter.workflowId !== data.workflowId) { if (filter.workflowId !== undefined && filter.workflowId !== data.workflowId) {
continue; continue;
} }
returnData.push( returnData.push(
{ {
idActive: data.id.toString(), id: data.id.toString(),
workflowId: data.workflowId.toString(), workflowId: data.workflowId === undefined ? '' : data.workflowId.toString(),
mode: data.mode, mode: data.mode,
retryOf: data.retryOf, retryOf: data.retryOf,
startedAt: new Date(data.startedAt), startedAt: new Date(data.startedAt),
} }
); );
} }
return returnData; return returnData;
} }
})); }));
@ -1702,6 +1738,20 @@ class App {
// Forces the execution to stop // Forces the execution to stop
this.app.post(`/${this.restEndpoint}/executions-current/:id/stop`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IExecutionsStopData> => { this.app.post(`/${this.restEndpoint}/executions-current/:id/stop`, ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IExecutionsStopData> => {
if (config.get('executions.mode') === 'queue') { if (config.get('executions.mode') === 'queue') {
// Manual executions should still be stoppable, so
// try notifying the `activeExecutions` to stop it.
const result = await this.activeExecutionsInstance.stopExecution(req.params.id);
if (result !== undefined) {
const returnData: IExecutionsStopData = {
mode: result.mode,
startedAt: new Date(result.startedAt),
stoppedAt: result.stoppedAt ? new Date(result.stoppedAt) : undefined,
finished: result.finished,
};
return returnData;
}
const currentJobs = await Queue.getInstance().getJobs(['active', 'waiting']); const currentJobs = await Queue.getInstance().getJobs(['active', 'waiting']);
const job = currentJobs.find(job => job.data.executionId.toString() === req.params.id); const job = currentJobs.find(job => job.data.executionId.toString() === req.params.id);
@ -1721,26 +1771,26 @@ class App {
stoppedAt: fullExecutionData.stoppedAt ? new Date(fullExecutionData.stoppedAt) : undefined, stoppedAt: fullExecutionData.stoppedAt ? new Date(fullExecutionData.stoppedAt) : undefined,
finished: fullExecutionData.finished, finished: fullExecutionData.finished,
}; };
return returnData; return returnData;
} else { } else {
const executionId = req.params.id; const executionId = req.params.id;
// Stopt he execution and wait till it is done and we got the data // Stopt he execution and wait till it is done and we got the data
const result = await this.activeExecutionsInstance.stopExecution(executionId); const result = await this.activeExecutionsInstance.stopExecution(executionId);
if (result === undefined) { if (result === undefined) {
throw new Error(`The execution id "${executionId}" could not be found.`); throw new Error(`The execution id "${executionId}" could not be found.`);
} }
const returnData: IExecutionsStopData = { const returnData: IExecutionsStopData = {
mode: result.mode, mode: result.mode,
startedAt: new Date(result.startedAt), startedAt: new Date(result.startedAt),
stoppedAt: result.stoppedAt ? new Date(result.stoppedAt) : undefined, stoppedAt: result.stoppedAt ? new Date(result.stoppedAt) : undefined,
finished: result.finished, finished: result.finished,
}; };
return returnData; return returnData;
} }
})); }));

View file

@ -17,6 +17,7 @@ import {
IWorkflowExecuteAdditionalData, IWorkflowExecuteAdditionalData,
WebhookHttpMethod, WebhookHttpMethod,
Workflow, Workflow,
WorkflowActivateMode,
WorkflowExecuteMode, WorkflowExecuteMode,
} from 'n8n-workflow'; } from 'n8n-workflow';
@ -161,7 +162,7 @@ export class TestWebhooks {
* @returns {(Promise<IExecutionDb | undefined>)} * @returns {(Promise<IExecutionDb | undefined>)}
* @memberof TestWebhooks * @memberof TestWebhooks
*/ */
async needsWebhookData(workflowData: IWorkflowDb, workflow: Workflow, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode, sessionId?: string, destinationNode?: string): Promise<boolean> { async needsWebhookData(workflowData: IWorkflowDb, workflow: Workflow, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode, activation: WorkflowActivateMode, sessionId?: string, destinationNode?: string): Promise<boolean> {
const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData, destinationNode); const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData, destinationNode);
if (webhooks.length === 0) { if (webhooks.length === 0) {
@ -193,7 +194,7 @@ export class TestWebhooks {
}; };
try { try {
await this.activeWebhooks!.add(workflow, webhookData, mode); await this.activeWebhooks!.add(workflow, webhookData, mode, activation);
} catch (error) { } catch (error) {
activatedKey.forEach(deleteKey => delete this.testWebhookData[deleteKey] ); activatedKey.forEach(deleteKey => delete this.testWebhookData[deleteKey] );
await this.activeWebhooks!.removeWorkflow(workflow); await this.activeWebhooks!.removeWorkflow(workflow);

View file

@ -1,4 +1,5 @@
import { import {
ActiveExecutions,
CredentialsHelper, CredentialsHelper,
Db, Db,
ExternalHooks, ExternalHooks,
@ -108,51 +109,15 @@ function pruneExecutionData(): void {
// throttle just on success to allow for self healing on failure // throttle just on success to allow for self healing on failure
Db.collections.Execution!.delete({ stoppedAt: LessThanOrEqual(date.toISOString()) }) Db.collections.Execution!.delete({ stoppedAt: LessThanOrEqual(date.toISOString()) })
.then(data => .then(data =>
setTimeout(() => { setTimeout(() => {
throttling = false; throttling = false;
}, timeout * 1000) }, timeout * 1000)
).catch(err => throttling = false); ).catch(err => throttling = false);
} }
} }
/**
* Pushes the execution out to all connected clients
*
* @param {WorkflowExecuteMode} mode The mode in which the workflow got started in
* @param {IRun} fullRunData The RunData of the finished execution
* @param {string} executionIdActive The id of the finished execution
* @param {string} [executionIdDb] The database id of finished execution
*/
export function pushExecutionFinished(mode: WorkflowExecuteMode, fullRunData: IRun, executionIdActive: string, executionIdDb?: string, retryOf?: string) {
// Clone the object except the runData. That one is not supposed
// to be send. Because that data got send piece by piece after
// each node which finished executing
const pushRunData = {
...fullRunData,
data: {
...fullRunData.data,
resultData: {
...fullRunData.data.resultData,
runData: {},
},
},
};
// Push data to editor-ui once workflow finished
const sendData: IPushDataExecutionFinished = {
executionIdActive,
executionIdDb,
data: pushRunData,
retryOf,
};
const pushInstance = Push.getInstance();
pushInstance.send('executionFinished', sendData);
}
/** /**
* Returns hook functions to push data to Editor-UI * Returns hook functions to push data to Editor-UI
* *
@ -192,25 +157,52 @@ function hookFunctionsPush(): IWorkflowExecuteHooks {
], ],
workflowExecuteBefore: [ workflowExecuteBefore: [
async function (this: WorkflowHooks): Promise<void> { async function (this: WorkflowHooks): Promise<void> {
// Push data to editor-ui once workflow finished // Push data to session which started the workflow
if (this.mode === 'manual') { if (this.sessionId === undefined) {
const pushInstance = Push.getInstance(); return;
pushInstance.send('executionStarted', {
executionId: this.executionId,
mode: this.mode,
startedAt: new Date(),
retryOf: this.retryOf,
workflowId: this.workflowData.id as string,
workflowName: this.workflowData.name,
});
} }
const pushInstance = Push.getInstance();
pushInstance.send('executionStarted', {
executionId: this.executionId,
mode: this.mode,
startedAt: new Date(),
retryOf: this.retryOf,
workflowId: this.workflowData.id as string,
workflowName: this.workflowData.name,
}, this.sessionId);
}, },
], ],
workflowExecuteAfter: [ workflowExecuteAfter: [
async function (this: WorkflowHooks, fullRunData: IRun, newStaticData: IDataObject): Promise<void> { async function (this: WorkflowHooks, fullRunData: IRun, newStaticData: IDataObject): Promise<void> {
if (this.mode === 'manual') { // Push data to session which started the workflow
pushExecutionFinished(this.mode, fullRunData, this.executionId, undefined, this.retryOf); if (this.sessionId === undefined) {
return;
} }
// Clone the object except the runData. That one is not supposed
// to be send. Because that data got send piece by piece after
// each node which finished executing
const pushRunData = {
...fullRunData,
data: {
...fullRunData.data,
resultData: {
...fullRunData.data.resultData,
runData: {},
},
},
};
// Push data to editor-ui once workflow finished
// TODO: Look at this again
const sendData: IPushDataExecutionFinished = {
executionId: this.executionId,
data: pushRunData,
retryOf: this.retryOf,
};
const pushInstance = Push.getInstance();
pushInstance.send('executionFinished', sendData, this.sessionId);
}, },
], ],
}; };
@ -238,59 +230,70 @@ export function hookFunctionsPreExecute(parentProcessMode?: string): IWorkflowEx
return; return;
} }
const execution = await Db.collections.Execution!.findOne(this.executionId); try {
const execution = await Db.collections.Execution!.findOne(this.executionId);
if (execution === undefined) { if (execution === undefined) {
// Something went badly wrong if this happens. // Something went badly wrong if this happens.
// This check is here mostly to make typescript happy. // This check is here mostly to make typescript happy.
return undefined; return undefined;
} }
const fullExecutionData: IExecutionResponse = ResponseHelper.unflattenExecutionData(execution); const fullExecutionData: IExecutionResponse = ResponseHelper.unflattenExecutionData(execution);
if (fullExecutionData.finished) { if (fullExecutionData.finished) {
// We already received ´workflowExecuteAfter´ webhook, so this is just an async call // We already received ´workflowExecuteAfter´ webhook, so this is just an async call
// that was left behind. We skip saving because the other call should have saved everything // that was left behind. We skip saving because the other call should have saved everything
// so this one is safe to ignore // so this one is safe to ignore
return; return;
}
if (fullExecutionData.data === undefined) {
fullExecutionData.data = {
startData: {
},
resultData: {
runData: {},
},
executionData: {
contextData: {},
nodeExecutionStack: [],
waitingExecution: {},
},
};
}
if (Array.isArray(fullExecutionData.data.resultData.runData[nodeName])) {
// Append data if array exists
fullExecutionData.data.resultData.runData[nodeName].push(data);
} else {
// Initialize array and save data
fullExecutionData.data.resultData.runData[nodeName] = [data];
}
fullExecutionData.data.executionData = executionData.executionData;
// Set last executed node so that it may resume on failure
fullExecutionData.data.resultData.lastNodeExecuted = nodeName;
const flattenedExecutionData = ResponseHelper.flattenExecutionData(fullExecutionData);
await Db.collections.Execution!.update(this.executionId, flattenedExecutionData as IExecutionFlattedDb);
} catch (err) {
// TODO: Improve in the future!
// Errors here might happen because of database access
// For busy machines, we may get "Database is locked" errors.
// We do this to prevent crashes and executions ending in `unknown` state.
console.log(`Failed saving execution progress to database for execution ID ${this.executionId}`, err);
} }
if (fullExecutionData.data === undefined) {
fullExecutionData.data = {
startData: {
},
resultData: {
runData: {},
},
executionData: {
contextData: {},
nodeExecutionStack: [],
waitingExecution: {},
},
};
}
if (Array.isArray(fullExecutionData.data.resultData.runData[nodeName])) {
// Append data if array exists
fullExecutionData.data.resultData.runData[nodeName].push(data);
} else {
// Initialize array and save data
fullExecutionData.data.resultData.runData[nodeName] = [data];
}
fullExecutionData.data.executionData = executionData.executionData;
// Set last executed node so that it may resume on failure
fullExecutionData.data.resultData.lastNodeExecuted = nodeName;
const flattenedExecutionData = ResponseHelper.flattenExecutionData(fullExecutionData);
await Db.collections.Execution!.update(this.executionId, flattenedExecutionData as IExecutionFlattedDb);
}, },
], ],
}; };
} }
/** /**
* Returns hook functions to save workflow execution and call error workflow * Returns hook functions to save workflow execution and call error workflow
* *
@ -330,7 +333,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
if (isManualMode && saveManualExecutions === false) { if (isManualMode && saveManualExecutions === false) {
// Data is always saved, so we remove from database // Data is always saved, so we remove from database
Db.collections.Execution!.delete(this.executionId); await Db.collections.Execution!.delete(this.executionId);
return; return;
} }
@ -350,7 +353,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
executeErrorWorkflow(this.workflowData, fullRunData, this.mode, undefined, this.retryOf); executeErrorWorkflow(this.workflowData, fullRunData, this.mode, undefined, this.retryOf);
} }
// Data is always saved, so we remove from database // Data is always saved, so we remove from database
Db.collections.Execution!.delete(this.executionId); await Db.collections.Execution!.delete(this.executionId);
return; return;
} }
@ -397,57 +400,79 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
/** /**
* Executes the workflow with the given ID * Returns hook functions to save workflow execution and call error workflow
* for running with queues. Manual executions should never run on queues as
* they are always executed in the main process.
* *
* @export * @returns {IWorkflowExecuteHooks}
* @param {string} workflowId The id of the workflow to execute
* @param {IWorkflowExecuteAdditionalData} additionalData
* @param {INodeExecutionData[]} [inputData]
* @returns {(Promise<Array<INodeExecutionData[] | null>>)}
*/ */
export async function executeWorkflow(workflowInfo: IExecuteWorkflowInfo, additionalData: IWorkflowExecuteAdditionalData, inputData?: INodeExecutionData[]): Promise<Array<INodeExecutionData[] | null>> { function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
return {
nodeExecuteBefore: [],
nodeExecuteAfter: [],
workflowExecuteBefore: [],
workflowExecuteAfter: [
async function (this: WorkflowHooks, fullRunData: IRun, newStaticData: IDataObject): Promise<void> {
try {
if (WorkflowHelpers.isWorkflowIdValid(this.workflowData.id as string) === true && newStaticData) {
// Workflow is saved so update in database
try {
await WorkflowHelpers.saveStaticDataById(this.workflowData.id as string, newStaticData);
} catch (e) {
// TODO: Add proper logging!
console.error(`There was a problem saving the workflow with id "${this.workflowData.id}" to save changed staticData: ${e.message}`);
}
}
// Check config to know if execution should be saved or not
let saveDataErrorExecution = config.get('executions.saveDataOnError') as string;
if (this.workflowData.settings !== undefined) {
saveDataErrorExecution = (this.workflowData.settings.saveDataErrorExecution as string) || saveDataErrorExecution;
}
const workflowDidSucceed = !fullRunData.data.resultData.error;
if (workflowDidSucceed === false && saveDataErrorExecution === 'none') {
executeErrorWorkflow(this.workflowData, fullRunData, this.mode, undefined, this.retryOf);
}
const fullExecutionData: IExecutionDb = {
data: fullRunData.data,
mode: fullRunData.mode,
finished: fullRunData.finished ? fullRunData.finished : false,
startedAt: fullRunData.startedAt,
stoppedAt: fullRunData.stoppedAt,
workflowData: this.workflowData,
};
if (this.retryOf !== undefined) {
fullExecutionData.retryOf = this.retryOf.toString();
}
if (this.workflowData.id !== undefined && WorkflowHelpers.isWorkflowIdValid(this.workflowData.id.toString()) === true) {
fullExecutionData.workflowId = this.workflowData.id.toString();
}
const executionData = ResponseHelper.flattenExecutionData(fullExecutionData);
// Save the Execution in DB
await Db.collections.Execution!.update(this.executionId, executionData as IExecutionFlattedDb);
if (fullRunData.finished === true && this.retryOf !== undefined) {
// If the retry was successful save the reference it on the original execution
// await Db.collections.Execution!.save(executionData as IExecutionFlattedDb);
await Db.collections.Execution!.update(this.retryOf, { retrySuccessId: this.executionId });
}
} catch (error) {
executeErrorWorkflow(this.workflowData, fullRunData, this.mode, undefined, this.retryOf);
}
},
],
};
}
export async function getRunData(workflowData: IWorkflowBase, inputData?: INodeExecutionData[]): Promise<IWorkflowExecutionDataProcess> {
const mode = 'integrated'; const mode = 'integrated';
if (workflowInfo.id === undefined && workflowInfo.code === undefined) {
throw new Error(`No information about the workflow to execute found. Please provide either the "id" or "code"!`);
}
if (Db.collections!.Workflow === null) {
// The first time executeWorkflow gets called the Database has
// to get initialized first
await Db.init();
}
let workflowData: IWorkflowBase | undefined;
if (workflowInfo.id !== undefined) {
workflowData = await Db.collections!.Workflow!.findOne(workflowInfo.id);
if (workflowData === undefined) {
throw new Error(`The workflow with the id "${workflowInfo.id}" does not exist.`);
}
} else {
workflowData = workflowInfo.code;
}
const externalHooks = ExternalHooks();
await externalHooks.init();
const nodeTypes = NodeTypes();
const workflowName = workflowData ? workflowData.name : undefined;
const workflow = new Workflow({ id: workflowInfo.id, name: workflowName, nodes: workflowData!.nodes, connections: workflowData!.connections, active: workflowData!.active, nodeTypes, staticData: workflowData!.staticData });
// Does not get used so set it simply to empty string
const executionId = '';
// Get the needed credentials for the current workflow as they will differ to the ones of the
// calling workflow.
const credentials = await WorkflowCredentials(workflowData!.nodes);
// Create new additionalData to have different workflow loaded and to call
// different webooks
const additionalDataIntegrated = await getBase(credentials);
additionalDataIntegrated.hooks = getWorkflowHooksIntegrated(mode, executionId, workflowData!, { parentProcessMode: additionalData.hooks!.mode });
// Find Start-Node // Find Start-Node
const requiredNodeTypes = ['n8n-nodes-base.start']; const requiredNodeTypes = ['n8n-nodes-base.start'];
let startNode: INode | undefined; let startNode: INode | undefined;
@ -494,17 +519,113 @@ export async function executeWorkflow(workflowInfo: IExecuteWorkflowInfo, additi
}, },
}; };
// Get the needed credentials for the current workflow as they will differ to the ones of the
// calling workflow.
const credentials = await WorkflowCredentials(workflowData!.nodes);
const runData: IWorkflowExecutionDataProcess = {
credentials,
executionMode: mode,
executionData: runExecutionData,
// @ts-ignore
workflowData,
};
return runData;
}
export async function getWorkflowData(workflowInfo: IExecuteWorkflowInfo): Promise<IWorkflowBase> {
if (workflowInfo.id === undefined && workflowInfo.code === undefined) {
throw new Error(`No information about the workflow to execute found. Please provide either the "id" or "code"!`);
}
if (Db.collections!.Workflow === null) {
// The first time executeWorkflow gets called the Database has
// to get initialized first
await Db.init();
}
let workflowData: IWorkflowBase | undefined;
if (workflowInfo.id !== undefined) {
workflowData = await Db.collections!.Workflow!.findOne(workflowInfo.id);
if (workflowData === undefined) {
throw new Error(`The workflow with the id "${workflowInfo.id}" does not exist.`);
}
} else {
workflowData = workflowInfo.code;
}
return workflowData!;
}
/**
* Executes the workflow with the given ID
*
* @export
* @param {string} workflowId The id of the workflow to execute
* @param {IWorkflowExecuteAdditionalData} additionalData
* @param {INodeExecutionData[]} [inputData]
* @returns {(Promise<Array<INodeExecutionData[] | null>>)}
*/
export async function executeWorkflow(workflowInfo: IExecuteWorkflowInfo, additionalData: IWorkflowExecuteAdditionalData, inputData?: INodeExecutionData[], parentExecutionId?: string, loadedWorkflowData?: IWorkflowBase, loadedRunData?: IWorkflowExecutionDataProcess): Promise<Array<INodeExecutionData[] | null> | IRun> {
const externalHooks = ExternalHooks();
await externalHooks.init();
const nodeTypes = NodeTypes();
const workflowData = loadedWorkflowData !== undefined ? loadedWorkflowData : await getWorkflowData(workflowInfo);
const workflowName = workflowData ? workflowData.name : undefined;
const workflow = new Workflow({ id: workflowInfo.id, name: workflowName, nodes: workflowData!.nodes, connections: workflowData!.connections, active: workflowData!.active, nodeTypes, staticData: workflowData!.staticData });
const runData = loadedRunData !== undefined ? loadedRunData : await getRunData(workflowData, inputData);
let executionId;
if (parentExecutionId !== undefined) {
executionId = parentExecutionId;
} else {
executionId = parentExecutionId !== undefined ? parentExecutionId : await ActiveExecutions.getInstance().add(runData);
}
const runExecutionData = runData.executionData as IRunExecutionData;
// Get the needed credentials for the current workflow as they will differ to the ones of the
// calling workflow.
const credentials = await WorkflowCredentials(workflowData!.nodes);
// Create new additionalData to have different workflow loaded and to call
// different webooks
const additionalDataIntegrated = await getBase(credentials);
additionalDataIntegrated.hooks = getWorkflowHooksIntegrated(runData.executionMode, executionId, workflowData!, { parentProcessMode: additionalData.hooks!.mode });
// Make sure we pass on the original executeWorkflow function we received
// This one already contains changes to talk to parent process
// and get executionID from `activeExecutions` running on main process
additionalDataIntegrated.executeWorkflow = additionalData.executeWorkflow;
// Execute the workflow // Execute the workflow
const workflowExecute = new WorkflowExecute(additionalDataIntegrated, mode, runExecutionData); const workflowExecute = new WorkflowExecute(additionalDataIntegrated, runData.executionMode, runExecutionData);
const data = await workflowExecute.processRunExecutionData(workflow); const data = await workflowExecute.processRunExecutionData(workflow);
await externalHooks.run('workflow.postExecute', [data, workflowData]); await externalHooks.run('workflow.postExecute', [data, workflowData]);
if (data.finished === true) { if (data.finished === true) {
// Workflow did finish successfully // Workflow did finish successfully
const returnData = WorkflowHelpers.getDataLastExecutedNodeData(data);
return returnData!.data!.main; if (parentExecutionId !== undefined) {
return data;
} else {
await ActiveExecutions.getInstance().remove(executionId, data);
const returnData = WorkflowHelpers.getDataLastExecutedNodeData(data);
return returnData!.data!.main;
}
} else { } else {
await ActiveExecutions.getInstance().remove(executionId, data);
// Workflow did fail // Workflow did fail
const error = new Error(data.data.resultData.error!.message); const error = new Error(data.data.resultData.error!.message);
error.stack = data.data.resultData.error!.stack; error.stack = data.data.resultData.error!.stack;
@ -564,6 +685,22 @@ export function getWorkflowHooksIntegrated(mode: WorkflowExecuteMode, executionI
return new WorkflowHooks(hookFunctions, mode, executionId, workflowData, optionalParameters); return new WorkflowHooks(hookFunctions, mode, executionId, workflowData, optionalParameters);
} }
/**
* Returns WorkflowHooks instance for running integrated workflows
* (Workflows which get started inside of another workflow)
*/
export function getWorkflowHooksWorkerExecuter(mode: WorkflowExecuteMode, executionId: string, workflowData: IWorkflowBase, optionalParameters?: IWorkflowHooksOptionalParameters): WorkflowHooks {
optionalParameters = optionalParameters || {};
const hookFunctions = hookFunctionsSaveWorker();
const preExecuteFunctions = hookFunctionsPreExecute(optionalParameters.parentProcessMode);
for (const key of Object.keys(preExecuteFunctions)) {
if (hookFunctions[key] === undefined) {
hookFunctions[key] = [];
}
hookFunctions[key]!.push.apply(hookFunctions[key], preExecuteFunctions[key]);
}
return new WorkflowHooks(hookFunctions, mode, executionId, workflowData, optionalParameters);
}
/** /**
* Returns WorkflowHooks instance for main process if workflow runs via worker * Returns WorkflowHooks instance for main process if workflow runs via worker
@ -616,6 +753,6 @@ export function getWorkflowHooksMain(data: IWorkflowExecutionDataProcess, execut
} }
} }
return new WorkflowHooks(hookFunctions, data.executionMode, executionId, data.workflowData, { sessionId: data.sessionId, retryOf: data.retryOf as string}); return new WorkflowHooks(hookFunctions, data.executionMode, executionId, data.workflowData, { sessionId: data.sessionId, retryOf: data.retryOf as string });
} }

View file

@ -101,9 +101,6 @@ export class WorkflowRunner {
// Remove from active execution with empty data. That will // Remove from active execution with empty data. That will
// set the execution to failed. // set the execution to failed.
this.activeExecutions.remove(executionId, fullRunData); this.activeExecutions.remove(executionId, fullRunData);
// Also send to Editor UI
WorkflowExecuteAdditionalData.pushExecutionFinished(executionMode, fullRunData, executionId);
} }
/** /**
@ -175,7 +172,7 @@ export class WorkflowRunner {
workflowExecution = workflowExecute.processRunExecutionData(workflow); workflowExecution = workflowExecute.processRunExecutionData(workflow);
} else if (data.runData === undefined || data.startNodes === undefined || data.startNodes.length === 0 || data.destinationNode === undefined) { } else if (data.runData === undefined || data.startNodes === undefined || data.startNodes.length === 0 || data.destinationNode === undefined) {
// Execute all nodes // Execute all nodes
// Can execute without webhook so go on // Can execute without webhook so go on
const workflowExecute = new WorkflowExecute(additionalData, data.executionMode); const workflowExecute = new WorkflowExecute(additionalData, data.executionMode);
workflowExecution = workflowExecute.run(workflow, undefined, data.destinationNode); workflowExecution = workflowExecute.run(workflow, undefined, data.destinationNode);
@ -298,7 +295,7 @@ export class WorkflowRunner {
}, queueRecoveryInterval * 1000); }, queueRecoveryInterval * 1000);
}); });
const clearWatchdogInterval = () => { const clearWatchdogInterval = () => {
if (watchDogInterval) { if (watchDogInterval) {
clearInterval(watchDogInterval); clearInterval(watchDogInterval);
@ -332,7 +329,7 @@ export class WorkflowRunner {
await jobData; await jobData;
} }
const executionDb = await Db.collections.Execution!.findOne(executionId) as IExecutionFlattedDb; const executionDb = await Db.collections.Execution!.findOne(executionId) as IExecutionFlattedDb;
const fullExecutionData = ResponseHelper.unflattenExecutionData(executionDb) as IExecutionResponse; const fullExecutionData = ResponseHelper.unflattenExecutionData(executionDb) as IExecutionResponse;
@ -349,7 +346,27 @@ export class WorkflowRunner {
// Normally also static data should be supplied here but as it only used for sending // Normally also static data should be supplied here but as it only used for sending
// data to editor-UI is not needed. // data to editor-UI is not needed.
hooks.executeHookFunctions('workflowExecuteAfter', [runData]); hooks.executeHookFunctions('workflowExecuteAfter', [runData]);
try {
// Check if this execution data has to be removed from database
// based on workflow settings.
let saveDataErrorExecution = config.get('executions.saveDataOnError') as string;
let saveDataSuccessExecution = config.get('executions.saveDataOnSuccess') as string;
if (data.workflowData.settings !== undefined) {
saveDataErrorExecution = (data.workflowData.settings.saveDataErrorExecution as string) || saveDataErrorExecution;
saveDataSuccessExecution = (data.workflowData.settings.saveDataSuccessExecution as string) || saveDataSuccessExecution;
}
const workflowDidSucceed = !runData.data.resultData.error;
if (workflowDidSucceed === true && saveDataSuccessExecution === 'none' ||
workflowDidSucceed === false && saveDataErrorExecution === 'none'
) {
await Db.collections.Execution!.delete(executionId);
}
} catch (err) {
// We don't want errors here to crash n8n. Just log and proceed.
console.log('Error removing saved execution from database. More details: ', err);
}
resolve(runData); resolve(runData);
}); });
@ -440,7 +457,7 @@ export class WorkflowRunner {
// Listen to data from the subprocess // Listen to data from the subprocess
subprocess.on('message', (message: IProcessMessage) => { subprocess.on('message', async (message: IProcessMessage) => {
if (message.type === 'end') { if (message.type === 'end') {
clearTimeout(executionTimeout); clearTimeout(executionTimeout);
this.activeExecutions.remove(executionId!, message.data.runData); this.activeExecutions.remove(executionId!, message.data.runData);
@ -457,6 +474,11 @@ export class WorkflowRunner {
const timeoutError = { message: 'Workflow execution timed out!' } as IExecutionError; const timeoutError = { message: 'Workflow execution timed out!' } as IExecutionError;
this.processError(timeoutError, startedAt, data.executionMode, executionId); this.processError(timeoutError, startedAt, data.executionMode, executionId);
} else if (message.type === 'startExecution') {
const executionId = await this.activeExecutions.add(message.data.runData);
subprocess.send({ type: 'executionId', data: {executionId} } as IProcessMessage);
} else if (message.type === 'finishExecution') {
await this.activeExecutions.remove(message.data.executionId, message.data.result);
} }
}); });

View file

@ -7,6 +7,7 @@ import {
IWorkflowExecutionDataProcessWithExecution, IWorkflowExecutionDataProcessWithExecution,
NodeTypes, NodeTypes,
WorkflowExecuteAdditionalData, WorkflowExecuteAdditionalData,
WorkflowHelpers,
} from './'; } from './';
import { import {
@ -17,12 +18,15 @@ import {
import { import {
IDataObject, IDataObject,
IExecuteData, IExecuteData,
IExecuteWorkflowInfo,
IExecutionError, IExecutionError,
INodeExecutionData,
INodeType, INodeType,
INodeTypeData, INodeTypeData,
IRun, IRun,
IRunExecutionData, IRunExecutionData,
ITaskData, ITaskData,
IWorkflowExecuteAdditionalData,
IWorkflowExecuteHooks, IWorkflowExecuteHooks,
Workflow, Workflow,
WorkflowHooks, WorkflowHooks,
@ -35,9 +39,20 @@ export class WorkflowRunnerProcess {
startedAt = new Date(); startedAt = new Date();
workflow: Workflow | undefined; workflow: Workflow | undefined;
workflowExecute: WorkflowExecute | undefined; workflowExecute: WorkflowExecute | undefined;
executionIdCallback: (executionId: string) => void | undefined;
static async stopProcess() {
setTimeout(() => {
// Attempt a graceful shutdown, giving executions 30 seconds to finish
process.exit(0);
}, 30000);
}
async runWorkflow(inputData: IWorkflowExecutionDataProcessWithExecution): Promise<IRun> { async runWorkflow(inputData: IWorkflowExecutionDataProcessWithExecution): Promise<IRun> {
process.on('SIGTERM', WorkflowRunnerProcess.stopProcess);
process.on('SIGINT', WorkflowRunnerProcess.stopProcess);
this.data = inputData; this.data = inputData;
let className: string; let className: string;
let tempNode: INodeType; let tempNode: INodeType;
@ -92,16 +107,41 @@ export class WorkflowRunnerProcess {
await Db.init(); await Db.init();
} }
this.workflow = new Workflow({ id: this.data.workflowData.id as string | undefined, name: this.data.workflowData.name, nodes: this.data.workflowData!.nodes, connections: this.data.workflowData!.connections, active: this.data.workflowData!.active, nodeTypes, staticData: this.data.workflowData!.staticData, settings: this.data.workflowData!.settings}); this.workflow = new Workflow({ id: this.data.workflowData.id as string | undefined, name: this.data.workflowData.name, nodes: this.data.workflowData!.nodes, connections: this.data.workflowData!.connections, active: this.data.workflowData!.active, nodeTypes, staticData: this.data.workflowData!.staticData, settings: this.data.workflowData!.settings });
const additionalData = await WorkflowExecuteAdditionalData.getBase(this.data.credentials); const additionalData = await WorkflowExecuteAdditionalData.getBase(this.data.credentials);
additionalData.hooks = this.getProcessForwardHooks(); additionalData.hooks = this.getProcessForwardHooks();
const executeWorkflowFunction = additionalData.executeWorkflow;
additionalData.executeWorkflow = async (workflowInfo: IExecuteWorkflowInfo, additionalData: IWorkflowExecuteAdditionalData, inputData?: INodeExecutionData[] | undefined): Promise<Array<INodeExecutionData[] | null> | IRun> => {
const workflowData = await WorkflowExecuteAdditionalData.getWorkflowData(workflowInfo);
const runData = await WorkflowExecuteAdditionalData.getRunData(workflowData, inputData);
await sendToParentProcess('startExecution', { runData });
const executionId: string = await new Promise((resolve) => {
this.executionIdCallback = (executionId: string) => {
resolve(executionId);
};
});
let result: IRun;
try {
result = await executeWorkflowFunction(workflowInfo, additionalData, inputData, executionId, workflowData, runData);
} catch (e) {
await sendToParentProcess('finishExecution', { executionId });
// Throw same error we had
throw e;
}
await sendToParentProcess('finishExecution', { executionId, result });
const returnData = WorkflowHelpers.getDataLastExecutedNodeData(result);
return returnData!.data!.main;
};
if (this.data.executionData !== undefined) { if (this.data.executionData !== undefined) {
this.workflowExecute = new WorkflowExecute(additionalData, this.data.executionMode, this.data.executionData); this.workflowExecute = new WorkflowExecute(additionalData, this.data.executionMode, this.data.executionData);
return this.workflowExecute.processRunExecutionData(this.workflow); return this.workflowExecute.processRunExecutionData(this.workflow);
} else if (this.data.runData === undefined || this.data.startNodes === undefined || this.data.startNodes.length === 0 || this.data.destinationNode === undefined) { } else if (this.data.runData === undefined || this.data.startNodes === undefined || this.data.startNodes.length === 0 || this.data.destinationNode === undefined) {
// Execute all nodes // Execute all nodes
// Can execute without webhook so go on // Can execute without webhook so go on
this.workflowExecute = new WorkflowExecute(additionalData, this.data.executionMode); this.workflowExecute = new WorkflowExecute(additionalData, this.data.executionMode);
return this.workflowExecute.run(this.workflow, undefined, this.data.destinationNode); return this.workflowExecute.run(this.workflow, undefined, this.data.destinationNode);
@ -152,8 +192,8 @@ export class WorkflowRunnerProcess {
}, },
], ],
nodeExecuteAfter: [ nodeExecuteAfter: [
async (nodeName: string, data: ITaskData, executionData: IRunExecutionData): Promise<void> => { async (nodeName: string, data: ITaskData): Promise<void> => {
this.sendHookToParentProcess('nodeExecuteAfter', [nodeName, data, executionData]); this.sendHookToParentProcess('nodeExecuteAfter', [nodeName, data]);
}, },
], ],
workflowExecuteBefore: [ workflowExecuteBefore: [
@ -257,6 +297,8 @@ process.on('message', async (message: IProcessMessage) => {
// Stop process // Stop process
process.exit(); process.exit();
} else if (message.type === 'executionId') {
workflowRunner.executionIdCallback(message.data.executionId);
} }
} catch (error) { } catch (error) {
// Catch all uncaught errors and forward them to parent process // Catch all uncaught errors and forward them to parent process

View file

@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import * as config from '../../../../config';
export class ChangeDataSize1615306975123 implements MigrationInterface {
name = 'ChangeDataSize1615306975123';
async up(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.get('database.tablePrefix');
await queryRunner.query('ALTER TABLE `' + tablePrefix + 'execution_entity` MODIFY COLUMN `data` MEDIUMTEXT NOT NULL');
}
async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.get('database.tablePrefix');
await queryRunner.query('ALTER TABLE `' + tablePrefix + 'execution_entity` MODIFY COLUMN `data` TEXT NOT NULL');
}
}

View file

@ -3,6 +3,7 @@ import { WebhookModel1592447867632 } from './1592447867632-WebhookModel';
import { CreateIndexStoppedAt1594902918301 } from './1594902918301-CreateIndexStoppedAt'; import { CreateIndexStoppedAt1594902918301 } from './1594902918301-CreateIndexStoppedAt';
import { AddWebhookId1611149998770 } from './1611149998770-AddWebhookId'; import { AddWebhookId1611149998770 } from './1611149998770-AddWebhookId';
import { MakeStoppedAtNullable1607431743767 } from './1607431743767-MakeStoppedAtNullable'; import { MakeStoppedAtNullable1607431743767 } from './1607431743767-MakeStoppedAtNullable';
import { ChangeDataSize1615306975123 } from './1615306975123-ChangeDataSize';
export const mysqlMigrations = [ export const mysqlMigrations = [
InitialMigration1588157391238, InitialMigration1588157391238,
@ -10,4 +11,5 @@ export const mysqlMigrations = [
CreateIndexStoppedAt1594902918301, CreateIndexStoppedAt1594902918301,
AddWebhookId1611149998770, AddWebhookId1611149998770,
MakeStoppedAtNullable1607431743767, MakeStoppedAtNullable1607431743767,
ChangeDataSize1615306975123,
]; ];

View file

@ -1,74 +1,74 @@
{ {
"name": "n8n-core", "name": "n8n-core",
"version": "0.62.0", "version": "0.67.0",
"description": "Core functionality of n8n", "description": "Core functionality of n8n",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",
"author": { "author": {
"name": "Jan Oberhauser", "name": "Jan Oberhauser",
"email": "jan@n8n.io" "email": "jan@n8n.io"
},
"repository": {
"type": "git",
"url": "git+https://github.com/n8n-io/n8n.git"
},
"main": "dist/src/index",
"types": "dist/src/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "npm run watch",
"tslint": "tslint -p tsconfig.json -c tslint.json",
"tslintfix": "tslint --fix -p tsconfig.json -c tslint.json",
"watch": "tsc --watch",
"test": "jest"
},
"files": [
"dist"
],
"devDependencies": {
"@types/cron": "^1.7.1",
"@types/crypto-js": "^4.0.1",
"@types/express": "^4.17.6",
"@types/jest": "^26.0.13",
"@types/lodash.get": "^4.4.6",
"@types/mime-types": "^2.1.0",
"@types/node": "14.0.27",
"@types/request-promise-native": "~1.0.15",
"jest": "^26.4.2",
"source-map-support": "^0.5.9",
"ts-jest": "^26.3.0",
"tslint": "^6.1.2",
"typescript": "~3.9.7"
},
"dependencies": {
"client-oauth2": "^4.2.5",
"cron": "^1.7.2",
"crypto-js": "4.0.0",
"file-type": "^14.6.2",
"lodash.get": "^4.4.2",
"mime-types": "^2.1.27",
"n8n-workflow": "~0.55.0",
"oauth-1.0a": "^2.2.6",
"p-cancelable": "^2.0.0",
"request": "^2.88.2",
"request-promise-native": "^1.0.7"
},
"jest": {
"transform": {
"^.+\\.tsx?$": "ts-jest"
}, },
"repository": { "testURL": "http://localhost/",
"type": "git", "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
"url": "git+https://github.com/n8n-io/n8n.git" "testPathIgnorePatterns": [
}, "/dist/",
"main": "dist/src/index", "/node_modules/"
"types": "dist/src/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "npm run watch",
"tslint": "tslint -p tsconfig.json -c tslint.json",
"tslintfix": "tslint --fix -p tsconfig.json -c tslint.json",
"watch": "tsc --watch",
"test": "jest"
},
"files": [
"dist"
], ],
"devDependencies": { "moduleFileExtensions": [
"@types/cron": "^1.7.1", "ts",
"@types/crypto-js": "^4.0.1", "tsx",
"@types/express": "^4.17.6", "js",
"@types/jest": "^26.0.13", "json",
"@types/lodash.get": "^4.4.6", "node"
"@types/mime-types": "^2.1.0", ]
"@types/node": "14.0.27", }
"@types/request-promise-native": "~1.0.15",
"jest": "^26.4.2",
"source-map-support": "^0.5.9",
"ts-jest": "^26.3.0",
"tslint": "^6.1.2",
"typescript": "~3.9.7"
},
"dependencies": {
"client-oauth2": "^4.2.5",
"cron": "^1.7.2",
"crypto-js": "4.0.0",
"file-type": "^14.6.2",
"lodash.get": "^4.4.2",
"mime-types": "^2.1.27",
"n8n-workflow": "~0.51.0",
"oauth-1.0a": "^2.2.6",
"p-cancelable": "^2.0.0",
"request": "^2.88.2",
"request-promise-native": "^1.0.7"
},
"jest": {
"transform": {
"^.+\\.tsx?$": "ts-jest"
},
"testURL": "http://localhost/",
"testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
"testPathIgnorePatterns": [
"/dist/",
"/node_modules/"
],
"moduleFileExtensions": [
"ts",
"tsx",
"js",
"json",
"node"
]
}
} }

View file

@ -2,6 +2,7 @@ import {
IWebhookData, IWebhookData,
WebhookHttpMethod, WebhookHttpMethod,
Workflow, Workflow,
WorkflowActivateMode,
WorkflowExecuteMode, WorkflowExecuteMode,
} from 'n8n-workflow'; } from 'n8n-workflow';
@ -30,7 +31,7 @@ export class ActiveWebhooks {
* @returns {Promise<void>} * @returns {Promise<void>}
* @memberof ActiveWebhooks * @memberof ActiveWebhooks
*/ */
async add(workflow: Workflow, webhookData: IWebhookData, mode: WorkflowExecuteMode): Promise<void> { async add(workflow: Workflow, webhookData: IWebhookData, mode: WorkflowExecuteMode, activation: WorkflowActivateMode): Promise<void> {
if (workflow.id === undefined) { if (workflow.id === undefined) {
throw new Error('Webhooks can only be added for saved workflows as an id is needed!'); throw new Error('Webhooks can only be added for saved workflows as an id is needed!');
} }
@ -57,10 +58,10 @@ export class ActiveWebhooks {
this.webhookUrls[webhookKey].push(webhookData); this.webhookUrls[webhookKey].push(webhookData);
try { try {
const webhookExists = await workflow.runWebhookMethod('checkExists', webhookData, NodeExecuteFunctions, mode, this.testWebhooks); const webhookExists = await workflow.runWebhookMethod('checkExists', webhookData, NodeExecuteFunctions, mode, activation, this.testWebhooks);
if (webhookExists !== true) { if (webhookExists !== true) {
// If webhook does not exist yet create it // If webhook does not exist yet create it
await workflow.runWebhookMethod('create', webhookData, NodeExecuteFunctions, mode, this.testWebhooks); await workflow.runWebhookMethod('create', webhookData, NodeExecuteFunctions, mode, activation, this.testWebhooks);
} }
} catch (error) { } catch (error) {
@ -183,7 +184,7 @@ export class ActiveWebhooks {
// Go through all the registered webhooks of the workflow and remove them // Go through all the registered webhooks of the workflow and remove them
for (const webhookData of webhooks) { for (const webhookData of webhooks) {
await workflow.runWebhookMethod('delete', webhookData, NodeExecuteFunctions, mode, this.testWebhooks); await workflow.runWebhookMethod('delete', webhookData, NodeExecuteFunctions, mode, 'update', this.testWebhooks);
delete this.webhookUrls[this.getWebhookKey(webhookData.httpMethod, webhookData.path, webhookData.webhookId)]; delete this.webhookUrls[this.getWebhookKey(webhookData.httpMethod, webhookData.path, webhookData.webhookId)];
} }

View file

@ -8,6 +8,8 @@ import {
ITriggerResponse, ITriggerResponse,
IWorkflowExecuteAdditionalData, IWorkflowExecuteAdditionalData,
Workflow, Workflow,
WorkflowActivateMode,
WorkflowExecuteMode,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { import {
@ -66,14 +68,14 @@ export class ActiveWorkflows {
* @returns {Promise<void>} * @returns {Promise<void>}
* @memberof ActiveWorkflows * @memberof ActiveWorkflows
*/ */
async add(id: string, workflow: Workflow, additionalData: IWorkflowExecuteAdditionalData, getTriggerFunctions: IGetExecuteTriggerFunctions, getPollFunctions: IGetExecutePollFunctions): Promise<void> { async add(id: string, workflow: Workflow, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode, activation: WorkflowActivateMode, getTriggerFunctions: IGetExecuteTriggerFunctions, getPollFunctions: IGetExecutePollFunctions): Promise<void> {
this.workflowData[id] = {}; this.workflowData[id] = {};
const triggerNodes = workflow.getTriggerNodes(); const triggerNodes = workflow.getTriggerNodes();
let triggerResponse: ITriggerResponse | undefined; let triggerResponse: ITriggerResponse | undefined;
this.workflowData[id].triggerResponses = []; this.workflowData[id].triggerResponses = [];
for (const triggerNode of triggerNodes) { for (const triggerNode of triggerNodes) {
triggerResponse = await workflow.runTrigger(triggerNode, getTriggerFunctions, additionalData, 'trigger'); triggerResponse = await workflow.runTrigger(triggerNode, getTriggerFunctions, additionalData, mode, activation);
if (triggerResponse !== undefined) { if (triggerResponse !== undefined) {
// If a response was given save it // If a response was given save it
this.workflowData[id].triggerResponses!.push(triggerResponse); this.workflowData[id].triggerResponses!.push(triggerResponse);
@ -84,7 +86,7 @@ export class ActiveWorkflows {
if (pollNodes.length) { if (pollNodes.length) {
this.workflowData[id].pollResponses = []; this.workflowData[id].pollResponses = [];
for (const pollNode of pollNodes) { for (const pollNode of pollNodes) {
this.workflowData[id].pollResponses!.push(await this.activatePolling(pollNode, workflow, additionalData, getPollFunctions)); this.workflowData[id].pollResponses!.push(await this.activatePolling(pollNode, workflow, additionalData, getPollFunctions, mode, activation));
} }
} }
} }
@ -100,10 +102,8 @@ export class ActiveWorkflows {
* @returns {Promise<IPollResponse>} * @returns {Promise<IPollResponse>}
* @memberof ActiveWorkflows * @memberof ActiveWorkflows
*/ */
async activatePolling(node: INode, workflow: Workflow, additionalData: IWorkflowExecuteAdditionalData, getPollFunctions: IGetExecutePollFunctions): Promise<IPollResponse> { async activatePolling(node: INode, workflow: Workflow, additionalData: IWorkflowExecuteAdditionalData, getPollFunctions: IGetExecutePollFunctions, mode: WorkflowExecuteMode, activation: WorkflowActivateMode): Promise<IPollResponse> {
const mode = 'trigger'; const pollFunctions = getPollFunctions(workflow, node, additionalData, mode, activation);
const pollFunctions = getPollFunctions(workflow, node, additionalData, mode);
const pollTimes = pollFunctions.getNodeParameter('pollTimes') as unknown as { const pollTimes = pollFunctions.getNodeParameter('pollTimes') as unknown as {
item: ITriggerTime[]; item: ITriggerTime[];

View file

@ -34,6 +34,7 @@ import {
NodeHelpers, NodeHelpers,
NodeParameterValue, NodeParameterValue,
Workflow, Workflow,
WorkflowActivateMode,
WorkflowDataProxy, WorkflowDataProxy,
WorkflowExecuteMode, WorkflowExecuteMode,
} from 'n8n-workflow'; } from 'n8n-workflow';
@ -103,6 +104,9 @@ export async function prepareBinaryData(binaryData: Buffer, filePath?: string, m
const filePathParts = path.parse(filePath as string); const filePathParts = path.parse(filePath as string);
if (filePathParts.dir !== '') {
returnData.directory = filePathParts.dir;
}
returnData.fileName = filePathParts.base; returnData.fileName = filePathParts.base;
// Remove the dot // Remove the dot
@ -161,8 +165,9 @@ export function requestOAuth2(this: IAllExecuteFunctions, credentialsType: strin
return this.helpers.request!(newRequestOptions) return this.helpers.request!(newRequestOptions)
.catch(async (error: IResponseError) => { .catch(async (error: IResponseError) => {
// TODO: Check if also other codes are possible const statusCodeReturned = oAuth2Options?.tokenExpiredStatusCode === undefined ? 401 : oAuth2Options?.tokenExpiredStatusCode;
if (error.statusCode === 401) {
if (error.statusCode === statusCodeReturned) {
// Token is probably not valid anymore. So try refresh it. // Token is probably not valid anymore. So try refresh it.
const tokenRefreshOptions: IDataObject = {}; const tokenRefreshOptions: IDataObject = {};
@ -531,7 +536,7 @@ export function getWorkflowMetadata(workflow: Workflow): IWorkflowMetadata {
* @returns {ITriggerFunctions} * @returns {ITriggerFunctions}
*/ */
// TODO: Check if I can get rid of: additionalData, and so then maybe also at ActiveWorkflowRunner.add // TODO: Check if I can get rid of: additionalData, and so then maybe also at ActiveWorkflowRunner.add
export function getExecutePollFunctions(workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode): IPollFunctions { export function getExecutePollFunctions(workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode, activation: WorkflowActivateMode): IPollFunctions {
return ((workflow: Workflow, node: INode) => { return ((workflow: Workflow, node: INode) => {
return { return {
__emit: (data: INodeExecutionData[][]): void => { __emit: (data: INodeExecutionData[][]): void => {
@ -543,6 +548,9 @@ export function getExecutePollFunctions(workflow: Workflow, node: INode, additio
getMode: (): WorkflowExecuteMode => { getMode: (): WorkflowExecuteMode => {
return mode; return mode;
}, },
getActivationMode: (): WorkflowActivateMode => {
return activation;
},
getNode: () => { getNode: () => {
return getNode(node); return getNode(node);
}, },
@ -594,7 +602,7 @@ export function getExecutePollFunctions(workflow: Workflow, node: INode, additio
* @returns {ITriggerFunctions} * @returns {ITriggerFunctions}
*/ */
// TODO: Check if I can get rid of: additionalData, and so then maybe also at ActiveWorkflowRunner.add // TODO: Check if I can get rid of: additionalData, and so then maybe also at ActiveWorkflowRunner.add
export function getExecuteTriggerFunctions(workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode): ITriggerFunctions { export function getExecuteTriggerFunctions(workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode, activation: WorkflowActivateMode): ITriggerFunctions {
return ((workflow: Workflow, node: INode) => { return ((workflow: Workflow, node: INode) => {
return { return {
emit: (data: INodeExecutionData[][]): void => { emit: (data: INodeExecutionData[][]): void => {
@ -609,6 +617,9 @@ export function getExecuteTriggerFunctions(workflow: Workflow, node: INode, addi
getMode: (): WorkflowExecuteMode => { getMode: (): WorkflowExecuteMode => {
return mode; return mode;
}, },
getActivationMode: (): WorkflowActivateMode => {
return activation;
},
getNodeParameter: (parameterName: string, fallbackValue?: any): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object => { //tslint:disable-line:no-any getNodeParameter: (parameterName: string, fallbackValue?: any): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object => { //tslint:disable-line:no-any
const runExecutionData: IRunExecutionData | null = null; const runExecutionData: IRunExecutionData | null = null;
const itemIndex = 0; const itemIndex = 0;
@ -906,7 +917,7 @@ export function getLoadOptionsFunctions(workflow: Workflow, node: INode, additio
* @param {WorkflowExecuteMode} mode * @param {WorkflowExecuteMode} mode
* @returns {IHookFunctions} * @returns {IHookFunctions}
*/ */
export function getExecuteHookFunctions(workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode, isTest?: boolean, webhookData?: IWebhookData): IHookFunctions { export function getExecuteHookFunctions(workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode, activation: WorkflowActivateMode, isTest?: boolean, webhookData?: IWebhookData): IHookFunctions {
return ((workflow: Workflow, node: INode) => { return ((workflow: Workflow, node: INode) => {
const that = { const that = {
getCredentials(type: string): ICredentialDataDecryptedObject | undefined { getCredentials(type: string): ICredentialDataDecryptedObject | undefined {
@ -915,6 +926,9 @@ export function getExecuteHookFunctions(workflow: Workflow, node: INode, additio
getMode: (): WorkflowExecuteMode => { getMode: (): WorkflowExecuteMode => {
return mode; return mode;
}, },
getActivationMode: (): WorkflowActivateMode => {
return activation;
},
getNode: () => { getNode: () => {
return getNode(node); return getNode(node);
}, },

View file

@ -22,6 +22,8 @@ import {
NodeExecuteFunctions, NodeExecuteFunctions,
} from './'; } from './';
import { get } from 'lodash';
export class WorkflowExecute { export class WorkflowExecute {
runExecutionData: IRunExecutionData; runExecutionData: IRunExecutionData;
private additionalData: IWorkflowExecuteAdditionalData; private additionalData: IWorkflowExecuteAdditionalData;
@ -234,6 +236,21 @@ export class WorkflowExecute {
} }
/**
* Checks the incoming connection does not receive any data
*/
incomingConnectionIsEmpty(runData: IRunData, inputConnections: IConnection[], runIndex: number): boolean {
// for (const inputConnection of workflow.connectionsByDestinationNode[nodeToAdd].main[0]) {
for (const inputConnection of inputConnections) {
const nodeIncomingData = get(runData, `[${inputConnection.node}][${runIndex}].data.main[${inputConnection.index}]`);
if (nodeIncomingData !== undefined && (nodeIncomingData as object[]).length !== 0) {
return false;
}
}
return true;
}
addNodeToBeExecuted(workflow: Workflow, connectionData: IConnection, outputIndex: number, parentNodeName: string, nodeSuccessData: INodeExecutionData[][], runIndex: number): void { addNodeToBeExecuted(workflow: Workflow, connectionData: IConnection, outputIndex: number, parentNodeName: string, nodeSuccessData: INodeExecutionData[][], runIndex: number): void {
let stillDataMissing = false; let stillDataMissing = false;
@ -299,7 +316,7 @@ export class WorkflowExecute {
if (nodeWasWaiting === false) { if (nodeWasWaiting === false) {
// Get a list of all the output nodes that we can check for siblings eaiser // Get a list of all the output nodes that we can check for siblings easier
const checkOutputNodes = []; const checkOutputNodes = [];
for (const outputIndexParent in workflow.connectionsBySourceNode[parentNodeName].main) { for (const outputIndexParent in workflow.connectionsBySourceNode[parentNodeName].main) {
if (!workflow.connectionsBySourceNode[parentNodeName].main.hasOwnProperty(outputIndexParent)) { if (!workflow.connectionsBySourceNode[parentNodeName].main.hasOwnProperty(outputIndexParent)) {
@ -327,8 +344,12 @@ export class WorkflowExecute {
// previously processed one // previously processed one
if (inputData.node !== parentNodeName && checkOutputNodes.includes(inputData.node)) { if (inputData.node !== parentNodeName && checkOutputNodes.includes(inputData.node)) {
// So the parent node will be added anyway which // So the parent node will be added anyway which
// will then process this node next. So nothing to do. // will then process this node next. So nothing to do
continue; // unless the incoming data of the node is empty
// because then it would not be executed
if (!this.incomingConnectionIsEmpty(this.runExecutionData.resultData.runData, workflow.connectionsByDestinationNode[inputData.node].main[0], runIndex)) {
continue;
}
} }
// Check if it is already in the execution stack // Check if it is already in the execution stack
@ -384,7 +405,19 @@ export class WorkflowExecute {
continue; continue;
} }
if (workflow.connectionsByDestinationNode[nodeToAdd] === undefined) { let addEmptyItem = false;
if (workflow.connectionsByDestinationNode[nodeToAdd] === undefined) {
// Add empty item if the node does not have any input connections
addEmptyItem = true;
} else {
if (this.incomingConnectionIsEmpty(this.runExecutionData.resultData.runData, workflow.connectionsByDestinationNode[nodeToAdd].main[0], runIndex)) {
// Add empty item also if the input data is empty
addEmptyItem = true;
}
}
if (addEmptyItem === true) {
// Add only node if it does not have any inputs because else it will // Add only node if it does not have any inputs because else it will
// be added by its input node later anyway. // be added by its input node later anyway.
this.runExecutionData.executionData!.nodeExecutionStack.push( this.runExecutionData.executionData!.nodeExecutionStack.push(
@ -524,7 +557,7 @@ export class WorkflowExecute {
executionData = this.runExecutionData.executionData!.nodeExecutionStack.shift() as IExecuteData; executionData = this.runExecutionData.executionData!.nodeExecutionStack.shift() as IExecuteData;
executionNode = executionData.node; executionNode = executionData.node;
this.executeHook('nodeExecuteBefore', [executionNode.name]); await this.executeHook('nodeExecuteBefore', [executionNode.name]);
// Get the index of the current run // Get the index of the current run
runIndex = 0; runIndex = 0;
@ -689,7 +722,7 @@ export class WorkflowExecute {
// Add the execution data again so that it can get restarted // Add the execution data again so that it can get restarted
this.runExecutionData.executionData!.nodeExecutionStack.unshift(executionData); this.runExecutionData.executionData!.nodeExecutionStack.unshift(executionData);
this.executeHook('nodeExecuteAfter', [executionNode.name, taskData, this.runExecutionData]); await this.executeHook('nodeExecuteAfter', [executionNode.name, taskData, this.runExecutionData]);
break; break;
} }

View file

@ -596,7 +596,7 @@ class NodeTypesClass implements INodeTypes {
let item: INodeExecutionData; let item: INodeExecutionData;
let keepOnlySet: boolean; let keepOnlySet: boolean;
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
keepOnlySet = this.getNodeParameter('keepOnlySet', itemIndex, []) as boolean; keepOnlySet = this.getNodeParameter('keepOnlySet', itemIndex, false) as boolean;
item = items[itemIndex]; item = items[itemIndex];
const options = this.getNodeParameter('options', itemIndex, {}) as IDataObject; const options = this.getNodeParameter('options', itemIndex, {}) as IDataObject;

View file

@ -986,6 +986,172 @@ describe('WorkflowExecute', () => {
}, },
}, },
}, },
{
description: 'should use empty data if input of sibling does not receive any data from parent',
input: {
// Leave the workflowData in regular JSON to be able to easily
// copy it from/in the UI
workflowData: {
"nodes": [
{
"parameters": {},
"name": "Start",
"type": "n8n-nodes-base.start",
"typeVersion": 1,
"position": [
250,
300,
],
},
{
"parameters": {
"conditions": {
"number": [
{
"value1": "={{$json[\"value1\"]}}",
"operation": "equal",
"value2": 1,
},
],
},
},
"name": "IF",
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [
650,
300,
],
},
{
"parameters": {
"values": {
"string": [],
"number": [
{
"name": "value2",
"value": 2,
},
],
},
"options": {},
},
"name": "Set2",
"type": "n8n-nodes-base.set",
"typeVersion": 1,
"position": [
850,
450,
],
},
{
"parameters": {
"values": {
"number": [
{
"name": "value1",
"value": 1,
},
],
},
"options": {},
},
"name": "Set1",
"type": "n8n-nodes-base.set",
"typeVersion": 1,
"position": [
450,
300,
],
},
{
"parameters": {},
"name": "Merge",
"type": "n8n-nodes-base.merge",
"typeVersion": 1,
"position": [
1050,
300,
],
},
],
"connections": {
"Start": {
"main": [
[
{
"node": "Set1",
"type": "main",
"index": 0,
},
],
],
},
"IF": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 0,
},
],
[
{
"node": "Set2",
"type": "main",
"index": 0,
},
],
],
},
"Set2": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 1,
},
],
],
},
"Set1": {
"main": [
[
{
"node": "IF",
"type": "main",
"index": 0,
},
],
],
},
},
},
},
output: {
nodeExecutionOrder: [
'Start',
'Set1',
'IF',
'Set2',
'Merge',
],
nodeData: {
Merge: [
[
{
value1: 1,
},
{
value2: 2,
},
],
],
},
},
},
]; ];

View file

@ -6,3 +6,10 @@ indent_style = tab
end_of_line = lf end_of_line = lf
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true
[package.json]
indent_style = space
indent_size = 2
[*.ts]
quote_type = single

View file

@ -1,6 +1,6 @@
{ {
"name": "n8n-editor-ui", "name": "n8n-editor-ui",
"version": "0.77.0", "version": "0.84.0",
"description": "Workflow Editor UI for n8n", "description": "Workflow Editor UI for n8n",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",
@ -65,7 +65,7 @@
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"lodash.get": "^4.4.2", "lodash.get": "^4.4.2",
"lodash.set": "^4.3.2", "lodash.set": "^4.3.2",
"n8n-workflow": "~0.51.0", "n8n-workflow": "~0.55.0",
"node-sass": "^4.12.0", "node-sass": "^4.12.0",
"normalize-wheel": "^1.0.1", "normalize-wheel": "^1.0.1",
"prismjs": "^1.17.1", "prismjs": "^1.17.1",

View file

@ -314,8 +314,7 @@ export interface IExecutionsListResponse {
} }
export interface IExecutionsCurrentSummaryExtended { export interface IExecutionsCurrentSummaryExtended {
id?: string; id: string;
idActive: string;
finished?: boolean; finished?: boolean;
mode: WorkflowExecuteMode; mode: WorkflowExecuteMode;
retryOf?: string; retryOf?: string;
@ -334,8 +333,7 @@ export interface IExecutionsStopData {
} }
export interface IExecutionsSummary { export interface IExecutionsSummary {
id?: string; // executionIdDb id: string;
idActive?: string; // executionIdActive
mode: WorkflowExecuteMode; mode: WorkflowExecuteMode;
finished?: boolean; finished?: boolean;
retryOf?: string; retryOf?: string;
@ -370,8 +368,7 @@ export interface IPushDataExecutionStarted {
export interface IPushDataExecutionFinished { export interface IPushDataExecutionFinished {
data: IRun; data: IRun;
executionIdActive: string; executionId: string;
executionIdDb?: string;
retryOf?: string; retryOf?: string;
} }

View file

@ -6,7 +6,7 @@
{{parameter.displayName}}: {{parameter.displayName}}:
</div> </div>
<div class="text-editor" @keydown.stop> <div class="text-editor" @keydown.stop>
<prism-editor :lineNumbers="true" :code="value" @change="valueChanged" language="js"></prism-editor> <prism-editor :lineNumbers="true" :code="value" :readonly="isReadOnly" @change="valueChanged" language="js"></prism-editor>
</div> </div>
</div> </div>
</el-dialog> </el-dialog>
@ -14,42 +14,43 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from 'vue';
// @ts-ignore // @ts-ignore
import PrismEditor from 'vue-prism-editor'; import PrismEditor from 'vue-prism-editor';
import { import { genericHelpers } from '@/components/mixins/genericHelpers';
Workflow,
} from 'n8n-workflow';
export default Vue.extend({ import mixins from 'vue-typed-mixins';
name: 'CodeEdit',
props: [
'dialogVisible',
'parameter',
'value',
],
components: {
PrismEditor,
},
data () {
return {
};
},
methods: {
valueChanged (value: string) {
this.$emit('valueChanged', value);
},
closeDialog () { export default mixins(
// Handle the close externally as the visible parameter is an external prop genericHelpers,
// and is so not allowed to be changed here. )
this.$emit('closeDialog'); .extend({
return false; name: 'CodeEdit',
props: [
'dialogVisible',
'parameter',
'value',
],
components: {
PrismEditor,
}, },
}, data () {
}); return {
};
},
methods: {
valueChanged (value: string) {
this.$emit('valueChanged', value);
},
closeDialog () {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('closeDialog');
return false;
},
},
});
</script> </script>
<style scoped> <style scoped>

View file

@ -57,7 +57,6 @@
<template slot-scope="scope"> <template slot-scope="scope">
{{convertToDisplayDate(scope.row.startedAt)}}<br /> {{convertToDisplayDate(scope.row.startedAt)}}<br />
<small v-if="scope.row.id">ID: {{scope.row.id}}</small> <small v-if="scope.row.id">ID: {{scope.row.id}}</small>
<small v-if="scope.row.idActive && scope.row.id === undefined && scope.row.stoppedAt === undefined">Active-ID: {{scope.row.idActive}}</small>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column property="workflowName" label="Name"> <el-table-column property="workflowName" label="Name">
@ -89,14 +88,17 @@
<span class="status-badge success" v-else-if="scope.row.finished"> <span class="status-badge success" v-else-if="scope.row.finished">
Success Success
</span> </span>
<span class="status-badge error" v-else> <span class="status-badge error" v-else-if="scope.row.stoppedAt !== null">
Error Error
</span> </span>
<span class="status-badge warning" v-else>
Unknown
</span>
</el-tooltip> </el-tooltip>
<el-dropdown trigger="click" @command="handleRetryClick"> <el-dropdown trigger="click" @command="handleRetryClick">
<span class="el-dropdown-link"> <span class="el-dropdown-link">
<el-button class="retry-button" circle v-if="scope.row.stoppedAt !== undefined && !scope.row.finished && scope.row.retryOf === undefined && scope.row.retrySuccessId === undefined" type="text" size="small" title="Retry execution"> <el-button class="retry-button" v-bind:class="{ warning: scope.row.stoppedAt === null }" circle v-if="scope.row.stoppedAt !== undefined && !scope.row.finished && scope.row.retryOf === undefined && scope.row.retrySuccessId === undefined" type="text" size="small" title="Retry execution">
<font-awesome-icon icon="redo" /> <font-awesome-icon icon="redo" />
</el-button> </el-button>
</span> </span>
@ -126,8 +128,8 @@
</el-table-column> </el-table-column>
<el-table-column label="" width="100" align="center"> <el-table-column label="" width="100" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
<span v-if="scope.row.stoppedAt === undefined && scope.row.idActive"> <span v-if="scope.row.stoppedAt === undefined">
<el-button circle title="Stop Execution" @click.stop="stopExecution(scope.row.idActive)" :loading="stoppingExecutions.includes(scope.row.idActive)" size="mini"> <el-button circle title="Stop Execution" @click.stop="stopExecution(scope.row.id)" :loading="stoppingExecutions.includes(scope.row.id)" size="mini">
<font-awesome-icon icon="stop" /> <font-awesome-icon icon="stop" />
</el-button> </el-button>
</span> </span>
@ -173,6 +175,10 @@ import {
IDataObject, IDataObject,
} from 'n8n-workflow'; } from 'n8n-workflow';
import {
range as _range,
} from 'lodash';
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
export default mixins( export default mixins(
@ -411,14 +417,16 @@ export default mixins(
this.$store.commit('setActiveExecutions', activeExecutions); this.$store.commit('setActiveExecutions', activeExecutions);
}, },
async loadAutoRefresh () : Promise<void> { async loadAutoRefresh () : Promise<void> {
let firstId: string | number | undefined = 0; const filter = this.workflowFilterPast;
if (this.finishedExecutions.length !== 0) { // We cannot use firstId here as some executions finish out of order. Let's say
firstId = this.finishedExecutions[0].id; // You have execution ids 500 to 505 running.
} // Suppose 504 finishes before 500, 501, 502 and 503.
const activeExecutionsPromise: Promise<IExecutionsListResponse> = this.restApi().getPastExecutions({}, 100, undefined, firstId); // iF you use firstId, filtering id >= 504 you won't
// ever get ids 500, 501, 502 and 503 when they finish
const pastExecutionsPromise: Promise<IExecutionsListResponse> = this.restApi().getPastExecutions(filter, 30);
const currentExecutionsPromise: Promise<IExecutionsCurrentSummaryExtended[]> = this.restApi().getCurrentExecutions({}); const currentExecutionsPromise: Promise<IExecutionsCurrentSummaryExtended[]> = this.restApi().getCurrentExecutions({});
const results = await Promise.all([activeExecutionsPromise, currentExecutionsPromise]); const results = await Promise.all([pastExecutionsPromise, currentExecutionsPromise]);
for (const activeExecution of results[1]) { for (const activeExecution of results[1]) {
if (activeExecution.workflowId !== undefined && activeExecution.workflowName === undefined) { if (activeExecution.workflowId !== undefined && activeExecution.workflowName === undefined) {
@ -428,7 +436,55 @@ export default mixins(
this.$store.commit('setActiveExecutions', results[1]); this.$store.commit('setActiveExecutions', results[1]);
this.finishedExecutions.unshift.apply(this.finishedExecutions, results[0].results); const alreadyPresentExecutionIds = this.finishedExecutions.map(exec => exec.id);
let lastId = 0;
const gaps = [] as number[];
for(let i = results[0].results.length - 1; i >= 0; i--) {
const currentItem = results[0].results[i];
const currentId = parseInt(currentItem.id, 10);
if (lastId !== 0 && isNaN(currentId) === false) {
// We are doing this iteration to detect possible gaps.
// The gaps are used to remove executions that finished
// and were deleted from database but were displaying
// in this list while running.
if (currentId - lastId > 1) {
// We have some gaps.
const range = _range(lastId + 1, currentId);
gaps.push(...range);
}
}
lastId = parseInt(currentItem.id, 10) || 0;
// Check new results from end to start
// Add new items accordingly.
const executionIndex = alreadyPresentExecutionIds.indexOf(currentItem.id);
if (executionIndex !== -1) {
// Execution that we received is already present.
if (this.finishedExecutions[executionIndex].finished === false && currentItem.finished === true) {
// Concurrency stuff. This might happen if the execution finishes
// prior to saving all information to database. Somewhat rare but
// With auto refresh and several executions, it happens sometimes.
// So we replace the execution data so it displays correctly.
this.finishedExecutions[executionIndex] = currentItem;
}
continue;
}
// Find the correct position to place this newcomer
let j;
for (j = this.finishedExecutions.length - 1; j >= 0; j--) {
if (currentItem.id < this.finishedExecutions[j].id) {
this.finishedExecutions.splice(j + 1, 0, currentItem);
break;
}
}
if (j === -1) {
this.finishedExecutions.unshift(currentItem);
}
}
this.finishedExecutions = this.finishedExecutions.filter(execution => !gaps.includes(parseInt(execution.id, 10)) && lastId >= parseInt(execution.id, 10));
this.finishedExecutionsCount = results[0].count; this.finishedExecutionsCount = results[0].count;
}, },
async loadFinishedExecutions (): Promise<void> { async loadFinishedExecutions (): Promise<void> {
@ -554,6 +610,8 @@ export default mixins(
return `The workflow execution was a retry of "${entry.retryOf}" and failed.<br />New retries have to be started from the original execution.`; return `The workflow execution was a retry of "${entry.retryOf}" and failed.<br />New retries have to be started from the original execution.`;
} else if (entry.retrySuccessId !== undefined) { } else if (entry.retrySuccessId !== undefined) {
return `The workflow execution failed but the retry "${entry.retrySuccessId}" was successful.`; return `The workflow execution failed but the retry "${entry.retrySuccessId}" was successful.`;
} else if (entry.stoppedAt === null) {
return 'The workflow execution is probably still running but it may have crashed and n8n cannot safely tell. ';
} else { } else {
return 'The workflow execution failed.'; return 'The workflow execution failed.';
} }
@ -610,6 +668,10 @@ export default mixins(
color: $--custom-error-text; color: $--custom-error-text;
background-color: $--custom-error-background; background-color: $--custom-error-background;
margin-left: 5px; margin-left: 5px;
&.warning {
background-color: $--custom-warning-background;
color: $--custom-warning-text;
}
} }
.selection-options { .selection-options {
@ -640,6 +702,11 @@ export default mixins(
background-color: $--custom-success-background; background-color: $--custom-success-background;
color: $--custom-success-text; color: $--custom-success-text;
} }
&.warning {
background-color: $--custom-warning-background;
color: $--custom-warning-text;
}
} }
.workflow-name { .workflow-name {

View file

@ -12,23 +12,22 @@ import 'quill/dist/quill.core.css';
import Quill, { DeltaOperation } from 'quill'; import Quill, { DeltaOperation } from 'quill';
// @ts-ignore // @ts-ignore
import AutoFormat, { AutoformatHelperAttribute } from 'quill-autoformat'; import AutoFormat from 'quill-autoformat';
import { import {
NodeParameterValue, NodeParameterValue,
Workflow, Workflow,
WorkflowDataProxy,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { import {
IExecutionResponse,
IVariableItemSelected, IVariableItemSelected,
IVariableSelectorOption,
} from '@/Interface'; } from '@/Interface';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { workflowHelpers } from '@/components/mixins/workflowHelpers'; import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
export default mixins( export default mixins(
genericHelpers,
workflowHelpers, workflowHelpers,
) )
.extend({ .extend({
@ -119,7 +118,7 @@ export default mixins(
}; };
this.editor = new Quill(this.$refs['expression-editor'] as Element, { this.editor = new Quill(this.$refs['expression-editor'] as Element, {
readOnly: !!this.resolvedValue, readOnly: !!this.resolvedValue || this.isReadOnly,
modules: { modules: {
autoformat: {}, autoformat: {},
keyboard: { keyboard: {

View file

@ -10,7 +10,7 @@
<prism-editor v-if="!codeEditDialogVisible" :lineNumbers="true" :readonly="true" :code="displayValue" language="js"></prism-editor> <prism-editor v-if="!codeEditDialogVisible" :lineNumbers="true" :readonly="true" :code="displayValue" language="js"></prism-editor>
</div> </div>
<el-input v-else v-model="tempValue" ref="inputField" size="small" :type="getStringInputType" :rows="getArgument('rows')" :value="displayValue" :disabled="isReadOnly" @change="valueChanged" @keydown.stop @focus="setFocus" :title="displayTitle" :placeholder="isValueExpression?'':parameter.placeholder"> <el-input v-else v-model="tempValue" ref="inputField" size="small" :type="getStringInputType" :rows="getArgument('rows')" :value="displayValue" :disabled="!isValueExpression && isReadOnly" @change="valueChanged" @keydown.stop @focus="setFocus" :title="displayTitle" :placeholder="isValueExpression?'':parameter.placeholder">
<font-awesome-icon v-if="!isValueExpression && !isReadOnly" slot="suffix" icon="external-link-alt" class="edit-window-button clickable" title="Open Edit Window" @click="displayEditDialog()" /> <font-awesome-icon v-if="!isValueExpression && !isReadOnly" slot="suffix" icon="external-link-alt" class="edit-window-button clickable" title="Open Edit Window" @click="displayEditDialog()" />
</el-input> </el-input>
</div> </div>
@ -523,9 +523,6 @@ export default mixins(
this.valueChanged(value); this.valueChanged(value);
}, },
setFocus () { setFocus () {
if (this.isReadOnly === true) {
return;
}
if (this.isValueExpression) { if (this.isValueExpression) {
this.expressionEditDialogVisible = true; this.expressionEditDialogVisible = true;
return; return;

View file

@ -20,9 +20,9 @@
<div class="header"> <div class="header">
<div class="title-text"> <div class="title-text">
<strong v-if="dataCount < maxDisplayItems"> <strong v-if="dataCount < maxDisplayItems">
Results: {{ dataCount }} Items: {{ dataCount }}
</strong> </strong>
<strong v-else>Results: <strong v-else>Items:
<el-select v-model="maxDisplayItems" @click.stop> <el-select v-model="maxDisplayItems" @click.stop>
<el-option v-for="option in maxDisplayItemsOptions" :label="option" :value="option" :key="option" /> <el-option v-for="option in maxDisplayItemsOptions" :label="option" :value="option" :key="option" />
</el-select>&nbsp;/ </el-select>&nbsp;/
@ -157,6 +157,10 @@
<div class="label">File Name: </div> <div class="label">File Name: </div>
<div class="value">{{binaryData.fileName}}</div> <div class="value">{{binaryData.fileName}}</div>
</div> </div>
<div v-if="binaryData.directory">
<div class="label">Directory: </div>
<div class="value">{{binaryData.directory}}</div>
</div>
<div v-if="binaryData.fileExtension"> <div v-if="binaryData.fileExtension">
<div class="label">File Extension:</div> <div class="label">File Extension:</div>
<div class="value">{{binaryData.fileExtension}}</div> <div class="value">{{binaryData.fileExtension}}</div>

View file

@ -114,6 +114,9 @@ export default mixins(
// Has still options left so return // Has still options left so return
inputData.options = this.sortOptions(newOptions); inputData.options = this.sortOptions(newOptions);
return inputData; return inputData;
} else if (Array.isArray(newOptions) && newOptions.length === 0) {
delete inputData.options;
return inputData;
} }
// Has no options left so remove // Has no options left so remove
return null; return null;

View file

@ -23,14 +23,15 @@ export const mouseSelect = mixins(
this.selectBox.id = 'select-box'; this.selectBox.id = 'select-box';
this.selectBox.style.margin = '0px auto'; this.selectBox.style.margin = '0px auto';
this.selectBox.style.border = '2px dotted #FF0000'; this.selectBox.style.border = '2px dotted #FF0000';
this.selectBox.style.position = 'fixed'; // Positioned absolutely within #node-view. This is consistent with how nodes are positioned.
this.selectBox.style.position = 'absolute';
this.selectBox.style.zIndex = '100'; this.selectBox.style.zIndex = '100';
this.selectBox.style.visibility = 'hidden'; this.selectBox.style.visibility = 'hidden';
this.selectBox.addEventListener('mouseup', this.mouseUpMouseSelect); this.selectBox.addEventListener('mouseup', this.mouseUpMouseSelect);
// document.body.appendChild(this.selectBox); const nodeViewEl = this.$el.querySelector('#node-view') as HTMLDivElement;
this.$el.appendChild(this.selectBox); nodeViewEl.appendChild(this.selectBox);
}, },
isCtrlKeyPressed (e: MouseEvent | KeyboardEvent): boolean { isCtrlKeyPressed (e: MouseEvent | KeyboardEvent): boolean {
if (this.isTouchDevice === true) { if (this.isTouchDevice === true) {
@ -41,14 +42,28 @@ export const mouseSelect = mixins(
} }
return e.ctrlKey; return e.ctrlKey;
}, },
/**
* Gets mouse position within the node view. Both node view offset and scale (zoom) are considered when
* calculating position.
*
* @param event - mouse event within node view
*/
getMousePositionWithinNodeView (event: MouseEvent) {
// @ts-ignore
const nodeViewScale = this.nodeViewScale;
const offsetPosition = this.$store.getters.getNodeViewOffsetPosition;
return {
x: (event.pageX - offsetPosition[0]) / nodeViewScale,
y: (event.pageY - offsetPosition[1]) / nodeViewScale,
};
},
showSelectBox (event: MouseEvent) { showSelectBox (event: MouseEvent) {
// @ts-ignore this.selectBox = Object.assign(this.selectBox, this.getMousePositionWithinNodeView(event));
this.selectBox.x = event.pageX;
// @ts-ignore
this.selectBox.y = event.pageY;
this.selectBox.style.left = event.pageX + 'px'; // @ts-ignore
this.selectBox.style.top = event.pageY + 'px'; this.selectBox.style.left = this.selectBox.x + 'px';
// @ts-ignore
this.selectBox.style.top = this.selectBox.y + 'px';
this.selectBox.style.visibility = 'visible'; this.selectBox.style.visibility = 'visible';
this.selectActive = true; this.selectActive = true;
@ -75,25 +90,21 @@ export const mouseSelect = mixins(
this.selectActive = false; this.selectActive = false;
}, },
getSelectionBox (event: MouseEvent) { getSelectionBox (event: MouseEvent) {
const {x, y} = this.getMousePositionWithinNodeView(event);
return { return {
// @ts-ignore // @ts-ignore
x: Math.min(event.pageX, this.selectBox.x), x: Math.min(x, this.selectBox.x),
// @ts-ignore // @ts-ignore
y: Math.min(event.pageY, this.selectBox.y), y: Math.min(y, this.selectBox.y),
// @ts-ignore // @ts-ignore
width: Math.abs(event.pageX - this.selectBox.x), width: Math.abs(x - this.selectBox.x),
// @ts-ignore // @ts-ignore
height: Math.abs(event.pageY - this.selectBox.y), height: Math.abs(y - this.selectBox.y),
}; };
}, },
getNodesInSelection (event: MouseEvent): INodeUi[] { getNodesInSelection (event: MouseEvent): INodeUi[] {
const returnNodes: INodeUi[] = []; const returnNodes: INodeUi[] = [];
const selectionBox = this.getSelectionBox(event); const selectionBox = this.getSelectionBox(event);
const offsetPosition = this.$store.getters.getNodeViewOffsetPosition;
// Consider the offset of the workflow when it got moved
selectionBox.x -= offsetPosition[0];
selectionBox.y -= offsetPosition[1];
// Go through all nodes and check if they are selected // Go through all nodes and check if they are selected
this.$store.getters.allNodes.forEach((node: INodeUi) => { this.$store.getters.allNodes.forEach((node: INodeUi) => {

View file

@ -191,7 +191,7 @@ export const pushConnection = mixins(
return false; return false;
} }
if (this.$store.getters.activeExecutionId !== pushData.executionIdActive) { if (this.$store.getters.activeExecutionId !== pushData.executionId) {
// The workflow which did finish execution did either not get started // The workflow which did finish execution did either not get started
// by this session or we do not have the execution id yet. // by this session or we do not have the execution id yet.
if (isRetry !== true) { if (isRetry !== true) {
@ -242,7 +242,7 @@ export const pushConnection = mixins(
const pushData = receivedData.data as IPushDataExecutionStarted; const pushData = receivedData.data as IPushDataExecutionStarted;
const executionData: IExecutionsCurrentSummaryExtended = { const executionData: IExecutionsCurrentSummaryExtended = {
idActive: pushData.executionId, id: pushData.executionId,
finished: false, finished: false,
mode: pushData.mode, mode: pushData.mode,
startedAt: pushData.startedAt, startedAt: pushData.startedAt,

View file

@ -22,6 +22,8 @@ $--custom-running-background : #ffffe5;
$--custom-running-text : #eb9422; $--custom-running-text : #eb9422;
$--custom-success-background : #e3f0e4; $--custom-success-background : #e3f0e4;
$--custom-success-text : #40c351; $--custom-success-text : #40c351;
$--custom-warning-background : #ffffe5;
$--custom-warning-text : #eb9422;
$--custom-node-view-background : #faf9fe; $--custom-node-view-background : #faf9fe;

View file

@ -99,7 +99,7 @@ export const store = new Vuex.Store({
addActiveExecution (state, newActiveExecution: IExecutionsCurrentSummaryExtended) { addActiveExecution (state, newActiveExecution: IExecutionsCurrentSummaryExtended) {
// Check if the execution exists already // Check if the execution exists already
const activeExecution = state.activeExecutions.find(execution => { const activeExecution = state.activeExecutions.find(execution => {
return execution.idActive === newActiveExecution.idActive; return execution.id === newActiveExecution.id;
}); });
if (activeExecution !== undefined) { if (activeExecution !== undefined) {
@ -115,7 +115,7 @@ export const store = new Vuex.Store({
finishActiveExecution (state, finishedActiveExecution: IPushDataExecutionFinished) { finishActiveExecution (state, finishedActiveExecution: IPushDataExecutionFinished) {
// Find the execution to set to finished // Find the execution to set to finished
const activeExecution = state.activeExecutions.find(execution => { const activeExecution = state.activeExecutions.find(execution => {
return execution.idActive === finishedActiveExecution.executionIdActive; return execution.id === finishedActiveExecution.executionId;
}); });
if (activeExecution === undefined) { if (activeExecution === undefined) {
@ -123,8 +123,8 @@ export const store = new Vuex.Store({
return; return;
} }
if (finishedActiveExecution.executionIdDb !== undefined) { if (finishedActiveExecution.executionId !== undefined) {
Vue.set(activeExecution, 'id', finishedActiveExecution.executionIdDb); Vue.set(activeExecution, 'id', finishedActiveExecution.executionId);
} }
Vue.set(activeExecution, 'finished', finishedActiveExecution.data.finished); Vue.set(activeExecution, 'finished', finishedActiveExecution.data.finished);

View file

@ -742,8 +742,7 @@ export default mixins(
} as IRun; } as IRun;
const pushData = { const pushData = {
data: executedData, data: executedData,
executionIdActive: executionId, executionId,
executionIdDb: executionId,
retryOf: execution.retryOf, retryOf: execution.retryOf,
} as IPushDataExecutionFinished; } as IPushDataExecutionFinished;
this.$store.commit('finishActiveExecution', pushData); this.$store.commit('finishActiveExecution', pushData);
@ -759,8 +758,6 @@ export default mixins(
} else { } else {
this.$showError(error, 'Problem stopping execution', 'There was a problem stopping the execuction:'); this.$showError(error, 'Problem stopping execution', 'There was a problem stopping the execuction:');
} }
} }
this.stopExecutionInProgress = false; this.stopExecutionInProgress = false;
}, },
@ -964,7 +961,7 @@ export default mixins(
return originalName; return originalName;
} }
const nameMatch = originalName.match(/(.*[a-zA-Z])(\d*)/); const nameMatch = originalName.match(/(.*\D+)(\d*)/);
let ignore, baseName, nameIndex, uniqueName; let ignore, baseName, nameIndex, uniqueName;
let index = 1; let index = 1;
@ -2142,6 +2139,7 @@ export default mixins(
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
transform-origin: 0 0;
} }
.node-view-background { .node-view-background {

View file

@ -1,70 +1,70 @@
{ {
"name": "n8n-node-dev", "name": "n8n-node-dev",
"version": "0.11.0", "version": "0.11.0",
"description": "CLI to simplify n8n credentials/node development", "description": "CLI to simplify n8n credentials/node development",
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io", "homepage": "https://n8n.io",
"author": { "author": {
"name": "Jan Oberhauser", "name": "Jan Oberhauser",
"email": "jan@n8n.io" "email": "jan@n8n.io"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/n8n-io/n8n.git" "url": "git+https://github.com/n8n-io/n8n.git"
}, },
"main": "dist/src/index", "main": "dist/src/index",
"types": "dist/src/index.d.ts", "types": "dist/src/index.d.ts",
"oclif": { "oclif": {
"commands": "./dist/commands", "commands": "./dist/commands",
"bin": "n8n-node-dev" "bin": "n8n-node-dev"
}, },
"scripts": { "scripts": {
"dev": "npm run watch", "dev": "npm run watch",
"build": "tsc", "build": "tsc",
"postpack": "rm -f oclif.manifest.json", "postpack": "rm -f oclif.manifest.json",
"prepack": "echo \"Building project...\" && rm -rf dist && tsc -b && oclif-dev manifest", "prepack": "echo \"Building project...\" && rm -rf dist && tsc -b && oclif-dev manifest",
"tslint": "tslint -p tsconfig.json -c tslint.json", "tslint": "tslint -p tsconfig.json -c tslint.json",
"tslintfix": "tslint --fix -p tsconfig.json -c tslint.json", "tslintfix": "tslint --fix -p tsconfig.json -c tslint.json",
"watch": "tsc --watch" "watch": "tsc --watch"
}, },
"bin": { "bin": {
"n8n-node-dev": "./bin/n8n-node-dev" "n8n-node-dev": "./bin/n8n-node-dev"
}, },
"keywords": [ "keywords": [
"development", "development",
"node", "node",
"helper", "helper",
"n8n" "n8n"
], ],
"files": [ "files": [
"bin", "bin",
"dist", "dist",
"templates", "templates",
"oclif.manifest.json", "oclif.manifest.json",
"src/tsconfig-build.json" "src/tsconfig-build.json"
], ],
"devDependencies": { "devDependencies": {
"@oclif/dev-cli": "^1.22.2", "@oclif/dev-cli": "^1.22.2",
"@types/copyfiles": "^2.1.1", "@types/copyfiles": "^2.1.1",
"@types/inquirer": "^6.5.0", "@types/inquirer": "^6.5.0",
"@types/tmp": "^0.1.0", "@types/tmp": "^0.1.0",
"@types/vorpal": "^1.11.0", "@types/vorpal": "^1.11.0",
"tslint": "^6.1.2" "tslint": "^6.1.2"
}, },
"dependencies": { "dependencies": {
"@oclif/command": "^1.5.18", "@oclif/command": "^1.5.18",
"@oclif/errors": "^1.2.2", "@oclif/errors": "^1.2.2",
"@types/express": "^4.17.6", "@types/express": "^4.17.6",
"@types/node": "14.0.27", "@types/node": "14.0.27",
"change-case": "^4.1.1", "change-case": "^4.1.1",
"copyfiles": "^2.1.1", "copyfiles": "^2.1.1",
"inquirer": "^7.0.1", "inquirer": "^7.0.1",
"n8n-core": "^0.48.0", "n8n-core": "^0.48.0",
"n8n-workflow": "^0.42.0", "n8n-workflow": "^0.42.0",
"oauth-1.0a": "^2.2.6", "oauth-1.0a": "^2.2.6",
"replace-in-file": "^6.0.0", "replace-in-file": "^6.0.0",
"request": "^2.88.2", "request": "^2.88.2",
"tmp-promise": "^2.0.2", "tmp-promise": "^2.0.2",
"typescript": "~3.9.7" "typescript": "~3.9.7"
} }
} }

View file

@ -0,0 +1,18 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
export class ApiTemplateIoApi implements ICredentialType {
name = 'apiTemplateIoApi';
displayName = 'APITemplate.io API';
documentationUrl = 'apiTemplateIo';
properties = [
{
displayName: 'API Key',
name: 'apiKey',
type: 'string' as NodePropertyTypes,
default: '',
},
];
}

View file

@ -0,0 +1,18 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
export class AutopilotApi implements ICredentialType {
name = 'autopilotApi';
displayName = 'Autopilot API';
documentationUrl = 'autopilot';
properties = [
{
displayName: 'API Key',
name: 'apiKey',
type: 'string' as NodePropertyTypes,
default: '',
},
];
}

View file

@ -96,6 +96,21 @@ export class Aws implements ICredentialType {
default: '', default: '',
placeholder: 'https://email.{region}.amazonaws.com', placeholder: 'https://email.{region}.amazonaws.com',
}, },
{
displayName: 'SQS Endpoint',
name: 'sqsEndpoint',
description: 'If you use Amazon VPC to host n8n, you can establish a connection between your VPC and SQS using a VPC endpoint. Leave blank to use the default endpoint.',
type: 'string' as NodePropertyTypes,
displayOptions: {
show: {
customEndpoints: [
true,
],
},
},
default: '',
placeholder: 'https://sqs.{region}.amazonaws.com',
},
{ {
displayName: 'S3 Endpoint', displayName: 'S3 Endpoint',
name: 's3Endpoint', name: 's3Endpoint',

View file

@ -0,0 +1,56 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
// https://bitwarden.com/help/article/public-api/#authentication
export class BitwardenApi implements ICredentialType {
name = 'bitwardenApi';
displayName = 'Bitwarden API';
documentationUrl = 'bitwarden';
properties = [
{
displayName: 'Client ID',
name: 'clientId',
type: 'string' as NodePropertyTypes,
default: '',
},
{
displayName: 'Client Secret',
name: 'clientSecret',
type: 'string' as NodePropertyTypes,
default: '',
},
{
displayName: 'Environment',
name: 'environment',
type: 'options' as NodePropertyTypes,
default: 'cloudHosted',
options: [
{
name: 'Cloud-hosted',
value: 'cloudHosted',
},
{
name: 'Self-hosted',
value: 'selfHosted',
},
],
},
{
displayName: 'Self-hosted domain',
name: 'domain',
type: 'string' as NodePropertyTypes,
default: '',
placeholder: 'https://www.mydomain.com',
displayOptions: {
show: {
environment: [
'selfHosted',
],
},
},
},
];
}

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,15 @@
import { ICredentialType, NodePropertyTypes } from 'n8n-workflow';
export class DeepLApi implements ICredentialType {
name = 'deepLApi';
displayName = 'DeepL API';
documentationUrl = 'deepL';
properties = [
{
displayName: 'API Key',
name: 'apiKey',
type: 'string' as NodePropertyTypes,
default: '',
},
];
}

View file

@ -0,0 +1,24 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
export class DemioApi implements ICredentialType {
name = 'demioApi';
displayName = 'Demio API';
documentationUrl = 'demio';
properties = [
{
displayName: 'API Key',
name: 'apiKey',
type: 'string' as NodePropertyTypes,
default: '',
},
{
displayName: 'API Secret',
name: 'apiSecret',
type: 'string' as NodePropertyTypes,
default: '',
},
];
}

View file

@ -14,5 +14,21 @@ export class DropboxApi implements ICredentialType {
type: 'string' as NodePropertyTypes, type: 'string' as NodePropertyTypes,
default: '', default: '',
}, },
{
displayName: 'APP Access Type',
name: 'accessType',
type: 'options' as NodePropertyTypes,
options: [
{
name: 'App Folder',
value: 'folder',
},
{
name: 'Full Dropbox',
value: 'full',
},
],
default: 'full',
},
]; ];
} }

View file

@ -7,6 +7,7 @@ const scopes = [
'files.content.write', 'files.content.write',
'files.content.read', 'files.content.read',
'sharing.read', 'sharing.read',
'account_info.read',
]; ];
export class DropboxOAuth2Api implements ICredentialType { export class DropboxOAuth2Api implements ICredentialType {
@ -41,7 +42,7 @@ export class DropboxOAuth2Api implements ICredentialType {
displayName: 'Auth URI Query Parameters', displayName: 'Auth URI Query Parameters',
name: 'authQueryParameters', name: 'authQueryParameters',
type: 'hidden' as NodePropertyTypes, type: 'hidden' as NodePropertyTypes,
default: 'token_access_type=offline', default: 'token_access_type=offline&force_reapprove=true',
}, },
{ {
displayName: 'Authentication', displayName: 'Authentication',
@ -49,5 +50,21 @@ export class DropboxOAuth2Api implements ICredentialType {
type: 'hidden' as NodePropertyTypes, type: 'hidden' as NodePropertyTypes,
default: 'header', default: 'header',
}, },
{
displayName: 'APP Access Type',
name: 'accessType',
type: 'options' as NodePropertyTypes,
options: [
{
name: 'App Folder',
value: 'folder',
},
{
name: 'Full Dropbox',
value: 'full',
},
],
default: 'full',
},
]; ];
} }

View file

@ -0,0 +1,32 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
export class ERPNextApi implements ICredentialType {
name = 'erpNextApi';
displayName = 'ERPNext API';
documentationUrl = 'erpnext';
properties = [
{
displayName: 'API Key',
name: 'apiKey',
type: 'string' as NodePropertyTypes,
default: '',
},
{
displayName: 'API Secret',
name: 'apiSecret',
type: 'string' as NodePropertyTypes,
default: '',
},
{
displayName: 'Subdomain',
name: 'subdomain',
type: 'string' as NodePropertyTypes,
default: '',
placeholder: 'n8n',
description: 'ERPNext subdomain. For instance, entering n8n will make the url look like: https://n8n.erpnext.com/.',
},
];
}

View file

@ -0,0 +1,18 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
export class EmeliaApi implements ICredentialType {
name = 'emeliaApi';
displayName = 'Emelia API';
documentationUrl = 'emelia';
properties = [
{
displayName: 'API Key',
name: 'apiKey',
type: 'string' as NodePropertyTypes,
default: '',
},
];
}

View file

@ -0,0 +1,45 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
export class GoToWebinarOAuth2Api implements ICredentialType {
name = 'goToWebinarOAuth2Api';
extends = [
'oAuth2Api',
];
displayName = 'GoToWebinar OAuth2 API';
documentationUrl = 'goToWebinar';
properties = [
{
displayName: 'Authorization URL',
name: 'authUrl',
type: 'hidden' as NodePropertyTypes,
default: 'https://api.getgo.com/oauth/v2/authorize',
},
{
displayName: 'Access Token URL',
name: 'accessTokenUrl',
type: 'hidden' as NodePropertyTypes,
default: 'https://api.getgo.com/oauth/v2/token',
},
{
displayName: 'Scope',
name: 'scope',
type: 'hidden' as NodePropertyTypes,
default: '', // set when creating the OAuth client
},
{
displayName: 'Auth URI Query Parameters',
name: 'authQueryParameters',
type: 'hidden' as NodePropertyTypes,
default: '',
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'hidden' as NodePropertyTypes,
default: 'header',
},
];
}

View file

@ -0,0 +1,26 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
const scopes = [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/presentations',
];
export class GoogleSlidesOAuth2Api implements ICredentialType {
name = 'googleSlidesOAuth2Api';
extends = [
'googleOAuth2Api',
];
displayName = 'Google Slides OAuth2 API';
documentationUrl = 'google';
properties = [
{
displayName: 'Scope',
name: 'scope',
type: 'hidden' as NodePropertyTypes,
default: scopes.join(' '),
},
];
}

View file

@ -28,10 +28,23 @@ export class Kafka implements ICredentialType {
type: 'boolean' as NodePropertyTypes, type: 'boolean' as NodePropertyTypes,
default: true, default: true,
}, },
{
displayName: 'Authentication',
name: 'authentication',
type: 'boolean' as NodePropertyTypes,
default: false,
},
{ {
displayName: 'Username', displayName: 'Username',
name: 'username', name: 'username',
type: 'string' as NodePropertyTypes, type: 'string' as NodePropertyTypes,
displayOptions: {
show: {
authentication: [
true,
],
},
},
default: '', default: '',
description: 'Optional username if authenticated is required.', description: 'Optional username if authenticated is required.',
}, },
@ -39,11 +52,46 @@ export class Kafka implements ICredentialType {
displayName: 'Password', displayName: 'Password',
name: 'password', name: 'password',
type: 'string' as NodePropertyTypes, type: 'string' as NodePropertyTypes,
displayOptions: {
show: {
authentication: [
true,
],
},
},
typeOptions: { typeOptions: {
password: true, password: true,
}, },
default: '', default: '',
description: 'Optional password if authenticated is required.', description: 'Optional password if authenticated is required.',
}, },
{
displayName: 'SASL mechanism',
name: 'saslMechanism',
type: 'options' as NodePropertyTypes,
displayOptions: {
show: {
authentication: [
true,
],
},
},
options: [
{
name: 'plain',
value: 'plain',
},
{
name: 'scram-sha-256',
value: 'scram-sha-256',
},
{
name: 'scram-sha-512',
value: 'scram-sha-512',
},
],
default: 'plain',
description: 'The SASL mechanism.',
},
]; ];
} }

View file

@ -0,0 +1,18 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
export class LemlistApi implements ICredentialType {
name = 'lemlistApi';
displayName = 'Lemlist API';
documentationUrl = 'lemlist';
properties = [
{
displayName: 'API Key',
name: 'apiKey',
type: 'string' as NodePropertyTypes,
default: '',
},
];
}

View file

@ -50,5 +50,12 @@ export class MicrosoftSql implements ICredentialType {
type: 'boolean' as NodePropertyTypes, type: 'boolean' as NodePropertyTypes,
default: true, default: true,
}, },
{
displayName: 'Connect Timeout',
name: 'connectTimeout',
type: 'number' as NodePropertyTypes,
default: 15000,
description: 'Connection timeout in ms.',
},
]; ];
} }

View file

@ -0,0 +1,18 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
export class OuraApi implements ICredentialType {
name = 'ouraApi';
displayName = 'Oura API';
documentationUrl = 'oura';
properties = [
{
displayName: 'Personal Access Token',
name: 'accessToken',
type: 'string' as NodePropertyTypes,
default: '',
},
];
}

View file

@ -0,0 +1,24 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
export class PlivoApi implements ICredentialType {
name = 'plivoApi';
displayName = 'Plivo API';
documentationUrl = 'plivo';
properties = [
{
displayName: 'Auth ID',
name: 'authId',
type: 'string' as NodePropertyTypes,
default: '',
},
{
displayName: 'Auth Token',
name: 'authToken',
type: 'string' as NodePropertyTypes,
default: '',
},
];
}

View file

@ -0,0 +1,23 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
export class PostHogApi implements ICredentialType {
name = 'postHogApi';
displayName = 'PostHog API';
properties = [
{
displayName: 'URL',
name: 'url',
type: 'string' as NodePropertyTypes,
default: 'https://app.posthog.com',
},
{
displayName: 'API Key',
name: 'apiKey',
type: 'string' as NodePropertyTypes,
default: '',
},
];
}

View file

@ -0,0 +1,68 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
const scopes = [
'com.intuit.quickbooks.accounting',
'com.intuit.quickbooks.payment',
];
// https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization
export class QuickBooksOAuth2Api implements ICredentialType {
name = 'quickBooksOAuth2Api';
extends = [
'oAuth2Api',
];
displayName = 'QuickBooks OAuth2 API';
documentationUrl = 'quickbooks';
properties = [
{
displayName: 'Authorization URL',
name: 'authUrl',
type: 'hidden' as NodePropertyTypes,
default: 'https://appcenter.intuit.com/connect/oauth2',
},
{
displayName: 'Access Token URL',
name: 'accessTokenUrl',
type: 'hidden' as NodePropertyTypes,
default: 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer',
},
{
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',
},
{
displayName: 'Environment',
name: 'environment',
type: 'options' as NodePropertyTypes,
default: 'production',
options: [
{
name: 'Production',
value: 'production',
},
{
name: 'Sandbox',
value: 'sandbox',
},
],
},
];
}

View file

@ -0,0 +1,47 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
// https://developer.raindrop.io/v1/authentication
export class RaindropOAuth2Api implements ICredentialType {
name = 'raindropOAuth2Api';
extends = [
'oAuth2Api',
];
displayName = 'Raindrop OAuth2 API';
documentationUrl = 'raindrop';
properties = [
{
displayName: 'Authorization URL',
name: 'authUrl',
type: 'hidden' as NodePropertyTypes,
default: 'https://raindrop.io/oauth/authorize',
},
{
displayName: 'Access Token URL',
name: 'accessTokenUrl',
type: 'hidden' as NodePropertyTypes,
default: 'https://raindrop.io/oauth/access_token',
},
{
displayName: 'Auth URI Query Parameters',
name: 'authQueryParameters',
type: 'hidden' as NodePropertyTypes,
default: '',
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'hidden' as NodePropertyTypes,
default: 'body',
},
{
displayName: 'Scope',
name: 'scope',
type: 'hidden' as NodePropertyTypes,
default: '',
},
];
}

View file

@ -36,7 +36,7 @@ export class SpotifyOAuth2Api implements ICredentialType {
displayName: 'Scope', displayName: 'Scope',
name: 'scope', name: 'scope',
type: 'hidden' as NodePropertyTypes, type: 'hidden' as NodePropertyTypes,
default: 'user-read-playback-state playlist-read-collaborative user-modify-playback-state playlist-modify-public user-read-currently-playing playlist-read-private user-read-recently-played playlist-modify-private', default: 'user-read-playback-state playlist-read-collaborative user-modify-playback-state playlist-modify-public user-read-currently-playing playlist-read-private user-read-recently-played playlist-modify-private user-library-read',
}, },
{ {
displayName: 'Auth URI Query Parameters', displayName: 'Auth URI Query Parameters',

View file

@ -41,5 +41,11 @@ export class TheHiveApi implements ICredentialType {
}, },
], ],
}, },
{
displayName: 'Ignore SSL Issues',
name: 'allowUnauthorizedCerts',
type: 'boolean' as NodePropertyTypes,
default: false,
},
]; ];
} }

View file

@ -0,0 +1,34 @@
import {
ICredentialType,
NodePropertyTypes,
} from 'n8n-workflow';
export class WiseApi implements ICredentialType {
name = 'wiseApi';
displayName = 'Wise API';
documentationUrl = 'wise';
properties = [
{
displayName: 'API Token',
name: 'apiToken',
type: 'string' as NodePropertyTypes,
default: '',
},
{
displayName: 'Environment',
name: 'environment',
type: 'options' as NodePropertyTypes,
default: 'live',
options: [
{
name: 'Live',
value: 'live',
},
{
name: 'Test',
value: 'test',
},
],
},
];
}

View file

@ -0,0 +1,73 @@
import { ITriggerFunctions } from 'n8n-core';
import {
INodeType,
INodeTypeDescription,
ITriggerResponse,
} from 'n8n-workflow';
export class ActivationTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'Activation Trigger',
name: 'activationTrigger',
icon: 'fa:play-circle',
group: ['trigger'],
version: 1,
description: 'Executes whenever the workflow becomes active.',
defaults: {
name: 'Activation Trigger',
color: '#00e000',
},
inputs: [],
outputs: ['main'],
properties: [
{
displayName: 'Events',
name: 'events',
type: 'multiOptions',
required: true,
default: [],
description: 'Specifies under which conditions an execution should happen:<br />' +
'- <b>Activation</b>: Workflow gets activated<br />' +
'- <b>Update</b>: Workflow gets saved while active<br>' +
'- <b>Start</b>: n8n starts or restarts',
options: [
{
name: 'Activation',
value: 'activate',
description: 'Run when workflow gets activated',
},
{
name: 'Start',
value: 'init',
description: 'Run when n8n starts or restarts',
},
{
name: 'Update',
value: 'update',
description: 'Run when workflow gets saved while it is active',
},
],
},
],
};
async trigger(this: ITriggerFunctions): Promise<ITriggerResponse> {
const events = this.getNodeParameter('events', []) as string[];
const activationMode = this.getActivationMode();
if (events.includes(activationMode)) {
this.emit([this.helpers.returnJsonArray([{ activation: activationMode }])]);
}
const self = this;
async function manualTriggerFunction() {
self.emit([self.helpers.returnJsonArray([{ activation: 'manual' }])]);
}
return {
manualTriggerFunction,
};
}
}

View file

@ -137,7 +137,7 @@ export class Airtable implements INodeType {
// delete // delete
// ---------------------------------- // ----------------------------------
{ {
displayName: 'Id', displayName: 'ID',
name: 'id', name: 'id',
type: 'string', type: 'string',
displayOptions: { displayOptions: {
@ -317,7 +317,7 @@ export class Airtable implements INodeType {
// read // read
// ---------------------------------- // ----------------------------------
{ {
displayName: 'Id', displayName: 'ID',
name: 'id', name: 'id',
type: 'string', type: 'string',
displayOptions: { displayOptions: {
@ -336,7 +336,7 @@ export class Airtable implements INodeType {
// update // update
// ---------------------------------- // ----------------------------------
{ {
displayName: 'Id', displayName: 'ID',
name: 'id', name: 'id',
type: 'string', type: 'string',
displayOptions: { displayOptions: {
@ -499,7 +499,7 @@ export class Airtable implements INodeType {
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
id = this.getNodeParameter('id', i) as string; id = this.getNodeParameter('id', i) as string;
endpoint = `${application}/${table}/${id}`; endpoint = `${application}/${table}`;
// Make one request after another. This is slower but makes // Make one request after another. This is slower but makes
// sure that we do not run into the rate limit they have in // sure that we do not run into the rate limit they have in
@ -507,9 +507,11 @@ export class Airtable implements INodeType {
// functionality in core should make it easy to make requests // functionality in core should make it easy to make requests
// according to specific rules like not more than 5 requests // according to specific rules like not more than 5 requests
// per seconds. // per seconds.
qs.records = [id];
responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs);
returnData.push(responseData); returnData.push(...responseData.records);
} }
} else if (operation === 'list') { } else if (operation === 'list') {
@ -586,7 +588,6 @@ export class Airtable implements INodeType {
let updateAllFields: boolean; let updateAllFields: boolean;
let fields: string[]; let fields: string[];
let options: IDataObject; let options: IDataObject;
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
updateAllFields = this.getNodeParameter('updateAllFields', i) as boolean; updateAllFields = this.getNodeParameter('updateAllFields', i) as boolean;
options = this.getNodeParameter('options', i, {}) as IDataObject; options = this.getNodeParameter('options', i, {}) as IDataObject;
@ -616,13 +617,9 @@ export class Airtable implements INodeType {
} }
} }
if (options.typecast === true) {
body['typecast'] = true;
}
id = this.getNodeParameter('id', i) as string; id = this.getNodeParameter('id', i) as string;
endpoint = `${application}/${table}/${id}`; endpoint = `${application}/${table}`;
// Make one request after another. This is slower but makes // Make one request after another. This is slower but makes
// sure that we do not run into the rate limit they have in // sure that we do not run into the rate limit they have in
@ -631,9 +628,11 @@ export class Airtable implements INodeType {
// according to specific rules like not more than 5 requests // according to specific rules like not more than 5 requests
// per seconds. // per seconds.
responseData = await apiRequest.call(this, requestMethod, endpoint, body, qs); const data = { records: [{ id, fields: body.fields }], typecast: (options.typecast) ? true : false };
returnData.push(responseData); responseData = await apiRequest.call(this, requestMethod, endpoint, data, qs);
returnData.push(...responseData.records);
} }
} else { } else {

View file

@ -58,6 +58,7 @@ export async function apiRequest(this: IHookFunctions | IExecuteFunctions | ILoa
body, body,
qs: query, qs: query,
uri: uri || `https://api.airtable.com/v0/${endpoint}`, uri: uri || `https://api.airtable.com/v0/${endpoint}`,
useQuerystring: false,
json: true, json: true,
}; };

View file

@ -0,0 +1,20 @@
{
"node": "n8n-nodes-base.apiTemplateIo",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": [
"Marketing & Content"
],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/credentials/apiTemplateIo"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/nodes/n8n-nodes-base.apiTemplateIo/"
}
]
}
}

View file

@ -0,0 +1,560 @@
import {
IExecuteFunctions,
} from 'n8n-core';
import {
IDataObject,
ILoadOptionsFunctions,
INodeExecutionData,
INodePropertyOptions,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import {
apiTemplateIoApiRequest,
downloadImage,
loadResource,
validateJSON,
} from './GenericFunctions';
export class ApiTemplateIo implements INodeType {
description: INodeTypeDescription = {
displayName: 'APITemplate.io',
name: 'apiTemplateIo',
icon: 'file:apiTemplateIo.svg',
group: ['transform'],
version: 1,
description: 'Consume the APITemplate.io API',
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
defaults: {
name: 'APITemplate.io',
color: '#c0c0c0',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'apiTemplateIoApi',
required: true,
},
],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{
name: 'Account',
value: 'account',
},
{
name: 'Image',
value: 'image',
},
{
name: 'PDF',
value: 'pdf',
},
],
default: 'image',
description: 'Resource to consume',
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
default: 'create',
required: true,
description: 'Operation to perform',
options: [
{
name: 'Create',
value: 'create',
},
],
displayOptions: {
show: {
resource: [
'image',
'pdf',
],
},
},
},
{
displayName: 'Operation',
name: 'operation',
type: 'options',
default: 'get',
required: true,
description: 'Operation to perform',
options: [
{
name: 'Get',
value: 'get',
},
],
displayOptions: {
show: {
resource: [
'account',
],
},
},
},
{
displayName: 'Template ID',
name: 'imageTemplateId',
type: 'options',
required: true,
default: '',
description: 'ID of the image template to use.',
typeOptions: {
loadOptionsMethod: 'getImageTemplates',
},
displayOptions: {
show: {
resource: [
'image',
],
operation: [
'create',
],
},
},
},
{
displayName: 'Template ID',
name: 'pdfTemplateId',
type: 'options',
required: true,
default: '',
description: 'ID of the PDF template to use.',
typeOptions: {
loadOptionsMethod: 'getPdfTemplates',
},
displayOptions: {
show: {
resource: [
'pdf',
],
operation: [
'create',
],
},
},
},
{
displayName: 'JSON Parameters',
name: 'jsonParameters',
type: 'boolean',
default: false,
displayOptions: {
show: {
resource: [
'pdf',
'image',
],
operation: [
'create',
],
},
},
},
{
displayName: 'Download',
name: 'download',
type: 'boolean',
default: false,
displayOptions: {
show: {
resource: [
'pdf',
'image',
],
operation: [
'create',
],
},
},
description: 'Name of the binary property to which to<br />write the data of the read file.',
},
{
displayName: 'Binary Property',
name: 'binaryProperty',
type: 'string',
required: true,
default: 'data',
description: 'Name of the binary property to which to write to.',
displayOptions: {
show: {
resource: [
'pdf',
'image',
],
operation: [
'create',
],
download: [
true,
],
},
},
},
{
displayName: 'Overrides (JSON)',
name: 'overridesJson',
type: 'json',
default: '',
displayOptions: {
show: {
resource: [
'image',
],
operation: [
'create',
],
jsonParameters: [
true,
],
},
},
placeholder: `[ {"name": "text_1", "text": "hello world", "textBackgroundColor": "rgba(246, 243, 243, 0)" } ]`,
},
{
displayName: 'Properties (JSON)',
name: 'propertiesJson',
type: 'json',
default: '',
displayOptions: {
show: {
resource: [
'pdf',
],
operation: [
'create',
],
jsonParameters: [
true,
],
},
},
placeholder: `{ "name": "text_1" }`,
},
{
displayName: 'Overrides',
name: 'overridesUi',
placeholder: 'Add Override',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
displayOptions: {
show: {
resource: [
'image',
],
operation: [
'create',
],
jsonParameters: [
false,
],
},
},
default: {},
options: [
{
name: 'overrideValues',
displayName: 'Override',
values: [
{
displayName: 'Properties',
name: 'propertiesUi',
placeholder: 'Add Property',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
name: 'propertyValues',
displayName: 'Property',
values: [
{
displayName: 'Key',
name: 'key',
type: 'string',
default: '',
description: 'Name of the property',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'Value to the property.',
},
],
},
],
},
],
},
],
},
{
displayName: 'Properties',
name: 'propertiesUi',
placeholder: 'Add Property',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {},
displayOptions: {
show: {
resource: [
'pdf',
],
operation: [
'create',
],
jsonParameters: [
false,
],
},
},
options: [
{
name: 'propertyValues',
displayName: 'Property',
values: [
{
displayName: 'Key',
name: 'key',
type: 'string',
default: '',
description: 'Name of the property',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'Value to the property.',
},
],
},
],
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Field',
displayOptions: {
show: {
operation: [
'create',
],
resource: [
'pdf',
'image',
],
'download': [
true,
],
},
},
default: {},
options: [
{
displayName: 'File Name',
name: 'fileName',
type: 'string',
default: '',
description: 'The name of the downloaded image/pdf. It has to include the extension. For example: report.pdf',
},
],
},
],
};
methods = {
loadOptions: {
async getImageTemplates(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
return await loadResource.call(this, 'image');
},
async getPdfTemplates(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
return await loadResource.call(this, 'pdf');
},
},
};
async execute(this: IExecuteFunctions) {
const items = this.getInputData();
const returnData: IDataObject[] = [];
const length = items.length;
let responseData;
const resource = this.getNodeParameter('resource', 0) as string;
const operation = this.getNodeParameter('operation', 0) as string;
if (resource === 'account') {
// *********************************************************************
// account
// *********************************************************************
if (operation === 'get') {
// ----------------------------------
// account: get
// ----------------------------------
for (let i = 0; i < length; i++) {
responseData = await apiTemplateIoApiRequest.call(this, 'GET', '/account-information');
returnData.push(responseData);
}
}
} else if (resource === 'image') {
// *********************************************************************
// image
// *********************************************************************
if (operation === 'create') {
// ----------------------------------
// image: create
// ----------------------------------
const download = this.getNodeParameter('download', 0) as boolean;
// https://docs.apitemplate.io/reference/api-reference.html#create-an-image-jpeg-and-png
for (let i = 0; i < length; i++) {
const jsonParameters = this.getNodeParameter('jsonParameters', i) as boolean;
const options = this.getNodeParameter('options', i) as IDataObject;
const qs = {
template_id: this.getNodeParameter('imageTemplateId', i),
};
const body = { overrides: [] } as IDataObject;
if (jsonParameters === false) {
const overrides = (this.getNodeParameter('overridesUi', i) as IDataObject || {}).overrideValues as IDataObject[] || [];
if (overrides.length !== 0) {
const data: IDataObject[] = [];
for (const override of overrides) {
const properties = (override.propertiesUi as IDataObject || {}).propertyValues as IDataObject[] || [];
data.push(properties.reduce((obj, value) => Object.assign(obj, { [`${value.key}`]: value.value }), {}));
}
body.overrides = data;
}
} else {
const overrideJson = this.getNodeParameter('overridesJson', i) as string;
if (overrideJson !== '') {
const data = validateJSON(overrideJson);
if (data === undefined) {
throw new Error('A valid JSON must be provided.');
}
body.overrides = data;
}
}
responseData = await apiTemplateIoApiRequest.call(this, 'POST', '/create', qs, body);
if (download === true) {
const binaryProperty = this.getNodeParameter('binaryProperty', i) as string;
const data = await downloadImage.call(this, responseData.download_url);
const fileName = responseData.download_url.split('/').pop();
const binaryData = await this.helpers.prepareBinaryData(data, options.fileName || fileName);
responseData = {
json: responseData,
binary: {
[binaryProperty]: binaryData,
},
};
}
returnData.push(responseData);
}
if (download === true) {
return this.prepareOutputData(returnData as unknown as INodeExecutionData[]);
}
}
} else if (resource === 'pdf') {
// *********************************************************************
// pdf
// *********************************************************************
if (operation === 'create') {
// ----------------------------------
// pdf: create
// ----------------------------------
// https://docs.apitemplate.io/reference/api-reference.html#create-a-pdf
const download = this.getNodeParameter('download', 0) as boolean;
for (let i = 0; i < length; i++) {
const jsonParameters = this.getNodeParameter('jsonParameters', i) as boolean;
const options = this.getNodeParameter('options', i) as IDataObject;
const qs = {
template_id: this.getNodeParameter('pdfTemplateId', i),
};
let data;
if (jsonParameters === false) {
const properties = (this.getNodeParameter('propertiesUi', i) as IDataObject || {}).propertyValues as IDataObject[] || [];
if (properties.length === 0) {
throw new Error('The parameter properties cannot be empty');
}
data = properties.reduce((obj, value) => Object.assign(obj, { [`${value.key}`]: value.value }), {});
} else {
const propertiesJson = this.getNodeParameter('propertiesJson', i) as string;
data = validateJSON(propertiesJson);
if (data === undefined) {
throw new Error('A valid JSON must be provided.');
}
}
responseData = await apiTemplateIoApiRequest.call(this, 'POST', '/create', qs, data);
if (download === true) {
const binaryProperty = this.getNodeParameter('binaryProperty', i) as string;
const data = await downloadImage.call(this, responseData.download_url);
const fileName = responseData.download_url.split('/').pop();
const binaryData = await this.helpers.prepareBinaryData(data, options.fileName || fileName);
responseData = {
json: responseData,
binary: {
[binaryProperty]: binaryData,
},
};
}
returnData.push(responseData);
}
if (download === true) {
return this.prepareOutputData(returnData as unknown as INodeExecutionData[]);
}
}
}
return [this.helpers.returnJsonArray(returnData)];
}
}

View file

@ -0,0 +1,91 @@
import {
OptionsWithUri,
} from 'request';
import {
IExecuteFunctions,
ILoadOptionsFunctions,
} from 'n8n-core';
export async function apiTemplateIoApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions,
method: string,
endpoint: string,
qs = {},
body = {},
) {
const { apiKey } = this.getCredentials('apiTemplateIoApi') as { apiKey: string };
const options: OptionsWithUri = {
headers: {
'user-agent': 'n8n',
Accept: 'application/json',
'X-API-KEY': `${apiKey}`,
},
uri: `https://api.apitemplate.io/v1${endpoint}`,
method,
qs,
body,
followRedirect: true,
followAllRedirects: true,
json: true,
};
if (!Object.keys(body).length) {
delete options.body;
}
if (!Object.keys(qs).length) {
delete options.qs;
}
try {
const response = await this.helpers.request!(options);
if (response.status === 'error') {
throw new Error(response.message);
}
return response;
} catch (error) {
if (error?.response?.body?.message) {
throw new Error(`APITemplate.io error response [${error.statusCode}]: ${error.response.body.message}`);
}
throw error;
}
}
export async function loadResource(
this: ILoadOptionsFunctions,
resource: 'image' | 'pdf',
) {
const target = resource === 'image' ? ['JPEG', 'PNG'] : ['PDF'];
const templates = await apiTemplateIoApiRequest.call(this, 'GET', '/list-templates');
const filtered = templates.filter(({ format }: { format: 'PDF' | 'JPEG' | 'PNG' }) => target.includes(format));
return filtered.map(({ format, name, id }: { format: string, name: string, id: string }) => ({
name: `${name} (${format})`,
value: id,
}));
}
export function validateJSON(json: string | object | undefined): any { // tslint:disable-line:no-any
let result;
if (typeof json === 'object') {
return json;
}
try {
result = JSON.parse(json!);
} catch (exception) {
result = undefined;
}
return result;
}
export function downloadImage(this: IExecuteFunctions, url: string) {
return this.helpers.request({
uri: url,
method: 'GET',
json: false,
encoding: null,
});
}

View file

@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="80"
height="80"
viewBox="0 0 23.166659 21.166671"
version="1.1"
id="svg8"
inkscape:version="1.0.1 (1.0.1+r75)"
sodipodi:docname="favicon.svg"
inkscape:export-filename="/mnt/shared/bktan81@gmail.com/Companies/AlphaCloud/APITemplate.io/export/favicon-color.png"
inkscape:export-xdpi="614.40002"
inkscape:export-ydpi="614.40002">
<defs
id="defs2">
<rect
x="5.5194178"
y="6.7918406"
width="82.495544"
height="30.566183"
id="rect835" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="3.6962788"
inkscape:cx="84.04094"
inkscape:cy="31.651389"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
inkscape:document-rotation="0"
showgrid="false"
units="px"
inkscape:window-width="1920"
inkscape:window-height="1014"
inkscape:window-x="0"
inkscape:window-y="36"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
style="fill:#000000"
id="g944"
transform="matrix(0.26072249,-0.0450346,0.0450346,0.26072249,-4.4090493,0.80238047)">
<path
fill="#91bce5"
d="m 85.926965,13.698648 -63.285045,30.243807 15.697805,11.196666 1.472218,15.148608 13.242212,-6.250661 9.822444,5.345054 z"
id="path918" />
<path
fill="#1f212b"
d="m 39.778008,71.288154 c -0.158903,-0.0056 -0.31648,-0.04907 -0.45874,-0.130089 -0.283589,-0.159995 -0.470626,-0.448698 -0.501329,-0.772967 L 37.38898,55.688275 22.06215,44.756717 c -0.288015,-0.205176 -0.446151,-0.547904 -0.415808,-0.901061 0.03035,-0.353155 0.245283,-0.662843 0.564816,-0.815784 L 85.495203,12.796029 c 0.373477,-0.179075 0.817321,-0.109542 1.119534,0.176179 0.301247,0.284686 0.393931,0.72519 0.235443,1.108892 L 63.800813,69.764609 c -0.108224,0.262384 -0.32346,0.465996 -0.589929,0.560754 -0.268434,0.09369 -0.562801,0.0704 -0.811249,-0.06435 l -9.376677,-5.101349 -12.782391,6.034577 c -0.147425,0.0669 -0.305655,0.09939 -0.462559,0.09391 z M 24.604162,44.11304 38.920495,54.324893 c 0.235269,0.168314 0.386237,0.429742 0.414198,0.717894 l 1.33547,13.734974 11.957261,-5.644136 c 0.287923,-0.136035 0.624788,-0.126272 0.904658,0.02559 l 8.84643,4.812668 21.627569,-52.247035 z"
id="path920" />
<path
fill="#3a84c1"
d="M 40.73124,69.46431 46.129963,59.222484 85.897126,14.553126 39.059778,54.433821 Z"
id="path922" />
<path
fill="#1f212b"
d="m 40.71279,69.963971 c -0.07296,-0.0026 -0.146351,-0.02112 -0.215122,-0.05754 -0.244661,-0.128616 -0.338162,-0.431064 -0.208546,-0.675689 l 5.398723,-10.241827 c 0.01925,-0.03535 0.04242,-0.06856 0.06852,-0.09967 L 80.940068,19.368061 38.684781,54.856992 c -0.211297,0.176733 -0.526545,0.149715 -0.705278,-0.06165 -0.177698,-0.212332 -0.149715,-0.526547 0.06165,-0.705279 L 85.574314,14.169627 c 0.201987,-0.168053 0.497667,-0.153725 0.680201,0.03476 0.182533,0.188486 0.189157,0.485899 0.01525,0.680947 L 46.544181,59.510112 41.173361,69.69788 c -0.09216,0.17489 -0.27468,0.272579 -0.460568,0.266088 z"
id="path924" />
<path
fill="#1f212b"
d="m 28.066921,71.78593 c -4.37019,-0.64391 -8.339996,-2.867809 -10.598292,-6.766997 -1.432549,-2.473502 -1.528128,-6.298167 2.122716,-6.172679 3.274075,0.112327 4.26409,2.596776 4.740567,5.406801 1.694711,8.06304 -1.290339,11.045322 -9.002137,14.829352 -0.594821,0.242389 -0.134492,1.113471 0.457295,0.871978 5.181377,-2.944492 7.754842,-4.282847 9.399081,-7.76557 1.370916,-4.981188 0.489139,-11.773963 -2.947089,-13.689368 -3.921594,-2.143168 -8.01567,0.59562 -6.601411,4.939623 1.751362,5.378399 7.074496,8.562112 12.394405,9.345251 0.635099,0.09422 0.665001,-0.905343 0.03486,-0.998391 z"
id="path926"
sodipodi:nodetypes="cccccccccccc" />
<path
fill="#1f212b"
d="m 32.211855,72.060754 c -0.960938,-0.01857 -1.924304,-0.08221 -2.882497,-0.15069 -0.64277,-0.04646 -0.676671,0.952965 -0.0349,0.999392 0.958193,0.06848 1.921524,0.133135 2.882497,0.150689 0.642958,0.01242 0.679856,-0.986875 0.0349,-0.999391 z m 4.771615,-1.109149 c -1.135251,0.302564 -2.280575,0.549744 -3.442445,0.726303 -0.635768,0.09687 -0.401574,1.069637 0.232195,0.972696 1.16187,-0.176559 2.307194,-0.423738 3.442444,-0.726302 0.620118,-0.164459 0.391991,-1.139015 -0.232194,-0.972697 z"
id="path928" />
<path
fill="#1f212b"
d="m 54.069217,64.636113 c -0.08695,-0.003 -0.175091,-0.02913 -0.253366,-0.07989 l -7.481853,-4.834056 c -0.231899,-0.150184 -0.298138,-0.459685 -0.148952,-0.69162 0.150185,-0.231899 0.460683,-0.298103 0.691619,-0.148953 l 7.481852,4.834058 c 0.231899,0.150184 0.298139,0.459686 0.148953,0.69162 -0.09943,0.153622 -0.268357,0.234778 -0.438253,0.228845 z"
id="path926-0"
sodipodi:nodetypes="sccccccs"
style="fill:#000000" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

View file

@ -28,7 +28,7 @@ export class Asana implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
displayName: 'Asana', displayName: 'Asana',
name: 'asana', name: 'asana',
icon: 'file:asana.png', icon: 'file:asana.svg',
group: ['input'], group: ['input'],
version: 1, version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',

View file

@ -25,7 +25,7 @@ export class AsanaTrigger implements INodeType {
description: INodeTypeDescription = { description: INodeTypeDescription = {
displayName: 'Asana Trigger', displayName: 'Asana Trigger',
name: 'asanaTrigger', name: 'asanaTrigger',
icon: 'file:asana.png', icon: 'file:asana.svg',
group: ['trigger'], group: ['trigger'],
version: 1, version: 1,
description: 'Starts the workflow when Asana events occure.', description: 'Starts the workflow when Asana events occure.',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="60px" height="60px" viewBox="0 0 60 60" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>asana_node_icon</title>
<defs>
<radialGradient cx="50%" cy="55%" fx="50%" fy="55%" r="72.5074481%" gradientTransform="translate(0.500000,0.550000),scale(0.924043,1.000000),translate(-0.500000,-0.550000)" id="radialGradient-1">
<stop stop-color="#FFB900" offset="0%"></stop>
<stop stop-color="#F95D8F" offset="60%"></stop>
<stop stop-color="#F95353" offset="99.91%"></stop>
</radialGradient>
</defs>
<g id="asana_node_icon" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g id="asana" transform="translate(0.000000, 2.000000)" fill="url(#radialGradient-1)">
<g id="Group" transform="translate(0.731707, 0.731707)">
<path d="M45.5941463,28.5 C38.5995187,28.50323 32.9300593,34.1726894 32.9268293,41.1673171 C32.9300593,48.1619448 38.5995187,53.8314041 45.5941463,53.8346341 C52.588774,53.8314041 58.2582334,48.1619448 58.2614634,41.1673171 C58.2582334,34.1726894 52.588774,28.50323 45.5941463,28.5 L45.5941463,28.5 Z M12.6673171,28.5014634 C5.67268939,28.5046934 0.00323002946,34.1741528 -1.40700459e-15,41.1687805 C0.00323002946,48.1634082 5.67268939,53.8328675 12.6673171,53.8360976 C19.6619448,53.8328675 25.3314041,48.1634082 25.3346341,41.1687805 C25.3314041,34.1741528 19.6619448,28.5046934 12.6673171,28.5014634 L12.6673171,28.5014634 Z M41.7892683,12.6673171 C41.7868464,19.6619451 36.118042,25.3320595 29.1234146,25.3360976 C22.1282158,25.3328669 16.4585201,19.6625162 16.4560976,12.6673171 C16.4593276,5.67268939 22.128787,0.00323002946 29.1234146,-1.40700459e-15 C36.1174708,0.0040373938 41.7860389,5.67326048 41.7892683,12.6673171 L41.7892683,12.6673171 Z" id="Shape"></path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,378 @@
import {
IExecuteFunctions,
} from 'n8n-core';
import {
IDataObject,
ILoadOptionsFunctions,
INodeExecutionData,
INodePropertyOptions,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import {
autopilotApiRequest,
autopilotApiRequestAllItems,
} from './GenericFunctions';
import {
contactFields,
contactOperations,
} from './ContactDescription';
import {
contactJourneyFields,
contactJourneyOperations,
} from './ContactJourneyDescription';
import {
contactListFields,
contactListOperations,
} from './ContactListDescription';
import {
listFields,
listOperations,
} from './ListDescription';
export class Autopilot implements INodeType {
description: INodeTypeDescription = {
displayName: 'Autopilot',
name: 'autopilot',
icon: 'file:autopilot.svg',
group: ['input'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume Autopilot API',
defaults: {
name: 'Autopilot',
color: '#6ad7b9',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'autopilotApi',
required: true,
},
],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{
name: 'Contact',
value: 'contact',
},
{
name: 'Contact Journey',
value: 'contactJourney',
},
{
name: 'Contact List',
value: 'contactList',
},
{
name: 'List',
value: 'list',
},
],
default: 'contact',
description: 'The resource to operate on.',
},
...contactOperations,
...contactFields,
...contactJourneyOperations,
...contactJourneyFields,
...contactListOperations,
...contactListFields,
...listOperations,
...listFields,
],
};
methods = {
loadOptions: {
async getCustomFields(
this: ILoadOptionsFunctions,
): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const customFields = await autopilotApiRequest.call(
this,
'GET',
'/contacts/custom_fields',
);
for (const customField of customFields) {
returnData.push({
name: customField.name,
value: `${customField.name}-${customField.fieldType}`,
});
}
return returnData;
},
async getLists(
this: ILoadOptionsFunctions,
): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const { lists } = await autopilotApiRequest.call(
this,
'GET',
'/lists',
);
for (const list of lists) {
returnData.push({
name: list.title,
value: list.list_id,
});
}
return returnData;
},
async getTriggers(
this: ILoadOptionsFunctions,
): Promise<INodePropertyOptions[]> {
const returnData: INodePropertyOptions[] = [];
const { triggers } = await autopilotApiRequest.call(
this,
'GET',
'/triggers',
);
for (const trigger of triggers) {
returnData.push({
name: trigger.journey,
value: trigger.trigger_id,
});
}
return returnData;
},
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: IDataObject[] = [];
const length = (items.length as unknown) as number;
const qs: IDataObject = {};
let responseData;
const resource = this.getNodeParameter('resource', 0) as string;
const operation = this.getNodeParameter('operation', 0) as string;
for (let i = 0; i < length; i++) {
try {
if (resource === 'contact') {
if (operation === 'upsert') {
const email = this.getNodeParameter('email', i) as string;
const additionalFields = this.getNodeParameter('additionalFields', i) as IDataObject;
const body: IDataObject = {
Email: email,
};
Object.assign(body, additionalFields);
if (body.customFieldsUi) {
const customFieldsValues = (body.customFieldsUi as IDataObject).customFieldsValues as IDataObject[];
body.custom = {};
for (const customField of customFieldsValues) {
const [name, fieldType] = (customField.key as string).split('-');
const fieldName = name.replace(/\s/g, '--');
//@ts-ignore
body.custom[`${fieldType}--${fieldName}`] = customField.value;
}
delete body.customFieldsUi;
}
if (body.autopilotList) {
body._autopilot_list = body.autopilotList;
delete body.autopilotList;
}
if (body.autopilotSessionId) {
body._autopilot_session_id = body.autopilotSessionId;
delete body.autopilotSessionId;
}
if (body.newEmail) {
body._NewEmail = body.newEmail;
delete body.newEmail;
}
responseData = await autopilotApiRequest.call(
this,
'POST',
`/contact`,
{ contact: body },
);
}
if (operation === 'delete') {
const contactId = this.getNodeParameter('contactId', i) as string;
responseData = await autopilotApiRequest.call(
this,
'DELETE',
`/contact/${contactId}`,
);
responseData = { success: true };
}
if (operation === 'get') {
const contactId = this.getNodeParameter('contactId', i) as string;
responseData = await autopilotApiRequest.call(
this,
'GET',
`/contact/${contactId}`,
);
}
if (operation === 'getAll') {
const returnAll = this.getNodeParameter('returnAll', i) as boolean;
if (returnAll === false) {
qs.limit = this.getNodeParameter('limit', i) as number;
}
responseData = await autopilotApiRequestAllItems.call(
this,
'contacts',
'GET',
`/contacts`,
{},
qs,
);
if (returnAll === false) {
responseData = responseData.splice(0, qs.limit);
}
}
}
if (resource === 'contactJourney') {
if (operation === 'add') {
const triggerId = this.getNodeParameter('triggerId', i) as string;
const contactId = this.getNodeParameter('contactId', i) as string;
responseData = await autopilotApiRequest.call(
this,
'POST',
`/trigger/${triggerId}/contact/${contactId}`,
);
responseData = { success: true };
}
}
if (resource === 'contactList') {
if (['add', 'remove', 'exist'].includes(operation)) {
const listId = this.getNodeParameter('listId', i) as string;
const contactId = this.getNodeParameter('contactId', i) as string;
const method: { [key: string]: string } = {
'add': 'POST',
'remove': 'DELETE',
'exist': 'GET',
};
const endpoint = `/list/${listId}/contact/${contactId}`;
if (operation === 'exist') {
try {
await autopilotApiRequest.call(this, method[operation], endpoint);
responseData = { exist: true };
} catch (error) {
responseData = { exist: false };
}
} else if (operation === 'add' || operation === 'remove') {
responseData = await autopilotApiRequest.call(this, method[operation], endpoint);
responseData['success'] = true;
}
}
if (operation === 'getAll') {
const returnAll = this.getNodeParameter('returnAll', i) as boolean;
const listId = this.getNodeParameter('listId', i) as string;
if (returnAll === false) {
qs.limit = this.getNodeParameter('limit', i) as number;
}
responseData = await autopilotApiRequestAllItems.call(
this,
'contacts',
'GET',
`/list/${listId}/contacts`,
{},
qs,
);
if (returnAll === false) {
responseData = responseData.splice(0, qs.limit);
}
}
}
if (resource === 'list') {
if (operation === 'create') {
const name = this.getNodeParameter('name', i) as string;
const body: IDataObject = {
name,
};
responseData = await autopilotApiRequest.call(
this,
'POST',
`/list`,
body,
);
}
if (operation === 'getAll') {
const returnAll = this.getNodeParameter('returnAll', i) as boolean;
if (returnAll === false) {
qs.limit = this.getNodeParameter('limit', i) as number;
}
responseData = await autopilotApiRequest.call(
this,
'GET',
'/lists',
);
responseData = responseData.lists;
if (returnAll === false) {
responseData = responseData.splice(0, qs.limit);
}
}
}
if (Array.isArray(responseData)) {
returnData.push.apply(returnData, responseData as IDataObject[]);
} else if (responseData !== undefined) {
returnData.push(responseData as IDataObject);
}
} catch (error) {
if (this.continueOnFail()) {
returnData.push({ error: error.toString() });
continue;
}
throw error;
}
}
return [this.helpers.returnJsonArray(returnData)];
}
}

View file

@ -0,0 +1,140 @@
import {
IHookFunctions,
IWebhookFunctions,
} from 'n8n-core';
import {
IDataObject,
INodeType,
INodeTypeDescription,
IWebhookResponseData,
} from 'n8n-workflow';
import {
autopilotApiRequest,
} from './GenericFunctions';
import {
snakeCase,
} from 'change-case';
export class AutopilotTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'Autopilot Trigger',
name: 'autopilotTrigger',
icon: 'file:autopilot.svg',
group: ['trigger'],
version: 1,
subtitle: '={{$parameter["event"]}}',
description: 'Handle Autopilot events via webhooks',
defaults: {
name: 'Autopilot Trigger',
color: '#6ad7b9',
},
inputs: [],
outputs: ['main'],
credentials: [
{
name: 'autopilotApi',
required: true,
},
],
webhooks: [
{
name: 'default',
httpMethod: 'POST',
responseMode: 'onReceived',
path: 'webhook',
},
],
properties: [
{
displayName: 'Event',
name: 'event',
type: 'options',
required: true,
default: '',
options: [
{
name: 'Contact Added',
value: 'contactAdded',
},
{
name: 'Contact Added To List',
value: 'contactAddedToList',
},
{
name: 'Contact Entered Segment',
value: 'contactEnteredSegment',
},
{
name: 'Contact Left Segment',
value: 'contactLeftSegment',
},
{
name: 'Contact Removed From List',
value: 'contactRemovedFromList',
},
{
name: 'Contact Unsubscribed',
value: 'contactUnsubscribed',
},
{
name: 'Contact Updated',
value: 'contactUpdated',
},
],
},
],
};
// @ts-ignore
webhookMethods = {
default: {
async checkExists(this: IHookFunctions): Promise<boolean> {
const webhookData = this.getWorkflowStaticData('node');
const webhookUrl = this.getNodeWebhookUrl('default');
const event = this.getNodeParameter('event') as string;
const { hooks: webhooks } = await autopilotApiRequest.call(this, 'GET', '/hooks');
for (const webhook of webhooks) {
if (webhook.target_url === webhookUrl && webhook.event === snakeCase(event)) {
webhookData.webhookId = webhook.hook_id;
return true;
}
}
return false;
},
async create(this: IHookFunctions): Promise<boolean> {
const webhookUrl = this.getNodeWebhookUrl('default');
const webhookData = this.getWorkflowStaticData('node');
const event = this.getNodeParameter('event') as string;
const body: IDataObject = {
event: snakeCase(event),
target_url: webhookUrl,
};
const webhook = await autopilotApiRequest.call(this, 'POST', '/hook', body);
webhookData.webhookId = webhook.hook_id;
return true;
},
async delete(this: IHookFunctions): Promise<boolean> {
const webhookData = this.getWorkflowStaticData('node');
try {
await autopilotApiRequest.call(this, 'DELETE', `/hook/${webhookData.webhookId}`);
} catch (error) {
return false;
}
delete webhookData.webhookId;
return true;
},
},
};
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
const req = this.getRequestObject();
return {
workflowData: [
this.helpers.returnJsonArray(req.body),
],
};
}
}

View file

@ -0,0 +1,369 @@
import {
INodeProperties,
} from 'n8n-workflow';
export const contactOperations = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: [
'contact',
],
},
},
options: [
{
name: 'Create/Update',
value: 'upsert',
description: 'Create/Update a contact',
},
{
name: 'Delete',
value: 'delete',
description: 'Delete a contact',
},
{
name: 'Get',
value: 'get',
description: 'Get a contact',
},
{
name: 'Get All',
value: 'getAll',
description: 'Get all contacts',
},
],
default: 'upsert',
description: 'The operation to perform.',
},
] as INodeProperties[];
export const contactFields = [
/* -------------------------------------------------------------------------- */
/* contact:upsert */
/* -------------------------------------------------------------------------- */
{
displayName: 'Email',
name: 'email',
required: true,
type: 'string',
displayOptions: {
show: {
operation: [
'upsert',
],
resource: [
'contact',
],
},
},
default: '',
description: 'Email address of the contact.',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
displayOptions: {
show: {
operation: [
'upsert',
],
resource: [
'contact',
],
},
},
default: {},
placeholder: 'Add Field',
options: [
{
displayName: 'Company',
name: 'Company',
type: 'string',
default: '',
},
{
displayName: 'Custom Fields',
name: 'customFieldsUi',
type: 'fixedCollection',
default: '',
placeholder: 'Add Custom Field',
typeOptions: {
multipleValues: true,
loadOptionsMethod: 'getCustomFields',
},
options: [
{
name: 'customFieldsValues',
displayName: 'Custom Field',
values: [
{
displayName: 'Key',
name: 'key',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getCustomFields',
},
description: 'User-specified key of user-defined data.',
default: '',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
description: 'User-specified value of user-defined data.',
default: '',
},
],
},
],
},
{
displayName: 'Fax',
name: 'Fax',
type: 'string',
default: '',
},
{
displayName: 'First Name',
name: 'FirstName',
type: 'string',
default: '',
},
{
displayName: 'Industry',
name: 'Industry',
type: 'string',
default: '',
},
{
displayName: 'Last Name',
name: 'LastName',
type: 'string',
default: '',
},
{
displayName: 'Lead Source',
name: 'LeadSource',
type: 'string',
default: '',
},
{
displayName: 'LinkedIn URL',
name: 'LinkedIn',
type: 'string',
default: '',
},
{
displayName: 'List ID',
name: 'autopilotList',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getLists',
},
default: '',
description: 'List to which this contact will be added on creation.',
},
{
displayName: 'Mailing Country',
name: 'MailingCountry',
type: 'string',
default: '',
},
{
displayName: 'Mailing Postal Code',
name: 'MailingPostalCode',
type: 'string',
default: '',
},
{
displayName: 'Mailing State',
name: 'MailingState',
type: 'string',
default: '',
},
{
displayName: 'Mailing Street',
name: 'MailingStreet',
type: 'string',
default: '',
},
{
displayName: 'Mailing City',
name: 'MailingCity',
type: 'string',
default: '',
},
{
displayName: 'Mobile Phone',
name: 'MobilePhone',
type: 'string',
default: '',
},
{
displayName: 'New Email',
name: 'newEmail',
type: 'string',
default: '',
description: 'If provided, will change the email address of the contact identified by the Email field.',
},
{
displayName: 'Notify',
name: 'notify',
type: 'boolean',
default: true,
description: `By default Autopilot notifies registered REST hook endpoints for contact_added/contact_updated events when</br> a new contact is added or an existing contact is updated via API. Disable to skip notifications.`,
},
{
displayName: 'Number of Employees',
name: 'NumberOfEmployees',
type: 'number',
default: 0,
},
{
displayName: 'Owner Name',
name: 'owner_name',
type: 'string',
default: '',
},
{
displayName: 'Phone',
name: 'Phone',
type: 'string',
default: '',
},
{
displayName: 'Salutation',
name: 'Salutation',
type: 'string',
default: '',
},
{
displayName: 'Session ID',
name: 'autopilotSessionId',
type: 'string',
default: '',
description: 'Used to associate a contact with a session.',
},
{
displayName: 'Status',
name: 'Status',
type: 'string',
default: '',
},
{
displayName: 'Title',
name: 'Title',
type: 'string',
default: '',
},
{
displayName: 'Subscribe',
name: 'unsubscribed',
type: 'boolean',
default: false,
description: 'Whether to subscribe or un-subscribe a contact.',
},
{
displayName: 'Website URL',
name: 'Website',
type: 'string',
default: '',
},
],
},
/* -------------------------------------------------------------------------- */
/* contact:delete */
/* -------------------------------------------------------------------------- */
{
displayName: 'Contact ID',
name: 'contactId',
required: true,
type: 'string',
displayOptions: {
show: {
operation: [
'delete',
],
resource: [
'contact',
],
},
},
default: '',
description: 'Can be ID or email.',
},
/* -------------------------------------------------------------------------- */
/* contact:get */
/* -------------------------------------------------------------------------- */
{
displayName: 'Contact ID',
name: 'contactId',
required: true,
type: 'string',
displayOptions: {
show: {
operation: [
'get',
],
resource: [
'contact',
],
},
},
default: '',
description: 'Can be ID or email.',
},
/* -------------------------------------------------------------------------- */
/* contact:getAll */
/* -------------------------------------------------------------------------- */
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
operation: [
'getAll',
],
resource: [
'contact',
],
},
},
default: false,
description: 'If all results should be returned or only up to a given limit.',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
operation: [
'getAll',
],
resource: [
'contact',
],
returnAll: [
false,
],
},
},
typeOptions: {
minValue: 1,
maxValue: 500,
},
default: 100,
description: 'How many results to return.',
},
] as INodeProperties[];

View file

@ -0,0 +1,73 @@
import {
INodeProperties,
} from 'n8n-workflow';
export const contactJourneyOperations = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: [
'contactJourney',
],
},
},
options: [
{
name: 'Add',
value: 'add',
description: 'Add contact to list',
},
],
default: 'add',
description: 'The operation to perform.',
},
] as INodeProperties[];
export const contactJourneyFields = [
/* -------------------------------------------------------------------------- */
/* contactJourney:add */
/* -------------------------------------------------------------------------- */
{
displayName: 'Trigger ID',
name: 'triggerId',
required: true,
typeOptions: {
loadOptionsMethod: 'getTriggers',
},
type: 'options',
displayOptions: {
show: {
operation: [
'add',
],
resource: [
'contactJourney',
],
},
},
default: '',
description: 'List ID.',
},
{
displayName: 'Contact ID',
name: 'contactId',
required: true,
type: 'string',
displayOptions: {
show: {
operation: [
'add',
],
resource: [
'contactJourney',
],
},
},
default: '',
description: 'Can be ID or email.',
},
] as INodeProperties[];

View file

@ -0,0 +1,138 @@
import {
INodeProperties,
} from 'n8n-workflow';
export const contactListOperations = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: [
'contactList',
],
},
},
options: [
{
name: 'Add',
value: 'add',
description: 'Add contact to list.',
},
{
name: 'Exist',
value: 'exist',
description: 'Check if contact is on list.',
},
{
name: 'Get All',
value: 'getAll',
description: 'Get all contacts on list.',
},
{
name: 'Remove',
value: 'remove',
description: 'Remove a contact from a list.',
},
],
default: 'add',
description: 'Operation to perform.',
},
] as INodeProperties[];
export const contactListFields = [
/* -------------------------------------------------------------------------- */
/* contactList:add */
/* -------------------------------------------------------------------------- */
{
displayName: 'List ID',
name: 'listId',
required: true,
typeOptions: {
loadOptionsMethod: 'getLists',
},
type: 'options',
displayOptions: {
show: {
operation: [
'add',
'remove',
'exist',
'getAll',
],
resource: [
'contactList',
],
},
},
default: '',
description: 'ID of the list to operate on.',
},
{
displayName: 'Contact ID',
name: 'contactId',
required: true,
type: 'string',
displayOptions: {
show: {
operation: [
'add',
'remove',
'exist',
],
resource: [
'contactList',
],
},
},
default: '',
description: 'Can be ID or email.',
},
/* -------------------------------------------------------------------------- */
/* contactList:getAll */
/* -------------------------------------------------------------------------- */
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
operation: [
'getAll',
],
resource: [
'contactList',
],
},
},
default: false,
description: 'If all results should be returned or only up to a given limit.',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
operation: [
'getAll',
],
resource: [
'contactList',
],
returnAll: [
false,
],
},
},
typeOptions: {
minValue: 1,
maxValue: 500,
},
default: 100,
description: 'How many results to return.',
},
] as INodeProperties[];

View file

@ -0,0 +1,72 @@
import {
OptionsWithUri,
} from 'request';
import {
IExecuteFunctions,
ILoadOptionsFunctions,
} from 'n8n-core';
import {
IDataObject,
IHookFunctions,
IWebhookFunctions,
} from 'n8n-workflow';
export async function autopilotApiRequest(this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, query: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise<any> { // tslint:disable-line:no-any
const credentials = this.getCredentials('autopilotApi') as IDataObject;
const apiKey = `${credentials.apiKey}`;
const endpoint = 'https://api2.autopilothq.com/v1';
const options: OptionsWithUri = {
headers: {
'Content-Type': 'application/json',
autopilotapikey: apiKey,
},
method,
body,
qs: query,
uri: uri || `${endpoint}${resource}`,
json: true,
};
if (!Object.keys(body).length) {
delete options.body;
}
if (!Object.keys(query).length) {
delete options.qs;
}
try {
return await this.helpers.request!(options);
} catch (error) {
if (error.response) {
const errorMessage = error.response.body.message || error.response.body.description || error.message;
throw new Error(`Autopilot error response [${error.statusCode}]: ${errorMessage}`);
}
throw error;
}
}
export async function autopilotApiRequestAllItems(this: IExecuteFunctions | ILoadOptionsFunctions, propertyName: string, method: string, endpoint: string, body: any = {}, query: IDataObject = {}): Promise<any> { // tslint:disable-line:no-any
const returnData: IDataObject[] = [];
const returnAll = this.getNodeParameter('returnAll', 0, false) as boolean;
const base = endpoint;
let responseData;
do {
responseData = await autopilotApiRequest.call(this, method, endpoint, body, query);
endpoint = `${base}/${responseData.bookmark}`;
returnData.push.apply(returnData, responseData[propertyName]);
if (query.limit && returnData.length >= query.limit && returnAll === false) {
return returnData;
}
} while (
responseData.bookmark !== undefined
);
return returnData;
}

View file

@ -0,0 +1,102 @@
import {
INodeProperties,
} from 'n8n-workflow';
export const listOperations = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: [
'list',
],
},
},
options: [
{
name: 'Create',
value: 'create',
description: 'Create a list.',
},
{
name: 'Get All',
value: 'getAll',
description: 'Get all lists.',
},
],
default: 'create',
description: 'The operation to perform.',
},
] as INodeProperties[];
export const listFields = [
/* -------------------------------------------------------------------------- */
/* list:create */
/* -------------------------------------------------------------------------- */
{
displayName: 'Name',
name: 'name',
required: true,
type: 'string',
displayOptions: {
show: {
operation: [
'create',
],
resource: [
'list',
],
},
},
default: '',
description: 'Name of the list to create.',
},
/* -------------------------------------------------------------------------- */
/* list:getAll */
/* -------------------------------------------------------------------------- */
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
operation: [
'getAll',
],
resource: [
'list',
],
},
},
default: false,
description: 'If all results should be returned or only up to a given limit.',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
operation: [
'getAll',
],
resource: [
'list',
],
returnAll: [
false,
],
},
},
typeOptions: {
minValue: 1,
maxValue: 500,
},
default: 100,
description: 'How many results to return.',
},
] as INodeProperties[];

View file

@ -0,0 +1,8 @@
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="38 26 35 35">
<circle cx="50" cy="50" r="40" stroke="#18d4b2" stroke-width="3" fill="#18d4b2" />
<g>
<g>
<path fill="#ffffff" d="M45.4,42.6h19.9l3.4-4.8H42L45.4,42.6z M48.5,50.9h13.1l3.4-4.8H45.4L48.5,50.9z M102.5,50.2"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 378 B

View file

@ -0,0 +1,20 @@
{
"node": "n8n-nodes-base.awsComprehend",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": [
"Development"
],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/credentials/aws"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/nodes/n8n-nodes-base.awsComprehend/"
}
]
}
}

View file

@ -20,6 +20,8 @@ function getEndpointForService(service: string, credentials: ICredentialDataDecr
endpoint = credentials.lambdaEndpoint; endpoint = credentials.lambdaEndpoint;
} else if (service === 'sns' && credentials.snsEndpoint) { } else if (service === 'sns' && credentials.snsEndpoint) {
endpoint = credentials.snsEndpoint; endpoint = credentials.snsEndpoint;
} else if (service === 'sqs' && credentials.sqsEndpoint) {
endpoint = credentials.sqsEndpoint;
} else { } else {
endpoint = `https://${service}.${credentials.region}.amazonaws.com`; endpoint = `https://${service}.${credentials.region}.amazonaws.com`;
} }

View file

@ -12,6 +12,7 @@ import {
import { import {
awsApiRequestREST, awsApiRequestREST,
keysTPascalCase,
} from './GenericFunctions'; } from './GenericFunctions';
export class AwsRekognition implements INodeType { export class AwsRekognition implements INodeType {
@ -66,6 +67,16 @@ export class AwsRekognition implements INodeType {
displayName: 'Type', displayName: 'Type',
name: 'type', name: 'type',
type: 'options', type: 'options',
displayOptions: {
show: {
operation: [
'analyze',
],
resource: [
'image',
],
},
},
options: [ options: [
{ {
name: 'Detect Faces', name: 'Detect Faces',
@ -79,6 +90,10 @@ export class AwsRekognition implements INodeType {
name: 'Detect Moderation Labels', name: 'Detect Moderation Labels',
value: 'detectModerationLabels', value: 'detectModerationLabels',
}, },
{
name: 'Detect Text',
value: 'detectText',
},
{ {
name: 'Recognize Celebrity', name: 'Recognize Celebrity',
value: 'recognizeCelebrity', value: 'recognizeCelebrity',
@ -185,6 +200,59 @@ export class AwsRekognition implements INodeType {
}, },
default: {}, default: {},
options: [ options: [
{
displayName: 'Regions of Interest',
name: 'regionsOfInterestUi',
type: 'fixedCollection',
default: '',
placeholder: 'Add Region of Interest',
displayOptions: {
show: {
'/type': [
'detectText',
],
},
},
typeOptions: {
multipleValues: true,
},
options: [
{
name: 'regionsOfInterestValues',
displayName: 'Region of Interest',
values: [
{
displayName: 'Height',
name: 'height',
type: 'number',
description: 'Height of the bounding box as a ratio of the overall image height.',
default: 0,
},
{
displayName: 'Left',
name: 'left',
type: 'number',
description: 'Left coordinate of the bounding box as a ratio of overall image width.',
default: 0,
},
{
displayName: 'Top',
name: 'top',
type: 'number',
description: 'Top coordinate of the bounding box as a ratio of overall image height.',
default: 0,
},
{
displayName: 'Width',
name: 'Width',
type: 'number',
description: 'Width of the bounding box as a ratio of the overall image width.',
default: 0,
},
],
},
],
},
{ {
displayName: 'Version', displayName: 'Version',
name: 'version', name: 'version',
@ -199,6 +267,46 @@ export class AwsRekognition implements INodeType {
default: '', default: '',
description: 'If the bucket is versioning enabled, you can specify the object version', description: 'If the bucket is versioning enabled, you can specify the object version',
}, },
{
displayName: 'Word Filter',
name: 'wordFilterUi',
type: 'collection',
default: '',
placeholder: 'Add Word Filter',
displayOptions: {
show: {
'/type': [
'detectText',
],
},
},
typeOptions: {
multipleValues: false,
},
options: [
{
displayName: 'Min Bounding Box Height',
name: 'MinBoundingBoxHeight',
type: 'number',
description: 'Sets the minimum height of the word bounding box. Words with bounding box heights lesser than this value will be excluded from the result. Value is relative to the video frame height.',
default: 0,
},
{
displayName: 'Min Bounding Box Width',
name: 'MinBoundingBoxWidth',
type: 'number',
description: 'Sets the minimum width of the word bounding box. Words with bounding boxes widths lesser than this value will be excluded from the result. Value is relative to the video frame width.',
default: 0,
},
{
displayName: 'Min Confidence',
name: 'MinConfidence',
type: 'number',
description: 'Sets the confidence of word detection. Words with detection confidence below this will be excluded from the result. Values should be between 50 and 100 as Text in Video will not return any result below 50.',
default: 0,
},
],
},
{ {
displayName: 'Max Labels', displayName: 'Max Labels',
name: 'maxLabels', name: 'maxLabels',
@ -280,7 +388,7 @@ export class AwsRekognition implements INodeType {
let action = undefined; let action = undefined;
let body: IDataObject = {}; const body: IDataObject = {};
const type = this.getNodeParameter('type', 0) as string; const type = this.getNodeParameter('type', 0) as string;
@ -324,57 +432,72 @@ export class AwsRekognition implements INodeType {
action = 'RekognitionService.RecognizeCelebrities'; action = 'RekognitionService.RecognizeCelebrities';
} }
const binaryData = this.getNodeParameter('binaryData', 0) as boolean; if (type === 'detectText') {
action = 'RekognitionService.DetectText';
if (binaryData) { body.Filters = {};
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', 0) as string; const box = (additionalFields.regionsOfInterestUi as IDataObject || {}).regionsOfInterestValues as IDataObject[] || [];
if (items[i].binary === undefined) { if (box.length !== 0) {
throw new Error('No binary data exists on item!');
}
if ((items[i].binary as IBinaryKeyData)[binaryPropertyName] === undefined) {
throw new Error(`No binary data property "${binaryPropertyName}" does not exists on item!`);
}
const binaryPropertyData = (items[i].binary as IBinaryKeyData)[binaryPropertyName];
body = {
Image: {
Bytes: binaryPropertyData.data,
},
};
} else {
const bucket = this.getNodeParameter('bucket', i) as string;
const name = this.getNodeParameter('name', i) as string;
body = {
Image: {
S3Object: {
Bucket: bucket,
Name: name,
},
},
};
if (additionalFields.version) {
//@ts-ignore //@ts-ignore
body.Image.S3Object.Version = additionalFields.version as string; body.Filters.RegionsOfInterest = box.map((box: IDataObject) => {
return { BoundingBox: keysTPascalCase(box) };
});
} }
const wordFilter = additionalFields.wordFilterUi as IDataObject || {};
if (Object.keys(wordFilter).length !== 0) {
//@ts-ignore
body.Filters.WordFilter = keysTPascalCase(wordFilter);
}
const binaryData = this.getNodeParameter('binaryData', 0) as boolean;
if (binaryData) {
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', 0) as string;
if (items[i].binary === undefined) {
throw new Error('No binary data exists on item!');
}
if ((items[i].binary as IBinaryKeyData)[binaryPropertyName] === undefined) {
throw new Error(`No binary data property "${binaryPropertyName}" does not exists on item!`);
}
const binaryPropertyData = (items[i].binary as IBinaryKeyData)[binaryPropertyName];
Object.assign(body, {
Image: {
Bytes: binaryPropertyData.data,
},
});
} else {
const bucket = this.getNodeParameter('bucket', i) as string;
const name = this.getNodeParameter('name', i) as string;
Object.assign(body, {
Image: {
S3Object: {
Bucket: bucket,
Name: name,
},
},
});
if (additionalFields.version) {
//@ts-ignore
body.Image.S3Object.Version = additionalFields.version as string;
}
}
responseData = await awsApiRequestREST.call(this, 'rekognition', 'POST', '', JSON.stringify(body), {}, { 'X-Amz-Target': action, 'Content-Type': 'application/x-amz-json-1.1' });
} }
responseData = await awsApiRequestREST.call(this, 'rekognition', 'POST', '', JSON.stringify(body), {}, { 'X-Amz-Target': action, 'Content-Type': 'application/x-amz-json-1.1' });
// if (property !== undefined) {
// responseData = responseData[property as string];
// }
} }
} }
if (Array.isArray(responseData)) { if (Array.isArray(responseData)) {
returnData.push.apply(returnData, responseData as IDataObject[]); returnData.push.apply(returnData, responseData as IDataObject[]);
} else { } else {

View file

@ -29,6 +29,10 @@ import {
IDataObject, IDataObject,
} from 'n8n-workflow'; } from 'n8n-workflow';
import {
pascalCase,
} from 'change-case';
export async function awsApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, service: string, method: string, path: string, body?: string | Buffer | IDataObject, query: IDataObject = {}, headers?: object, option: IDataObject = {}, region?: string): Promise<any> { // tslint:disable-line:no-any export async function awsApiRequest(this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions, service: string, method: string, path: string, body?: string | Buffer | IDataObject, query: IDataObject = {}, headers?: object, option: IDataObject = {}, region?: string): Promise<any> { // tslint:disable-line:no-any
const credentials = this.getCredentials('aws'); const credentials = this.getCredentials('aws');
if (credentials === undefined) { if (credentials === undefined) {
@ -128,3 +132,11 @@ export async function awsApiRequestSOAPAllItems(this: IHookFunctions | IExecuteF
function queryToString(params: IDataObject) { function queryToString(params: IDataObject) {
return Object.keys(params).map(key => key + '=' + params[key]).join('&'); return Object.keys(params).map(key => key + '=' + params[key]).join('&');
} }
export function keysTPascalCase(object: IDataObject) {
const data: IDataObject = {};
for (const key of Object.keys(object)) {
data[pascalCase(key as string)] = object[key];
}
return data;
}

View file

@ -1125,15 +1125,9 @@ export class AwsSes implements INodeType {
setParameter(params, 'Destination.BccAddresses.member', additionalFields.bccAddresses as string[]); setParameter(params, 'Destination.BccAddresses.member', additionalFields.bccAddresses as string[]);
} }
if (additionalFields.ccAddressesUi) { if (additionalFields.ccAddresses) {
let ccAddresses = (additionalFields.ccAddressesUi as IDataObject).ccAddressesValues as string[]; setParameter(params, 'Destination.CcAddresses.member', additionalFields.ccAddresses as string[]);
//@ts-ignore
ccAddresses = ccAddresses.map(o => o.address);
if (ccAddresses) {
setParameter(params, 'Destination.CcAddresses.member', ccAddresses);
}
} }
responseData = await awsApiRequestSOAP.call(this, 'email', 'POST', '/?Action=SendEmail&' + params.join('&')); responseData = await awsApiRequestSOAP.call(this, 'email', 'POST', '/?Action=SendEmail&' + params.join('&'));
} }
@ -1184,13 +1178,8 @@ export class AwsSes implements INodeType {
setParameter(params, 'Destination.BccAddresses.member', additionalFields.bccAddresses as string[]); setParameter(params, 'Destination.BccAddresses.member', additionalFields.bccAddresses as string[]);
} }
if (additionalFields.ccAddressesUi) { if (additionalFields.ccAddresses) {
let ccAddresses = (additionalFields.ccAddressesUi as IDataObject).ccAddressesValues as string[]; setParameter(params, 'Destination.CcAddresses.member', additionalFields.ccAddresses as string[]);
//@ts-ignore
ccAddresses = ccAddresses.map(o => o.address);
if (ccAddresses) {
setParameter(params, 'Destination.CcAddresses.member', ccAddresses);
}
} }
if (templateDataUi) { if (templateDataUi) {

View file

@ -0,0 +1,21 @@
{
"node": "n8n-nodes-base.awsSqs",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": [
"Development",
"Communication"
],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/credentials/aws"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/nodes/n8n-nodes-base.awsSqs/"
}
]
}
}

View file

@ -0,0 +1,387 @@
import {
BINARY_ENCODING,
IExecuteFunctions,
} from 'n8n-core';
import {
IDataObject,
ILoadOptionsFunctions,
INodeExecutionData,
INodeParameters,
INodePropertyOptions,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import {
URL,
} from 'url';
import {
awsApiRequestSOAP,
} from '../GenericFunctions';
export class AwsSqs implements INodeType {
description: INodeTypeDescription = {
displayName: 'AWS SQS',
name: 'awsSqs',
icon: 'file:sqs.svg',
group: ['output'],
version: 1,
subtitle: `={{$parameter["operation"]}}`,
description: 'Sends messages to AWS SQS',
defaults: {
name: 'AWS SQS',
color: '#FF9900',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'aws',
required: true,
},
],
properties: [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
options: [
{
name: 'Send message',
value: 'sendMessage',
description: 'Send a message to a queue.',
},
],
default: 'sendMessage',
description: 'The operation to perform.',
},
{
displayName: 'Queue',
name: 'queue',
type: 'options',
typeOptions: {
loadOptionsMethod: 'getQueues',
},
displayOptions: {
show: {
operation: [
'sendMessage',
],
},
},
options: [],
default: '',
required: true,
description: 'Queue to send a message to.',
},
{
displayName: 'Queue Type',
name: 'queueType',
type: 'options',
options: [
{
name: 'FIFO',
value: 'fifo',
description: 'FIFO SQS queue.',
},
{
name: 'Standard',
value: 'standard',
description: 'Standard SQS queue.',
},
],
default: 'standard',
description: 'The operation to perform.',
},
{
displayName: 'Send Input Data',
name: 'sendInputData',
type: 'boolean',
default: true,
description: 'Send the data the node receives as JSON to SQS.',
},
{
displayName: 'Message',
name: 'message',
type: 'string',
displayOptions: {
show: {
operation: [
'sendMessage',
],
sendInputData: [
false,
],
},
},
required: true,
typeOptions: {
alwaysOpenEditWindow: true,
},
default: '',
description: 'Message to send to the queue.',
},
{
displayName: 'Message Group ID',
name: 'messageGroupId',
type: 'string',
default: '',
description: 'Tag that specifies that a message belongs to a specific message group. Applies only to FIFO (first-in-first-out) queues.',
displayOptions: {
show: {
queueType: [
'fifo',
],
},
},
required: true,
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
displayOptions: {
show: {
operation: [
'sendMessage',
],
},
},
default: {},
placeholder: 'Add Option',
options: [
{
displayName: 'Delay Seconds',
name: 'delaySeconds',
type: 'number',
displayOptions: {
show: {
'/queueType': [
'standard',
],
},
},
description: 'How long, in seconds, to delay a message for.',
default: 0,
typeOptions: {
minValue: 0,
maxValue: 900,
},
},
{
displayName: 'Message Attributes',
name: 'messageAttributes',
placeholder: 'Add Attribute',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
description: 'Attributes to set.',
default: {},
options: [
{
name: 'binary',
displayName: 'Binary',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
description: 'Name of the attribute.',
},
{
displayName: 'Property Name',
name: 'dataPropertyName',
type: 'string',
default: 'data',
description: 'Name of the binary property which contains the data for the message attribute.',
},
],
},
{
name: 'number',
displayName: 'Number',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
description: 'Name of the attribute.',
},
{
displayName: 'Value',
name: 'value',
type: 'number',
default: 0,
description: 'Number value of the attribute.',
},
],
},
{
name: 'string',
displayName: 'String',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
description: 'Name of the attribute.',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'String value of attribute.',
},
],
},
],
},
{
displayName: 'Message Deduplication ID',
name: 'messageDeduplicationId',
type: 'string',
default: '',
description: 'Token used for deduplication of sent messages. Applies only to FIFO (first-in-first-out) queues.',
displayOptions: {
show: {
'/queueType': [
'fifo',
],
},
},
},
],
},
],
};
methods = {
loadOptions: {
// Get all the available queues to display them to user so that it can be selected easily
async getQueues(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
let data;
try {
// loads first 1000 queues from SQS
data = await awsApiRequestSOAP.call(this, 'sqs', 'GET', `?Action=ListQueues`);
} catch (err) {
throw new Error(`AWS Error: ${err}`);
}
let queues = data.ListQueuesResponse.ListQueuesResult.QueueUrl;
if (!queues) {
return [];
}
if (!Array.isArray(queues)) {
// If user has only a single queue no array get returned so we make
// one manually to be able to process everything identically
queues = [queues];
}
return queues.map((queueUrl: string) => {
const urlParts = queueUrl.split('/');
const name = urlParts[urlParts.length - 1];
return {
name,
value: queueUrl,
};
});
},
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const returnData: IDataObject[] = [];
const operation = this.getNodeParameter('operation', 0) as string;
for (let i = 0; i < items.length; i++) {
const queueUrl = this.getNodeParameter('queue', i) as string;
const queuePath = new URL(queueUrl).pathname;
const params = [];
const options = this.getNodeParameter('options', i, {}) as IDataObject;
const sendInputData = this.getNodeParameter('sendInputData', i) as boolean;
const message = sendInputData ? JSON.stringify(items[i].json) : this.getNodeParameter('message', i) as string;
params.push(`MessageBody=${message}`);
if (options.delaySeconds) {
params.push(`DelaySeconds=${options.delaySeconds}`);
}
const queueType = this.getNodeParameter('queueType', i, {}) as string;
if (queueType === 'fifo') {
const messageDeduplicationId = this.getNodeParameter('options.messageDeduplicationId', i, '') as string;
if (messageDeduplicationId) {
params.push(`MessageDeduplicationId=${messageDeduplicationId}`);
}
const messageGroupId = this.getNodeParameter('messageGroupId', i) as string;
if (messageGroupId) {
params.push(`MessageGroupId=${messageGroupId}`);
}
}
let attributeCount = 0;
// Add string values
(this.getNodeParameter('options.messageAttributes.string', i, []) as INodeParameters[]).forEach((attribute) => {
attributeCount++;
params.push(`MessageAttribute.${attributeCount}.Name=${attribute.name}`);
params.push(`MessageAttribute.${attributeCount}.Value.StringValue=${attribute.value}`);
params.push(`MessageAttribute.${attributeCount}.Value.DataType=String`);
});
// Add binary values
(this.getNodeParameter('options.messageAttributes.binary', i, []) as INodeParameters[]).forEach((attribute) => {
attributeCount++;
const dataPropertyName = attribute.dataPropertyName as string;
const item = items[i];
if (item.binary === undefined) {
throw new Error('No binary data set. So message attribute cannot be added!');
}
if (item.binary[dataPropertyName] === undefined) {
throw new Error(`The binary property "${dataPropertyName}" does not exist. So message attribute cannot be added!`);
}
const binaryData = item.binary[dataPropertyName].data;
params.push(`MessageAttribute.${attributeCount}.Name=${attribute.name}`);
params.push(`MessageAttribute.${attributeCount}.Value.BinaryValue=${binaryData}`);
params.push(`MessageAttribute.${attributeCount}.Value.DataType=Binary`);
});
// Add number values
(this.getNodeParameter('options.messageAttributes.number', i, []) as INodeParameters[]).forEach((attribute) => {
attributeCount++;
params.push(`MessageAttribute.${attributeCount}.Name=${attribute.name}`);
params.push(`MessageAttribute.${attributeCount}.Value.StringValue=${attribute.value}`);
params.push(`MessageAttribute.${attributeCount}.Value.DataType=Number`);
});
let responseData;
try {
responseData = await awsApiRequestSOAP.call(this, 'sqs', 'GET', `${queuePath}/?Action=${operation}&` + params.join('&'));
} catch (err) {
throw new Error(`AWS Error: ${err}`);
}
const result = responseData.SendMessageResponse.SendMessageResult;
returnData.push(result as IDataObject);
}
return [this.helpers.returnJsonArray(returnData)];
}
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="-5 0 85 85" fill="#fff" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round"><use xlink:href="#A" x="2.188" y="2.5"/><symbol id="A" overflow="visible"><g fill="#876929" stroke="none"><path d="M0 25.938L.021 63.44 35 80l34.98-16.56v-9.368l-29.745-3.508.02-21.127L70 25.948V16.57L35 0 0 16.56v9.378z"/><path d="M.021 54.062l34.98 9.942V80L.021 63.44v-9.378z"/><path d="M4.465 65.549L0 63.431V16.56l4.475-2.109-.01 51.098z"/></g><path d="M40.255 50.564l-35.79 4.762.01-30.661 35.78 4.772v21.127zM70 25.948l-35-9.951V0l35 16.57v9.378zm-.02 28.124L35 64.004V80l34.98-16.56v-9.368z" stroke="none" fill="#d9a741"/><path d="M22.109 48.581l12.892 1.526V29.815L22.109 31.36v17.221zM9.125 47.065l8.365.982V31.924l-8.365 1.001v14.14z" stroke="none" fill="#876929"/><path d="M4.475 24.665L35 15.996l35 9.951-29.745 3.489-35.78-4.772z" fill="#624a1e" stroke="none"/><path d="M4.465 55.326L35 64.004l34.98-9.932-29.724-3.508-35.79 4.762z" fill="#fad791" stroke="none"/><path d="M69.98 45.918L35 50.107V29.815l34.98 4.218v11.885z" fill="#d9a741" stroke="none"/></symbol></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,509 @@
import {
IExecuteFunctions,
} from 'n8n-core';
import {
IDataObject,
ILoadOptionsFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import {
bitwardenApiRequest as tokenlessBitwardenApiRequest,
getAccessToken,
handleGetAll as tokenlessHandleGetAll,
loadResource,
} from './GenericFunctions';
import {
collectionFields,
collectionOperations,
CollectionUpdateFields,
} from './descriptions/CollectionDescription';
import {
eventFields,
eventOperations,
} from './descriptions/EventDescription';
import {
GroupCreationAdditionalFields,
groupFields,
groupOperations,
GroupUpdateFields,
} from './descriptions/GroupDescription';
import {
MemberCreationAdditionalFields,
memberFields,
memberOperations,
MemberUpdateFields,
} from './descriptions/MemberDescription';
import {
isEmpty,
partialRight,
} from 'lodash';
export class Bitwarden implements INodeType {
description: INodeTypeDescription = {
displayName: 'Bitwarden',
name: 'bitwarden',
icon: 'file:bitwarden.svg',
group: ['transform'],
version: 1,
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
description: 'Consume the Bitwarden API',
defaults: {
name: 'Bitwarden',
color: '#175DDC',
},
inputs: ['main'],
outputs: ['main'],
credentials: [
{
name: 'bitwardenApi',
required: true,
},
],
properties: [
{
displayName: 'Resource',
name: 'resource',
type: 'options',
options: [
{
name: 'Collection',
value: 'collection',
},
{
name: 'Event',
value: 'event',
},
{
name: 'Group',
value: 'group',
},
{
name: 'Member',
value: 'member',
},
],
default: 'collection',
description: 'Resource to consume',
},
...collectionOperations,
...collectionFields,
...eventOperations,
...eventFields,
...groupOperations,
...groupFields,
...memberOperations,
...memberFields,
],
};
methods = {
loadOptions: {
async getGroups(this: ILoadOptionsFunctions) {
return await loadResource.call(this, 'groups');
},
async getCollections(this: ILoadOptionsFunctions) {
return await loadResource.call(this, 'collections');
},
},
};
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 returnData: IDataObject[] = [];
const token = await getAccessToken.call(this);
const bitwardenApiRequest = partialRight(tokenlessBitwardenApiRequest, token);
const handleGetAll = partialRight(tokenlessHandleGetAll, token);
for (let i = 0; i < items.length; i++) {
if (resource === 'collection') {
// *********************************************************************
// collection
// *********************************************************************
if (operation === 'delete') {
// ----------------------------------
// collection: delete
// ----------------------------------
const id = this.getNodeParameter('collectionId', i);
const endpoint = `/public/collections/${id}`;
responseData = await bitwardenApiRequest.call(this, 'DELETE', endpoint, {}, {});
responseData = { success: true };
} else if (operation === 'get') {
// ----------------------------------
// collection: get
// ----------------------------------
const id = this.getNodeParameter('collectionId', i);
const endpoint = `/public/collections/${id}`;
responseData = await bitwardenApiRequest.call(this, 'GET', endpoint, {}, {});
} else if (operation === 'getAll') {
// ----------------------------------
// collection: getAll
// ----------------------------------
const endpoint = '/public/collections';
responseData = await handleGetAll.call(this, i, 'GET', endpoint, {}, {});
} else if (operation === 'update') {
// ----------------------------------
// collection: update
// ----------------------------------
const updateFields = this.getNodeParameter('updateFields', i) as CollectionUpdateFields;
if (isEmpty(updateFields)) {
throw new Error(`Please enter at least one field to update for the ${resource}.`);
}
const { groups, externalId } = updateFields;
const body = {} as IDataObject;
if (groups) {
body.groups = groups.map((groupId) => ({
id: groupId,
ReadOnly: false,
}));
}
if (externalId) {
body.externalId = externalId;
}
const id = this.getNodeParameter('collectionId', i);
const endpoint = `/public/collections/${id}`;
responseData = await bitwardenApiRequest.call(this, 'PUT', endpoint, {}, body);
}
} else if (resource === 'event') {
// *********************************************************************
// event
// *********************************************************************
if (operation === 'getAll') {
// ----------------------------------
// event: getAll
// ----------------------------------
const filters = this.getNodeParameter('filters', i) as IDataObject;
const qs = isEmpty(filters) ? {} : filters;
const endpoint = '/public/events';
responseData = await handleGetAll.call(this, i, 'GET', endpoint, qs, {});
}
} else if (resource === 'group') {
// *********************************************************************
// group
// *********************************************************************
if (operation === 'create') {
// ----------------------------------
// group: create
// ----------------------------------
const body = {
name: this.getNodeParameter('name', i),
AccessAll: this.getNodeParameter('accessAll', i),
} as IDataObject;
const {
collections,
externalId,
} = this.getNodeParameter('additionalFields', i) as GroupCreationAdditionalFields;
if (collections) {
body.collections = collections.map((collectionId) => ({
id: collectionId,
ReadOnly: false,
}));
}
if (externalId) {
body.externalId = externalId;
}
const endpoint = '/public/groups';
responseData = await bitwardenApiRequest.call(this, 'POST', endpoint, {}, body);
} else if (operation === 'delete') {
// ----------------------------------
// group: delete
// ----------------------------------
const id = this.getNodeParameter('groupId', i);
const endpoint = `/public/groups/${id}`;
responseData = await bitwardenApiRequest.call(this, 'DELETE', endpoint, {}, {});
responseData = { success: true };
} else if (operation === 'get') {
// ----------------------------------
// group: get
// ----------------------------------
const id = this.getNodeParameter('groupId', i);
const endpoint = `/public/groups/${id}`;
responseData = await bitwardenApiRequest.call(this, 'GET', endpoint, {}, {});
} else if (operation === 'getAll') {
// ----------------------------------
// group: getAll
// ----------------------------------
const endpoint = '/public/groups';
responseData = await handleGetAll.call(this, i, 'GET', endpoint, {}, {});
} else if (operation === 'getMembers') {
// ----------------------------------
// group: getMembers
// ----------------------------------
const id = this.getNodeParameter('groupId', i);
const endpoint = `/public/groups/${id}/member-ids`;
responseData = await bitwardenApiRequest.call(this, 'GET', endpoint, {}, {});
responseData = responseData.map((memberId: string) => ({ memberId }));
} else if (operation === 'update') {
// ----------------------------------
// group: update
// ----------------------------------
const groupId = this.getNodeParameter('groupId', i);
const updateFields = this.getNodeParameter('updateFields', i) as GroupUpdateFields;
if (isEmpty(updateFields)) {
throw new Error(`Please enter at least one field to update for the ${resource}.`);
}
// set defaults for `name` and `accessAll`, required by Bitwarden but optional in n8n
let { name, accessAll } = updateFields;
if (name === undefined) {
responseData = await bitwardenApiRequest.call(this, 'GET', `/public/groups/${groupId}`, {}, {}) as { name: string };
name = responseData.name;
}
if (accessAll === undefined) {
accessAll = false;
}
const body = {
name,
AccessAll: accessAll,
} as IDataObject;
const { collections, externalId } = updateFields;
if (collections) {
body.collections = collections.map((collectionId) => ({
id: collectionId,
ReadOnly: false,
}));
}
if (externalId) {
body.externalId = externalId;
}
const endpoint = `/public/groups/${groupId}`;
responseData = await bitwardenApiRequest.call(this, 'PUT', endpoint, {}, body);
} else if (operation === 'updateMembers') {
// ----------------------------------
// group: updateMembers
// ----------------------------------
const memberIds = this.getNodeParameter('memberIds', i) as string;
const body = {
memberIds: memberIds.includes(',') ? memberIds.split(',') : [memberIds],
};
const groupId = this.getNodeParameter('groupId', i);
const endpoint = `/public/groups/${groupId}/member-ids`;
responseData = await bitwardenApiRequest.call(this, 'PUT', endpoint, {}, body);
responseData = { success: true };
}
} else if (resource === 'member') {
// *********************************************************************
// member
// *********************************************************************
if (operation === 'create') {
// ----------------------------------
// member: create
// ----------------------------------
const body = {
email: this.getNodeParameter('email', i),
type: this.getNodeParameter('type', i),
AccessAll: this.getNodeParameter('accessAll', i),
} as IDataObject;
const {
collections,
externalId,
} = this.getNodeParameter('additionalFields', i) as MemberCreationAdditionalFields;
if (collections) {
body.collections = collections.map((collectionId) => ({
id: collectionId,
ReadOnly: false,
}));
}
if (externalId) {
body.externalId = externalId;
}
const endpoint = '/public/members/';
responseData = await bitwardenApiRequest.call(this, 'POST', endpoint, {}, body);
} else if (operation === 'delete') {
// ----------------------------------
// member: delete
// ----------------------------------
const id = this.getNodeParameter('memberId', i);
const endpoint = `/public/members/${id}`;
responseData = await bitwardenApiRequest.call(this, 'DELETE', endpoint, {}, {});
responseData = { success: true };
} else if (operation === 'get') {
// ----------------------------------
// member: get
// ----------------------------------
const id = this.getNodeParameter('memberId', i);
const endpoint = `/public/members/${id}`;
responseData = await bitwardenApiRequest.call(this, 'GET', endpoint, {}, {});
} else if (operation === 'getAll') {
// ----------------------------------
// member: getAll
// ----------------------------------
const endpoint = '/public/members';
responseData = await handleGetAll.call(this, i, 'GET', endpoint, {}, {});
} else if (operation === 'getGroups') {
// ----------------------------------
// member: getGroups
// ----------------------------------
const id = this.getNodeParameter('memberId', i);
const endpoint = `/public/members/${id}/group-ids`;
responseData = await bitwardenApiRequest.call(this, 'GET', endpoint, {}, {});
responseData = responseData.map((groupId: string) => ({ groupId }));
} else if (operation === 'update') {
// ----------------------------------
// member: update
// ----------------------------------
const body = {} as IDataObject;
const updateFields = this.getNodeParameter('updateFields', i) as MemberUpdateFields;
if (isEmpty(updateFields)) {
throw new Error(`Please enter at least one field to update for the ${resource}.`);
}
const { accessAll, collections, externalId, type } = updateFields;
if (accessAll !== undefined) {
body.AccessAll = accessAll;
}
if (collections) {
body.collections = collections.map((collectionId) => ({
id: collectionId,
ReadOnly: false,
}));
}
if (externalId) {
body.externalId = externalId;
}
if (type !== undefined) {
body.Type = type;
}
const id = this.getNodeParameter('memberId', i);
const endpoint = `/public/members/${id}`;
responseData = await bitwardenApiRequest.call(this, 'PUT', endpoint, {}, body);
} else if (operation === 'updateGroups') {
// ----------------------------------
// member: updateGroups
// ----------------------------------
const groupIds = this.getNodeParameter('groupIds', i) as string;
const body = {
groupIds: groupIds.includes(',') ? groupIds.split(',') : [groupIds],
};
const memberId = this.getNodeParameter('memberId', i);
const endpoint = `/public/members/${memberId}/group-ids`;
responseData = await bitwardenApiRequest.call(this, 'PUT', endpoint, {}, body);
responseData = { success: true };
}
}
Array.isArray(responseData)
? returnData.push(...responseData)
: returnData.push(responseData);
}
return [this.helpers.returnJsonArray(returnData)];
}
}

Some files were not shown because too many files have changed in this diff Show more