feat: (Execute Workflow Node): Inputs for Sub-workflows (#11830) (#11837)

Co-authored-by: Charlie Kolb <charlie@n8n.io>
Co-authored-by: Milorad FIlipović <milorad@n8n.io>
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
Ivan Atanasov
2024-12-20 17:01:22 +01:00
committed by GitHub
parent 6c323e4e49
commit d4116630a6
52 changed files with 4023 additions and 688 deletions

View File

@@ -0,0 +1,18 @@
{
"node": "n8n-nodes-base.executeWorkflow",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"details": "The Execute Workflow node can be used when you want your workflow to treat another workflow as a step in your flow. It allows you to modularize your workflows and have a single source of truth for series of actions you perform often. ",
"categories": ["Core Nodes"],
"resources": {
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.executeworkflow/"
}
]
},
"alias": ["n8n"],
"subcategories": {
"Core Nodes": ["Helpers", "Flow"]
}
}

View File

@@ -0,0 +1,113 @@
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions, IWorkflowDataProxyData } from 'n8n-workflow';
import { ExecuteWorkflow } from './ExecuteWorkflow.node';
import { getWorkflowInfo } from './GenericFunctions';
jest.mock('./GenericFunctions');
jest.mock('../../../utils/utilities');
describe('ExecuteWorkflow', () => {
const executeWorkflow = new ExecuteWorkflow();
const executeFunctions = mock<IExecuteFunctions>({
getNodeParameter: jest.fn(),
getInputData: jest.fn(),
getWorkflowDataProxy: jest.fn(),
executeWorkflow: jest.fn(),
continueOnFail: jest.fn(),
setMetadata: jest.fn(),
getNode: jest.fn(),
});
beforeEach(() => {
jest.clearAllMocks();
executeFunctions.getInputData.mockReturnValue([{ json: { key: 'value' } }]);
executeFunctions.getWorkflowDataProxy.mockReturnValue({
$workflow: { id: 'workflowId' },
$execution: { id: 'executionId' },
} as unknown as IWorkflowDataProxyData);
});
test('should execute workflow in "each" mode and wait for sub-workflow completion', async () => {
executeFunctions.getNodeParameter
.mockReturnValueOnce('database') // source
.mockReturnValueOnce('each') // mode
.mockReturnValueOnce(true) // waitForSubWorkflow
.mockReturnValueOnce([]); // workflowInputs.schema
executeFunctions.getInputData.mockReturnValue([{ json: { key: 'value' } }]);
executeFunctions.getWorkflowDataProxy.mockReturnValue({
$workflow: { id: 'workflowId' },
$execution: { id: 'executionId' },
} as unknown as IWorkflowDataProxyData);
(getWorkflowInfo as jest.Mock).mockResolvedValue({ id: 'subWorkflowId' });
(executeFunctions.executeWorkflow as jest.Mock).mockResolvedValue({
executionId: 'subExecutionId',
data: [[{ json: { key: 'subValue' } }]],
});
const result = await executeWorkflow.execute.call(executeFunctions);
expect(result).toEqual([
[
{
json: { key: 'value' },
index: 0,
pairedItem: { item: 0 },
metadata: {
subExecution: { workflowId: 'subWorkflowId', executionId: 'subExecutionId' },
},
},
],
]);
});
test('should execute workflow in "once" mode and not wait for sub-workflow completion', async () => {
executeFunctions.getNodeParameter
.mockReturnValueOnce('database') // source
.mockReturnValueOnce('once') // mode
.mockReturnValueOnce(false) // waitForSubWorkflow
.mockReturnValueOnce([]); // workflowInputs.schema
executeFunctions.getInputData.mockReturnValue([{ json: { key: 'value' } }]);
executeFunctions.executeWorkflow.mockResolvedValue({
executionId: 'subExecutionId',
data: [[{ json: { key: 'subValue' } }]],
});
const result = await executeWorkflow.execute.call(executeFunctions);
expect(result).toEqual([[{ json: { key: 'value' }, index: 0, pairedItem: { item: 0 } }]]);
});
test('should handle errors and continue on fail', async () => {
executeFunctions.getNodeParameter
.mockReturnValueOnce('database') // source
.mockReturnValueOnce('each') // mode
.mockReturnValueOnce(true) // waitForSubWorkflow
.mockReturnValueOnce([]); // workflowInputs.schema
(getWorkflowInfo as jest.Mock).mockRejectedValue(new Error('Test error'));
(executeFunctions.continueOnFail as jest.Mock).mockReturnValue(true);
const result = await executeWorkflow.execute.call(executeFunctions);
expect(result).toEqual([[{ json: { error: 'Test error' }, pairedItem: { item: 0 } }]]);
});
test('should throw error if not continuing on fail', async () => {
executeFunctions.getNodeParameter
.mockReturnValueOnce('database') // source
.mockReturnValueOnce('each') // mode
.mockReturnValueOnce(true) // waitForSubWorkflow
.mockReturnValueOnce([]); // workflowInputs.schema
(getWorkflowInfo as jest.Mock).mockRejectedValue(new Error('Test error'));
(executeFunctions.continueOnFail as jest.Mock).mockReturnValue(false);
await expect(executeWorkflow.execute.call(executeFunctions)).rejects.toThrow(
'Error executing workflow with item at index 0',
);
});
});

View File

@@ -0,0 +1,445 @@
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
import type {
ExecuteWorkflowData,
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { getWorkflowInfo } from './GenericFunctions';
import { generatePairedItemData } from '../../../utils/utilities';
import {
getCurrentWorkflowInputData,
loadWorkflowInputMappings,
} from '../../../utils/workflowInputsResourceMapping/GenericFunctions';
export class ExecuteWorkflow implements INodeType {
description: INodeTypeDescription = {
displayName: 'Execute Workflow',
name: 'executeWorkflow',
icon: 'fa:sign-in-alt',
iconColor: 'orange-red',
group: ['transform'],
version: [1, 1.1, 1.2],
subtitle: '={{"Workflow: " + $parameter["workflowId"]}}',
description: 'Execute another workflow',
defaults: {
name: 'Execute Workflow',
color: '#ff6d5a',
},
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
properties: [
{
displayName: 'Operation',
name: 'operation',
type: 'hidden',
noDataExpression: true,
default: 'call_workflow',
options: [
{
name: 'Call Another Workflow',
value: 'call_workflow',
},
],
},
{
displayName: 'This node is out of date. Please upgrade by removing it and adding a new one',
name: 'outdatedVersionWarning',
type: 'notice',
displayOptions: { show: { '@version': [{ _cnd: { lte: 1.1 } }] } },
default: '',
},
{
displayName: 'Source',
name: 'source',
type: 'options',
options: [
{
name: 'Database',
value: 'database',
description: 'Load the workflow from the database by ID',
},
{
name: 'Local File',
value: 'localFile',
description: 'Load the workflow from a locally saved file',
},
{
name: 'Parameter',
value: 'parameter',
description: 'Load the workflow from a parameter',
},
{
name: 'URL',
value: 'url',
description: 'Load the workflow from an URL',
},
],
default: 'database',
description: 'Where to get the workflow to execute from',
displayOptions: { show: { '@version': [{ _cnd: { lte: 1.1 } }] } },
},
{
displayName: 'Source',
name: 'source',
type: 'options',
options: [
{
name: 'Database',
value: 'database',
description: 'Load the workflow from the database by ID',
},
{
name: 'Define Below',
value: 'parameter',
description: 'Pass the JSON code of a workflow',
},
],
default: 'database',
description: 'Where to get the workflow to execute from',
displayOptions: { show: { '@version': [{ _cnd: { gte: 1.2 } }] } },
},
// ----------------------------------
// source:database
// ----------------------------------
{
displayName: 'Workflow ID',
name: 'workflowId',
type: 'string',
displayOptions: {
show: {
source: ['database'],
'@version': [1],
},
},
default: '',
required: true,
hint: 'Can be found in the URL of the workflow',
description:
"Note on using an expression here: if this node is set to run once with all items, they will all be sent to the <em>same</em> workflow. That workflow's ID will be calculated by evaluating the expression for the <strong>first input item</strong>.",
},
{
displayName: 'Workflow',
name: 'workflowId',
type: 'workflowSelector',
displayOptions: {
show: {
source: ['database'],
'@version': [{ _cnd: { gte: 1.1 } }],
},
},
default: '',
required: true,
hint: "Note on using an expression here: if this node is set to run once with all items, they will all be sent to the <em>same</em> workflow. That workflow's ID will be calculated by evaluating the expression for the <strong>first input item</strong>.",
},
// ----------------------------------
// source:localFile
// ----------------------------------
{
displayName: 'Workflow Path',
name: 'workflowPath',
type: 'string',
displayOptions: {
show: {
source: ['localFile'],
},
},
default: '',
placeholder: '/data/workflow.json',
required: true,
description: 'The path to local JSON workflow file to execute',
},
// ----------------------------------
// source:parameter
// ----------------------------------
{
displayName: 'Workflow JSON',
name: 'workflowJson',
type: 'json',
typeOptions: {
rows: 10,
},
displayOptions: {
show: {
source: ['parameter'],
},
},
default: '\n\n\n',
required: true,
description: 'The workflow JSON code to execute',
},
// ----------------------------------
// source:url
// ----------------------------------
{
displayName: 'Workflow URL',
name: 'workflowUrl',
type: 'string',
displayOptions: {
show: {
source: ['url'],
},
},
default: '',
placeholder: 'https://example.com/workflow.json',
required: true,
description: 'The URL from which to load the workflow from',
},
{
displayName:
'Any data you pass into this node will be output by the Execute Workflow Trigger. <a href="https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.executeworkflow/" target="_blank">More info</a>',
name: 'executeWorkflowNotice',
type: 'notice',
default: '',
displayOptions: { show: { '@version': [{ _cnd: { lte: 1.1 } }] } },
},
{
displayName: 'Workflow Inputs',
name: 'workflowInputs',
type: 'resourceMapper',
noDataExpression: true,
default: {
mappingMode: 'defineBelow',
value: null,
},
required: true,
typeOptions: {
loadOptionsDependsOn: ['workflowId.value'],
resourceMapper: {
localResourceMapperMethod: 'loadWorkflowInputMappings',
valuesLabel: 'Workflow Inputs',
mode: 'map',
fieldWords: {
singular: 'input',
plural: 'inputs',
},
addAllFields: true,
multiKeyMatch: false,
supportAutoMap: false,
showTypeConversionOptions: true,
},
},
displayOptions: {
show: {
source: ['database'],
'@version': [{ _cnd: { gte: 1.2 } }],
},
hide: {
workflowId: [''],
},
},
},
{
displayName: 'Mode',
name: 'mode',
type: 'options',
noDataExpression: true,
options: [
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'Run once with all items',
value: 'once',
description: 'Pass all items into a single execution of the sub-workflow',
},
{
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
name: 'Run once for each item',
value: 'each',
description: 'Call the sub-workflow individually for each item',
},
],
default: 'once',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
default: {},
placeholder: 'Add option',
options: [
{
displayName: 'Wait For Sub-Workflow Completion',
name: 'waitForSubWorkflow',
type: 'boolean',
default: true,
description:
'Whether the main workflow should wait for the sub-workflow to complete its execution before proceeding',
},
],
},
],
};
methods = {
localResourceMapping: {
loadWorkflowInputMappings,
},
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const source = this.getNodeParameter('source', 0) as string;
const mode = this.getNodeParameter('mode', 0, false) as string;
const items = getCurrentWorkflowInputData.call(this);
const workflowProxy = this.getWorkflowDataProxy(0);
const currentWorkflowId = workflowProxy.$workflow.id as string;
if (mode === 'each') {
const returnData: INodeExecutionData[][] = [];
for (let i = 0; i < items.length; i++) {
try {
const waitForSubWorkflow = this.getNodeParameter(
'options.waitForSubWorkflow',
i,
true,
) as boolean;
const workflowInfo = await getWorkflowInfo.call(this, source, i);
if (waitForSubWorkflow) {
const executionResult: ExecuteWorkflowData = await this.executeWorkflow(
workflowInfo,
[items[i]],
undefined,
{
parentExecution: {
executionId: workflowProxy.$execution.id,
workflowId: workflowProxy.$workflow.id,
},
},
);
const workflowResult = executionResult.data as INodeExecutionData[][];
for (const [outputIndex, outputData] of workflowResult.entries()) {
for (const item of outputData) {
item.pairedItem = { item: i };
item.metadata = {
subExecution: {
executionId: executionResult.executionId,
workflowId: workflowInfo.id ?? currentWorkflowId,
},
};
}
if (returnData[outputIndex] === undefined) {
returnData[outputIndex] = [];
}
returnData[outputIndex].push(...outputData);
}
} else {
const executionResult: ExecuteWorkflowData = await this.executeWorkflow(
workflowInfo,
[items[i]],
undefined,
{
doNotWaitToFinish: true,
parentExecution: {
executionId: workflowProxy.$execution.id,
workflowId: workflowProxy.$workflow.id,
},
},
);
if (returnData.length === 0) {
returnData.push([]);
}
returnData[0].push({
...items[i],
metadata: {
subExecution: {
workflowId: workflowInfo.id ?? currentWorkflowId,
executionId: executionResult.executionId,
},
},
});
}
} catch (error) {
if (this.continueOnFail()) {
if (returnData[i] === undefined) {
returnData[i] = [];
}
returnData[i].push({ json: { error: error.message }, pairedItem: { item: i } });
continue;
}
throw new NodeOperationError(this.getNode(), error, {
message: `Error executing workflow with item at index ${i}`,
description: error.message,
itemIndex: i,
});
}
}
this.setMetadata({
subExecutionsCount: items.length,
});
return returnData;
} else {
try {
const waitForSubWorkflow = this.getNodeParameter(
'options.waitForSubWorkflow',
0,
true,
) as boolean;
const workflowInfo = await getWorkflowInfo.call(this, source);
const executionResult: ExecuteWorkflowData = await this.executeWorkflow(
workflowInfo,
items,
undefined,
{
doNotWaitToFinish: !waitForSubWorkflow,
parentExecution: {
executionId: workflowProxy.$execution.id,
workflowId: workflowProxy.$workflow.id,
},
},
);
this.setMetadata({
subExecution: {
executionId: executionResult.executionId,
workflowId: workflowInfo.id ?? (workflowProxy.$workflow.id as string),
},
subExecutionsCount: 1,
});
if (!waitForSubWorkflow) {
return [items];
}
const workflowResult = executionResult.data as INodeExecutionData[][];
const fallbackPairedItemData = generatePairedItemData(items.length);
for (const output of workflowResult) {
const sameLength = output.length === items.length;
for (const [itemIndex, item] of output.entries()) {
if (item.pairedItem) continue;
if (sameLength) {
item.pairedItem = { item: itemIndex };
} else {
item.pairedItem = fallbackPairedItemData;
}
}
}
return workflowResult;
} catch (error) {
const pairedItem = generatePairedItemData(items.length);
if (this.continueOnFail()) {
return [[{ json: { error: error.message }, pairedItem }]];
}
throw error;
}
}
}
}

View File

@@ -0,0 +1,72 @@
import { readFile as fsReadFile } from 'fs/promises';
import { NodeOperationError, jsonParse } from 'n8n-workflow';
import type {
IExecuteFunctions,
IExecuteWorkflowInfo,
ILoadOptionsFunctions,
INodeParameterResourceLocator,
IRequestOptions,
} from 'n8n-workflow';
export async function getWorkflowInfo(
this: ILoadOptionsFunctions | IExecuteFunctions,
source: string,
itemIndex = 0,
) {
const workflowInfo: IExecuteWorkflowInfo = {};
const nodeVersion = this.getNode().typeVersion;
if (source === 'database') {
// Read workflow from database
if (nodeVersion === 1) {
workflowInfo.id = this.getNodeParameter('workflowId', itemIndex) as string;
} else {
const { value } = this.getNodeParameter(
'workflowId',
itemIndex,
{},
) as INodeParameterResourceLocator;
workflowInfo.id = value as string;
}
} else if (source === 'localFile') {
// Read workflow from filesystem
const workflowPath = this.getNodeParameter('workflowPath', itemIndex) as string;
let workflowJson;
try {
workflowJson = await fsReadFile(workflowPath, { encoding: 'utf8' });
} catch (error) {
if (error.code === 'ENOENT') {
throw new NodeOperationError(
this.getNode(),
`The file "${workflowPath}" could not be found, [item ${itemIndex}]`,
);
}
throw error;
}
workflowInfo.code = jsonParse(workflowJson);
} else if (source === 'parameter') {
// Read workflow from parameter
const workflowJson = this.getNodeParameter('workflowJson', itemIndex) as string;
workflowInfo.code = jsonParse(workflowJson);
} else if (source === 'url') {
// Read workflow from url
const workflowUrl = this.getNodeParameter('workflowUrl', itemIndex) as string;
const requestOptions = {
headers: {
accept: 'application/json,text/*;q=0.99',
},
method: 'GET',
uri: workflowUrl,
json: true,
gzip: true,
} satisfies IRequestOptions;
const response = await this.helpers.request(requestOptions);
workflowInfo.code = response;
}
return workflowInfo;
}