mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
refactor(core): Break up more code in the execution engine, and add tests (no-changelog) (#12320)
This commit is contained in:
parent
2f21404987
commit
a8dd35b0f0
|
@ -31,7 +31,6 @@ import pick from 'lodash/pick';
|
||||||
import { extension, lookup } from 'mime-types';
|
import { extension, lookup } from 'mime-types';
|
||||||
import type {
|
import type {
|
||||||
BinaryHelperFunctions,
|
BinaryHelperFunctions,
|
||||||
CloseFunction,
|
|
||||||
FileSystemHelperFunctions,
|
FileSystemHelperFunctions,
|
||||||
GenericValue,
|
GenericValue,
|
||||||
IAdditionalCredentialOptions,
|
IAdditionalCredentialOptions,
|
||||||
|
@ -47,7 +46,6 @@ import type {
|
||||||
IN8nHttpResponse,
|
IN8nHttpResponse,
|
||||||
INode,
|
INode,
|
||||||
INodeExecutionData,
|
INodeExecutionData,
|
||||||
INodeInputConfiguration,
|
|
||||||
IOAuth2Options,
|
IOAuth2Options,
|
||||||
IPairedItemData,
|
IPairedItemData,
|
||||||
IPollFunctions,
|
IPollFunctions,
|
||||||
|
@ -76,11 +74,8 @@ import type {
|
||||||
ICheckProcessedContextData,
|
ICheckProcessedContextData,
|
||||||
WebhookType,
|
WebhookType,
|
||||||
SchedulingFunctions,
|
SchedulingFunctions,
|
||||||
SupplyData,
|
|
||||||
AINodeConnectionType,
|
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import {
|
import {
|
||||||
NodeConnectionType,
|
|
||||||
LoggerProxy as Logger,
|
LoggerProxy as Logger,
|
||||||
NodeApiError,
|
NodeApiError,
|
||||||
NodeHelpers,
|
NodeHelpers,
|
||||||
|
@ -114,12 +109,11 @@ import {
|
||||||
UM_EMAIL_TEMPLATES_INVITE,
|
UM_EMAIL_TEMPLATES_INVITE,
|
||||||
UM_EMAIL_TEMPLATES_PWRESET,
|
UM_EMAIL_TEMPLATES_PWRESET,
|
||||||
} from './Constants';
|
} from './Constants';
|
||||||
import { createNodeAsTool } from './CreateNodeAsTool';
|
|
||||||
import { DataDeduplicationService } from './data-deduplication-service';
|
import { DataDeduplicationService } from './data-deduplication-service';
|
||||||
import { InstanceSettings } from './InstanceSettings';
|
import { InstanceSettings } from './InstanceSettings';
|
||||||
import type { IResponseError } from './Interfaces';
|
import type { IResponseError } from './Interfaces';
|
||||||
// eslint-disable-next-line import/no-cycle
|
// eslint-disable-next-line import/no-cycle
|
||||||
import { PollContext, SupplyDataContext, TriggerContext } from './node-execution-context';
|
import { PollContext, TriggerContext } from './node-execution-context';
|
||||||
import { ScheduledTaskManager } from './ScheduledTaskManager';
|
import { ScheduledTaskManager } from './ScheduledTaskManager';
|
||||||
import { SSHClientsManager } from './SSHClientsManager';
|
import { SSHClientsManager } from './SSHClientsManager';
|
||||||
|
|
||||||
|
@ -2013,185 +2007,6 @@ export function getWebhookDescription(
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getInputConnectionData(
|
|
||||||
this: IAllExecuteFunctions,
|
|
||||||
workflow: Workflow,
|
|
||||||
runExecutionData: IRunExecutionData,
|
|
||||||
parentRunIndex: number,
|
|
||||||
connectionInputData: INodeExecutionData[],
|
|
||||||
parentInputData: ITaskDataConnections,
|
|
||||||
additionalData: IWorkflowExecuteAdditionalData,
|
|
||||||
executeData: IExecuteData,
|
|
||||||
mode: WorkflowExecuteMode,
|
|
||||||
closeFunctions: CloseFunction[],
|
|
||||||
connectionType: AINodeConnectionType,
|
|
||||||
itemIndex: number,
|
|
||||||
abortSignal?: AbortSignal,
|
|
||||||
): Promise<unknown> {
|
|
||||||
const parentNode = this.getNode();
|
|
||||||
const parentNodeType = workflow.nodeTypes.getByNameAndVersion(
|
|
||||||
parentNode.type,
|
|
||||||
parentNode.typeVersion,
|
|
||||||
);
|
|
||||||
|
|
||||||
const inputs = NodeHelpers.getNodeInputs(workflow, parentNode, parentNodeType.description);
|
|
||||||
|
|
||||||
let inputConfiguration = inputs.find((input) => {
|
|
||||||
if (typeof input === 'string') {
|
|
||||||
return input === connectionType;
|
|
||||||
}
|
|
||||||
return input.type === connectionType;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (inputConfiguration === undefined) {
|
|
||||||
throw new ApplicationError('Node does not have input of type', {
|
|
||||||
extra: { nodeName: parentNode.name, connectionType },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof inputConfiguration === 'string') {
|
|
||||||
inputConfiguration = {
|
|
||||||
type: inputConfiguration,
|
|
||||||
} as INodeInputConfiguration;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectedNodes = workflow
|
|
||||||
.getParentNodes(parentNode.name, connectionType, 1)
|
|
||||||
.map((nodeName) => workflow.getNode(nodeName) as INode)
|
|
||||||
.filter((connectedNode) => connectedNode.disabled !== true);
|
|
||||||
|
|
||||||
if (connectedNodes.length === 0) {
|
|
||||||
if (inputConfiguration.required) {
|
|
||||||
throw new NodeOperationError(
|
|
||||||
parentNode,
|
|
||||||
`A ${inputConfiguration?.displayName ?? connectionType} sub-node must be connected and enabled`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return inputConfiguration.maxConnections === 1 ? undefined : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
inputConfiguration.maxConnections !== undefined &&
|
|
||||||
connectedNodes.length > inputConfiguration.maxConnections
|
|
||||||
) {
|
|
||||||
throw new NodeOperationError(
|
|
||||||
parentNode,
|
|
||||||
`Only ${inputConfiguration.maxConnections} ${connectionType} sub-nodes are/is allowed to be connected`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodes: SupplyData[] = [];
|
|
||||||
for (const connectedNode of connectedNodes) {
|
|
||||||
const connectedNodeType = workflow.nodeTypes.getByNameAndVersion(
|
|
||||||
connectedNode.type,
|
|
||||||
connectedNode.typeVersion,
|
|
||||||
);
|
|
||||||
const contextFactory = (runIndex: number, inputData: ITaskDataConnections) =>
|
|
||||||
new SupplyDataContext(
|
|
||||||
workflow,
|
|
||||||
connectedNode,
|
|
||||||
additionalData,
|
|
||||||
mode,
|
|
||||||
runExecutionData,
|
|
||||||
runIndex,
|
|
||||||
connectionInputData,
|
|
||||||
inputData,
|
|
||||||
connectionType,
|
|
||||||
executeData,
|
|
||||||
closeFunctions,
|
|
||||||
abortSignal,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!connectedNodeType.supplyData) {
|
|
||||||
if (connectedNodeType.description.outputs.includes(NodeConnectionType.AiTool)) {
|
|
||||||
/**
|
|
||||||
* This keeps track of how many times this specific AI tool node has been invoked.
|
|
||||||
* It is incremented on every invocation of the tool to keep the output of each invocation separate from each other.
|
|
||||||
*/
|
|
||||||
let toolRunIndex = 0;
|
|
||||||
const supplyData = createNodeAsTool({
|
|
||||||
node: connectedNode,
|
|
||||||
nodeType: connectedNodeType,
|
|
||||||
handleToolInvocation: async (toolArgs) => {
|
|
||||||
const runIndex = toolRunIndex++;
|
|
||||||
const context = contextFactory(runIndex, {});
|
|
||||||
context.addInputData(NodeConnectionType.AiTool, [[{ json: toolArgs }]]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Execute the sub-node with the proxied context
|
|
||||||
const result = await connectedNodeType.execute?.call(
|
|
||||||
context as unknown as IExecuteFunctions,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Process and map the results
|
|
||||||
const mappedResults = result?.[0]?.flatMap((item) => item.json);
|
|
||||||
|
|
||||||
// Add output data to the context
|
|
||||||
context.addOutputData(NodeConnectionType.AiTool, runIndex, [
|
|
||||||
[{ json: { response: mappedResults } }],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Return the stringified results
|
|
||||||
return JSON.stringify(mappedResults);
|
|
||||||
} catch (error) {
|
|
||||||
const nodeError = new NodeOperationError(connectedNode, error as Error);
|
|
||||||
context.addOutputData(NodeConnectionType.AiTool, runIndex, nodeError);
|
|
||||||
return 'Error during node execution: ' + nodeError.description;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
nodes.push(supplyData);
|
|
||||||
} else {
|
|
||||||
throw new ApplicationError('Node does not have a `supplyData` method defined', {
|
|
||||||
extra: { nodeName: connectedNode.name },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const context = contextFactory(parentRunIndex, parentInputData);
|
|
||||||
try {
|
|
||||||
const supplyData = await connectedNodeType.supplyData.call(context, itemIndex);
|
|
||||||
if (supplyData.closeFunction) {
|
|
||||||
closeFunctions.push(supplyData.closeFunction);
|
|
||||||
}
|
|
||||||
nodes.push(supplyData);
|
|
||||||
} catch (error) {
|
|
||||||
// Propagate errors from sub-nodes
|
|
||||||
if (error.functionality === 'configuration-node') throw error;
|
|
||||||
if (!(error instanceof ExecutionBaseError)) {
|
|
||||||
error = new NodeOperationError(connectedNode, error, {
|
|
||||||
itemIndex,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentNodeRunIndex = 0;
|
|
||||||
if (runExecutionData.resultData.runData.hasOwnProperty(parentNode.name)) {
|
|
||||||
currentNodeRunIndex = runExecutionData.resultData.runData[parentNode.name].length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display the error on the node which is causing it
|
|
||||||
await context.addExecutionDataFunctions(
|
|
||||||
'input',
|
|
||||||
error,
|
|
||||||
connectionType,
|
|
||||||
parentNode.name,
|
|
||||||
currentNodeRunIndex,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Display on the calling node which node has the error
|
|
||||||
throw new NodeOperationError(connectedNode, `Error in sub-node ${connectedNode.name}`, {
|
|
||||||
itemIndex,
|
|
||||||
functionality: 'configuration-node',
|
|
||||||
description: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return inputConfiguration.maxConnections === 1
|
|
||||||
? (nodes || [])[0]?.response
|
|
||||||
: nodes.map((node) => node.response);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getRequestHelperFunctions = (
|
export const getRequestHelperFunctions = (
|
||||||
workflow: Workflow,
|
workflow: Workflow,
|
||||||
node: INode,
|
node: INode,
|
||||||
|
@ -2254,7 +2069,7 @@ export const getRequestHelperFunctions = (
|
||||||
|
|
||||||
const runIndex = 0;
|
const runIndex = 0;
|
||||||
|
|
||||||
const additionalKeys = {
|
const additionalKeys: IWorkflowDataProxyAdditionalKeys = {
|
||||||
$request: requestOptions,
|
$request: requestOptions,
|
||||||
$response: {} as IN8nHttpFullResponse,
|
$response: {} as IN8nHttpFullResponse,
|
||||||
$version: node.typeVersion,
|
$version: node.typeVersion,
|
||||||
|
@ -2379,7 +2194,7 @@ export const getRequestHelperFunctions = (
|
||||||
responseData.push(tempResponseData);
|
responseData.push(tempResponseData);
|
||||||
|
|
||||||
additionalKeys.$response = newResponse;
|
additionalKeys.$response = newResponse;
|
||||||
additionalKeys.$pageCount = additionalKeys.$pageCount + 1;
|
additionalKeys.$pageCount = (additionalKeys.$pageCount ?? 0) + 1;
|
||||||
|
|
||||||
const maxRequests = getResolvedValue(
|
const maxRequests = getResolvedValue(
|
||||||
paginationOptions.maxRequests,
|
paginationOptions.maxRequests,
|
||||||
|
|
|
@ -1,11 +1,15 @@
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
import type {
|
import type {
|
||||||
|
Expression,
|
||||||
INode,
|
INode,
|
||||||
|
INodeType,
|
||||||
|
INodeTypes,
|
||||||
INodeExecutionData,
|
INodeExecutionData,
|
||||||
IWorkflowExecuteAdditionalData,
|
IWorkflowExecuteAdditionalData,
|
||||||
Workflow,
|
Workflow,
|
||||||
WorkflowExecuteMode,
|
WorkflowExecuteMode,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
|
|
||||||
import { InstanceSettings } from '@/InstanceSettings';
|
import { InstanceSettings } from '@/InstanceSettings';
|
||||||
|
@ -18,23 +22,29 @@ describe('NodeExecutionContext', () => {
|
||||||
const instanceSettings = mock<InstanceSettings>({ instanceId: 'abc123' });
|
const instanceSettings = mock<InstanceSettings>({ instanceId: 'abc123' });
|
||||||
Container.set(InstanceSettings, instanceSettings);
|
Container.set(InstanceSettings, instanceSettings);
|
||||||
|
|
||||||
|
const node = mock<INode>();
|
||||||
|
const nodeType = mock<INodeType>({ description: mock() });
|
||||||
|
const nodeTypes = mock<INodeTypes>();
|
||||||
|
const expression = mock<Expression>();
|
||||||
const workflow = mock<Workflow>({
|
const workflow = mock<Workflow>({
|
||||||
id: '123',
|
id: '123',
|
||||||
name: 'Test Workflow',
|
name: 'Test Workflow',
|
||||||
active: true,
|
active: true,
|
||||||
nodeTypes: mock(),
|
nodeTypes,
|
||||||
timezone: 'UTC',
|
timezone: 'UTC',
|
||||||
|
expression,
|
||||||
});
|
});
|
||||||
const node = mock<INode>();
|
|
||||||
let additionalData = mock<IWorkflowExecuteAdditionalData>({
|
let additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||||
credentialsHelper: mock(),
|
credentialsHelper: mock(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mode: WorkflowExecuteMode = 'manual';
|
const mode: WorkflowExecuteMode = 'manual';
|
||||||
const testContext = new TestContext(workflow, node, additionalData, mode);
|
let testContext: TestContext;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
testContext = new TestContext(workflow, node, additionalData, mode);
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getNode', () => {
|
describe('getNode', () => {
|
||||||
|
@ -106,9 +116,9 @@ describe('NodeExecutionContext', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getKnownNodeTypes', () => {
|
describe('getKnownNodeTypes', () => {
|
||||||
it('should call getKnownTypes method of workflow.nodeTypes', () => {
|
it('should call getKnownTypes method of nodeTypes', () => {
|
||||||
testContext.getKnownNodeTypes();
|
testContext.getKnownNodeTypes();
|
||||||
expect(workflow.nodeTypes.getKnownTypes).toHaveBeenCalled();
|
expect(nodeTypes.getKnownTypes).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -165,4 +175,164 @@ describe('NodeExecutionContext', () => {
|
||||||
expect(result).toEqual([outputData]);
|
expect(result).toEqual([outputData]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getNodeInputs', () => {
|
||||||
|
it('should return static inputs array when inputs is an array', () => {
|
||||||
|
nodeType.description.inputs = [NodeConnectionType.Main, NodeConnectionType.AiLanguageModel];
|
||||||
|
|
||||||
|
const result = testContext.getNodeInputs();
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ type: NodeConnectionType.Main },
|
||||||
|
{ type: NodeConnectionType.AiLanguageModel },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return input objects when inputs contains configurations', () => {
|
||||||
|
nodeType.description.inputs = [
|
||||||
|
{ type: NodeConnectionType.Main },
|
||||||
|
{ type: NodeConnectionType.AiLanguageModel, required: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = testContext.getNodeInputs();
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ type: NodeConnectionType.Main },
|
||||||
|
{ type: NodeConnectionType.AiLanguageModel, required: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should evaluate dynamic inputs when inputs is a function', () => {
|
||||||
|
const inputsExpressions = '={{ ["main", "ai_languageModel"] }}';
|
||||||
|
nodeType.description.inputs = inputsExpressions;
|
||||||
|
expression.getSimpleParameterValue.mockReturnValue([
|
||||||
|
NodeConnectionType.Main,
|
||||||
|
NodeConnectionType.AiLanguageModel,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = testContext.getNodeInputs();
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ type: NodeConnectionType.Main },
|
||||||
|
{ type: NodeConnectionType.AiLanguageModel },
|
||||||
|
]);
|
||||||
|
expect(expression.getSimpleParameterValue).toHaveBeenCalledWith(
|
||||||
|
node,
|
||||||
|
inputsExpressions,
|
||||||
|
'internal',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNodeOutputs', () => {
|
||||||
|
it('should return static outputs array when outputs is an array', () => {
|
||||||
|
nodeType.description.outputs = [NodeConnectionType.Main, NodeConnectionType.AiLanguageModel];
|
||||||
|
|
||||||
|
const result = testContext.getNodeOutputs();
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ type: NodeConnectionType.Main },
|
||||||
|
{ type: NodeConnectionType.AiLanguageModel },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return output objects when outputs contains configurations', () => {
|
||||||
|
nodeType.description.outputs = [
|
||||||
|
{ type: NodeConnectionType.Main },
|
||||||
|
{ type: NodeConnectionType.AiLanguageModel, required: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = testContext.getNodeOutputs();
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ type: NodeConnectionType.Main },
|
||||||
|
{ type: NodeConnectionType.AiLanguageModel, required: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should evaluate dynamic outputs when outputs is a function', () => {
|
||||||
|
const outputsExpressions = '={{ ["main", "ai_languageModel"] }}';
|
||||||
|
nodeType.description.outputs = outputsExpressions;
|
||||||
|
expression.getSimpleParameterValue.mockReturnValue([
|
||||||
|
NodeConnectionType.Main,
|
||||||
|
NodeConnectionType.AiLanguageModel,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = testContext.getNodeOutputs();
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ type: NodeConnectionType.Main },
|
||||||
|
{ type: NodeConnectionType.AiLanguageModel },
|
||||||
|
]);
|
||||||
|
expect(expression.getSimpleParameterValue).toHaveBeenCalledWith(
|
||||||
|
node,
|
||||||
|
outputsExpressions,
|
||||||
|
'internal',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add error output when node has continueOnFail error handling', () => {
|
||||||
|
const nodeWithError = mock<INode>({ onError: 'continueErrorOutput' });
|
||||||
|
const contextWithError = new TestContext(workflow, nodeWithError, additionalData, mode);
|
||||||
|
nodeType.description.outputs = [NodeConnectionType.Main];
|
||||||
|
|
||||||
|
const result = contextWithError.getNodeOutputs();
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ type: NodeConnectionType.Main, displayName: 'Success' },
|
||||||
|
{ type: NodeConnectionType.Main, displayName: 'Error', category: 'error' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getConnectedNodes', () => {
|
||||||
|
it('should return connected nodes of given type', () => {
|
||||||
|
const node1 = mock<INode>({ name: 'Node 1', type: 'test', disabled: false });
|
||||||
|
const node2 = mock<INode>({ name: 'Node 2', type: 'test', disabled: false });
|
||||||
|
|
||||||
|
workflow.getParentNodes.mockReturnValue(['Node 1', 'Node 2']);
|
||||||
|
workflow.getNode.mockImplementation((name) => {
|
||||||
|
if (name === 'Node 1') return node1;
|
||||||
|
if (name === 'Node 2') return node2;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = testContext.getConnectedNodes(NodeConnectionType.Main);
|
||||||
|
|
||||||
|
expect(result).toEqual([node1, node2]);
|
||||||
|
expect(workflow.getParentNodes).toHaveBeenCalledWith(node.name, NodeConnectionType.Main, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out disabled nodes', () => {
|
||||||
|
const node1 = mock<INode>({ name: 'Node 1', type: 'test', disabled: false });
|
||||||
|
const node2 = mock<INode>({ name: 'Node 2', type: 'test', disabled: true });
|
||||||
|
|
||||||
|
workflow.getParentNodes.mockReturnValue(['Node 1', 'Node 2']);
|
||||||
|
workflow.getNode.mockImplementation((name) => {
|
||||||
|
if (name === 'Node 1') return node1;
|
||||||
|
if (name === 'Node 2') return node2;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = testContext.getConnectedNodes(NodeConnectionType.Main);
|
||||||
|
|
||||||
|
expect(result).toEqual([node1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out non-existent nodes', () => {
|
||||||
|
const node1 = mock<INode>({ name: 'Node 1', type: 'test', disabled: false });
|
||||||
|
|
||||||
|
workflow.getParentNodes.mockReturnValue(['Node 1', 'NonExistent']);
|
||||||
|
workflow.getNode.mockImplementation((name) => {
|
||||||
|
if (name === 'Node 1') return node1;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = testContext.getConnectedNodes(NodeConnectionType.Main);
|
||||||
|
|
||||||
|
expect(result).toEqual([node1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -16,8 +16,6 @@ import type {
|
||||||
ITaskMetadata,
|
ITaskMetadata,
|
||||||
ContextType,
|
ContextType,
|
||||||
IContextObject,
|
IContextObject,
|
||||||
INodeInputConfiguration,
|
|
||||||
INodeOutputConfiguration,
|
|
||||||
IWorkflowDataProxyData,
|
IWorkflowDataProxyData,
|
||||||
ISourceData,
|
ISourceData,
|
||||||
AiEvent,
|
AiEvent,
|
||||||
|
@ -161,26 +159,6 @@ export class BaseExecuteContext extends NodeExecutionContext {
|
||||||
return allItems;
|
return allItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
getNodeInputs(): INodeInputConfiguration[] {
|
|
||||||
const nodeType = this.workflow.nodeTypes.getByNameAndVersion(
|
|
||||||
this.node.type,
|
|
||||||
this.node.typeVersion,
|
|
||||||
);
|
|
||||||
return NodeHelpers.getNodeInputs(this.workflow, this.node, nodeType.description).map((input) =>
|
|
||||||
typeof input === 'string' ? { type: input } : input,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getNodeOutputs(): INodeOutputConfiguration[] {
|
|
||||||
const nodeType = this.workflow.nodeTypes.getByNameAndVersion(
|
|
||||||
this.node.type,
|
|
||||||
this.node.typeVersion,
|
|
||||||
);
|
|
||||||
return NodeHelpers.getNodeOutputs(this.workflow, this.node, nodeType.description).map(
|
|
||||||
(output) => (typeof output === 'string' ? { type: output } : output),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getInputSourceData(inputIndex = 0, connectionType = NodeConnectionType.Main): ISourceData {
|
getInputSourceData(inputIndex = 0, connectionType = NodeConnectionType.Main): ISourceData {
|
||||||
if (this.executeData?.source === null) {
|
if (this.executeData?.source === null) {
|
||||||
// Should never happen as n8n sets it automatically
|
// Should never happen as n8n sets it automatically
|
||||||
|
|
|
@ -28,7 +28,6 @@ import {
|
||||||
copyInputItems,
|
copyInputItems,
|
||||||
normalizeItems,
|
normalizeItems,
|
||||||
constructExecutionMetaData,
|
constructExecutionMetaData,
|
||||||
getInputConnectionData,
|
|
||||||
assertBinaryData,
|
assertBinaryData,
|
||||||
getBinaryDataBuffer,
|
getBinaryDataBuffer,
|
||||||
copyBinaryFile,
|
copyBinaryFile,
|
||||||
|
@ -41,6 +40,7 @@ import {
|
||||||
} from '@/NodeExecuteFunctions';
|
} from '@/NodeExecuteFunctions';
|
||||||
|
|
||||||
import { BaseExecuteContext } from './base-execute-context';
|
import { BaseExecuteContext } from './base-execute-context';
|
||||||
|
import { getInputConnectionData } from './utils/getInputConnectionData';
|
||||||
|
|
||||||
export class ExecuteContext extends BaseExecuteContext implements IExecuteFunctions {
|
export class ExecuteContext extends BaseExecuteContext implements IExecuteFunctions {
|
||||||
readonly helpers: IExecuteFunctions['helpers'];
|
readonly helpers: IExecuteFunctions['helpers'];
|
||||||
|
|
|
@ -4,8 +4,9 @@ export { ExecuteSingleContext } from './execute-single-context';
|
||||||
export { HookContext } from './hook-context';
|
export { HookContext } from './hook-context';
|
||||||
export { LoadOptionsContext } from './load-options-context';
|
export { LoadOptionsContext } from './load-options-context';
|
||||||
export { PollContext } from './poll-context';
|
export { PollContext } from './poll-context';
|
||||||
|
// eslint-disable-next-line import/no-cycle
|
||||||
export { SupplyDataContext } from './supply-data-context';
|
export { SupplyDataContext } from './supply-data-context';
|
||||||
export { TriggerContext } from './trigger-context';
|
export { TriggerContext } from './trigger-context';
|
||||||
export { WebhookContext } from './webhook-context';
|
export { WebhookContext } from './webhook-context';
|
||||||
|
|
||||||
export { getAdditionalKeys } from './utils';
|
export { getAdditionalKeys } from './utils/getAdditionalKeys';
|
||||||
|
|
|
@ -9,8 +9,11 @@ import type {
|
||||||
INodeCredentialDescription,
|
INodeCredentialDescription,
|
||||||
INodeCredentialsDetails,
|
INodeCredentialsDetails,
|
||||||
INodeExecutionData,
|
INodeExecutionData,
|
||||||
|
INodeInputConfiguration,
|
||||||
|
INodeOutputConfiguration,
|
||||||
IRunExecutionData,
|
IRunExecutionData,
|
||||||
IWorkflowExecuteAdditionalData,
|
IWorkflowExecuteAdditionalData,
|
||||||
|
NodeConnectionType,
|
||||||
NodeParameterValueType,
|
NodeParameterValueType,
|
||||||
NodeTypeAndVersion,
|
NodeTypeAndVersion,
|
||||||
Workflow,
|
Workflow,
|
||||||
|
@ -27,15 +30,14 @@ import {
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
|
|
||||||
import { HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_TOOL_NODE_TYPE } from '@/Constants';
|
import { HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_TOOL_NODE_TYPE } from '@/Constants';
|
||||||
|
import { Memoized } from '@/decorators';
|
||||||
import { extractValue } from '@/ExtractValue';
|
import { extractValue } from '@/ExtractValue';
|
||||||
import { InstanceSettings } from '@/InstanceSettings';
|
import { InstanceSettings } from '@/InstanceSettings';
|
||||||
|
|
||||||
import {
|
import { cleanupParameterData } from './utils/cleanupParameterData';
|
||||||
cleanupParameterData,
|
import { ensureType } from './utils/ensureType';
|
||||||
ensureType,
|
import { getAdditionalKeys } from './utils/getAdditionalKeys';
|
||||||
getAdditionalKeys,
|
import { validateValueAgainstSchema } from './utils/validateValueAgainstSchema';
|
||||||
validateValueAgainstSchema,
|
|
||||||
} from './utils';
|
|
||||||
|
|
||||||
export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCredentials'> {
|
export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCredentials'> {
|
||||||
protected readonly instanceSettings = Container.get(InstanceSettings);
|
protected readonly instanceSettings = Container.get(InstanceSettings);
|
||||||
|
@ -108,6 +110,42 @@ export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCr
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Memoized
|
||||||
|
get nodeType() {
|
||||||
|
const { type, typeVersion } = this.node;
|
||||||
|
return this.workflow.nodeTypes.getByNameAndVersion(type, typeVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Memoized
|
||||||
|
get nodeInputs() {
|
||||||
|
return NodeHelpers.getNodeInputs(this.workflow, this.node, this.nodeType.description).map(
|
||||||
|
(input) => (typeof input === 'string' ? { type: input } : input),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getNodeInputs(): INodeInputConfiguration[] {
|
||||||
|
return this.nodeInputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Memoized
|
||||||
|
get nodeOutputs() {
|
||||||
|
return NodeHelpers.getNodeOutputs(this.workflow, this.node, this.nodeType.description).map(
|
||||||
|
(output) => (typeof output === 'string' ? { type: output } : output),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getConnectedNodes(connectionType: NodeConnectionType): INode[] {
|
||||||
|
return this.workflow
|
||||||
|
.getParentNodes(this.node.name, connectionType, 1)
|
||||||
|
.map((nodeName) => this.workflow.getNode(nodeName))
|
||||||
|
.filter((node) => !!node)
|
||||||
|
.filter((node) => node.disabled !== true);
|
||||||
|
}
|
||||||
|
|
||||||
|
getNodeOutputs(): INodeOutputConfiguration[] {
|
||||||
|
return this.nodeOutputs;
|
||||||
|
}
|
||||||
|
|
||||||
getKnownNodeTypes() {
|
getKnownNodeTypes() {
|
||||||
return this.workflow.nodeTypes.getKnownTypes();
|
return this.workflow.nodeTypes.getKnownTypes();
|
||||||
}
|
}
|
||||||
|
@ -260,6 +298,7 @@ export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCr
|
||||||
return decryptedDataObject as T;
|
return decryptedDataObject as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Memoized
|
||||||
protected get additionalKeys() {
|
protected get additionalKeys() {
|
||||||
return getAdditionalKeys(this.additionalData, this.mode, this.runExecutionData);
|
return getAdditionalKeys(this.additionalData, this.mode, this.runExecutionData);
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,10 +33,10 @@ import {
|
||||||
getSSHTunnelFunctions,
|
getSSHTunnelFunctions,
|
||||||
normalizeItems,
|
normalizeItems,
|
||||||
returnJsonArray,
|
returnJsonArray,
|
||||||
getInputConnectionData,
|
|
||||||
} from '@/NodeExecuteFunctions';
|
} from '@/NodeExecuteFunctions';
|
||||||
|
|
||||||
import { BaseExecuteContext } from './base-execute-context';
|
import { BaseExecuteContext } from './base-execute-context';
|
||||||
|
import { getInputConnectionData } from './utils/getInputConnectionData';
|
||||||
|
|
||||||
export class SupplyDataContext extends BaseExecuteContext implements ISupplyDataFunctions {
|
export class SupplyDataContext extends BaseExecuteContext implements ISupplyDataFunctions {
|
||||||
readonly helpers: ISupplyDataFunctions['helpers'];
|
readonly helpers: ISupplyDataFunctions['helpers'];
|
||||||
|
|
|
@ -1,423 +0,0 @@
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import type {
|
|
||||||
EnsureTypeOptions,
|
|
||||||
FieldType,
|
|
||||||
IDataObject,
|
|
||||||
INode,
|
|
||||||
INodeParameters,
|
|
||||||
INodeProperties,
|
|
||||||
INodePropertyCollection,
|
|
||||||
INodePropertyOptions,
|
|
||||||
INodeType,
|
|
||||||
IRunExecutionData,
|
|
||||||
IWorkflowDataProxyAdditionalKeys,
|
|
||||||
IWorkflowExecuteAdditionalData,
|
|
||||||
NodeParameterValueType,
|
|
||||||
WorkflowExecuteMode,
|
|
||||||
} from 'n8n-workflow';
|
|
||||||
import {
|
|
||||||
ExpressionError,
|
|
||||||
isResourceMapperValue,
|
|
||||||
LoggerProxy,
|
|
||||||
NodeHelpers,
|
|
||||||
validateFieldType,
|
|
||||||
} from 'n8n-workflow';
|
|
||||||
|
|
||||||
import { PLACEHOLDER_EMPTY_EXECUTION_ID } from '@/Constants';
|
|
||||||
import {
|
|
||||||
setWorkflowExecutionMetadata,
|
|
||||||
setAllWorkflowExecutionMetadata,
|
|
||||||
getWorkflowExecutionMetadata,
|
|
||||||
getAllWorkflowExecutionMetadata,
|
|
||||||
} from '@/ExecutionMetadata';
|
|
||||||
import type { ExtendedValidationResult } from '@/Interfaces';
|
|
||||||
import { getSecretsProxy } from '@/Secrets';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up parameter data to make sure that only valid data gets returned
|
|
||||||
* INFO: Currently only converts Luxon Dates as we know for sure it will not be breaking
|
|
||||||
*/
|
|
||||||
export function cleanupParameterData(inputData: NodeParameterValueType): void {
|
|
||||||
if (typeof inputData !== 'object' || inputData === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(inputData)) {
|
|
||||||
inputData.forEach((value) => cleanupParameterData(value as NodeParameterValueType));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof inputData === 'object') {
|
|
||||||
Object.keys(inputData).forEach((key) => {
|
|
||||||
const value = (inputData as INodeParameters)[key];
|
|
||||||
if (typeof value === 'object') {
|
|
||||||
if (DateTime.isDateTime(value)) {
|
|
||||||
// Is a special luxon date so convert to string
|
|
||||||
(inputData as INodeParameters)[key] = value.toString();
|
|
||||||
} else {
|
|
||||||
cleanupParameterData(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const validateResourceMapperValue = (
|
|
||||||
parameterName: string,
|
|
||||||
paramValues: { [key: string]: unknown },
|
|
||||||
node: INode,
|
|
||||||
skipRequiredCheck = false,
|
|
||||||
): ExtendedValidationResult => {
|
|
||||||
const result: ExtendedValidationResult = { valid: true, newValue: paramValues };
|
|
||||||
const paramNameParts = parameterName.split('.');
|
|
||||||
if (paramNameParts.length !== 2) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
const resourceMapperParamName = paramNameParts[0];
|
|
||||||
const resourceMapperField = node.parameters[resourceMapperParamName];
|
|
||||||
if (!resourceMapperField || !isResourceMapperValue(resourceMapperField)) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
const schema = resourceMapperField.schema;
|
|
||||||
const paramValueNames = Object.keys(paramValues);
|
|
||||||
for (let i = 0; i < paramValueNames.length; i++) {
|
|
||||||
const key = paramValueNames[i];
|
|
||||||
const resolvedValue = paramValues[key];
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
|
|
||||||
const schemaEntry = schema.find((s) => s.id === key);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!skipRequiredCheck &&
|
|
||||||
schemaEntry?.required === true &&
|
|
||||||
schemaEntry.type !== 'boolean' &&
|
|
||||||
!resolvedValue
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
errorMessage: `The value "${String(key)}" is required but not set`,
|
|
||||||
fieldName: key,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
||||||
if (schemaEntry?.type) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
||||||
const validationResult = validateFieldType(key, resolvedValue, schemaEntry.type, {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
|
||||||
valueOptions: schemaEntry.options,
|
|
||||||
});
|
|
||||||
if (!validationResult.valid) {
|
|
||||||
return { ...validationResult, fieldName: key };
|
|
||||||
} else {
|
|
||||||
// If it's valid, set the casted value
|
|
||||||
paramValues[key] = validationResult.newValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateCollection = (
|
|
||||||
node: INode,
|
|
||||||
runIndex: number,
|
|
||||||
itemIndex: number,
|
|
||||||
propertyDescription: INodeProperties,
|
|
||||||
parameterPath: string[],
|
|
||||||
validationResult: ExtendedValidationResult,
|
|
||||||
): ExtendedValidationResult => {
|
|
||||||
let nestedDescriptions: INodeProperties[] | undefined;
|
|
||||||
|
|
||||||
if (propertyDescription.type === 'fixedCollection') {
|
|
||||||
nestedDescriptions = (propertyDescription.options as INodePropertyCollection[]).find(
|
|
||||||
(entry) => entry.name === parameterPath[1],
|
|
||||||
)?.values;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (propertyDescription.type === 'collection') {
|
|
||||||
nestedDescriptions = propertyDescription.options as INodeProperties[];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!nestedDescriptions) {
|
|
||||||
return validationResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
const validationMap: {
|
|
||||||
[key: string]: { type: FieldType; displayName: string; options?: INodePropertyOptions[] };
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
for (const prop of nestedDescriptions) {
|
|
||||||
if (!prop.validateType || prop.ignoreValidationDuringExecution) continue;
|
|
||||||
|
|
||||||
validationMap[prop.name] = {
|
|
||||||
type: prop.validateType,
|
|
||||||
displayName: prop.displayName,
|
|
||||||
options:
|
|
||||||
prop.validateType === 'options' ? (prop.options as INodePropertyOptions[]) : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Object.keys(validationMap).length) {
|
|
||||||
return validationResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validationResult.valid) {
|
|
||||||
for (const value of Array.isArray(validationResult.newValue)
|
|
||||||
? (validationResult.newValue as IDataObject[])
|
|
||||||
: [validationResult.newValue as IDataObject]) {
|
|
||||||
for (const key of Object.keys(value)) {
|
|
||||||
if (!validationMap[key]) continue;
|
|
||||||
|
|
||||||
const fieldValidationResult = validateFieldType(key, value[key], validationMap[key].type, {
|
|
||||||
valueOptions: validationMap[key].options,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!fieldValidationResult.valid) {
|
|
||||||
throw new ExpressionError(
|
|
||||||
`Invalid input for field '${validationMap[key].displayName}' inside '${propertyDescription.displayName}' in [item ${itemIndex}]`,
|
|
||||||
{
|
|
||||||
description: fieldValidationResult.errorMessage,
|
|
||||||
runIndex,
|
|
||||||
itemIndex,
|
|
||||||
nodeCause: node.name,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
||||||
value[key] = fieldValidationResult.newValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return validationResult;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const validateValueAgainstSchema = (
|
|
||||||
node: INode,
|
|
||||||
nodeType: INodeType,
|
|
||||||
parameterValue: string | number | boolean | object | null | undefined,
|
|
||||||
parameterName: string,
|
|
||||||
runIndex: number,
|
|
||||||
itemIndex: number,
|
|
||||||
) => {
|
|
||||||
const parameterPath = parameterName.split('.');
|
|
||||||
|
|
||||||
const propertyDescription = nodeType.description.properties.find(
|
|
||||||
(prop) =>
|
|
||||||
parameterPath[0] === prop.name && NodeHelpers.displayParameter(node.parameters, prop, node),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!propertyDescription) {
|
|
||||||
return parameterValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let validationResult: ExtendedValidationResult = { valid: true, newValue: parameterValue };
|
|
||||||
|
|
||||||
if (
|
|
||||||
parameterPath.length === 1 &&
|
|
||||||
propertyDescription.validateType &&
|
|
||||||
!propertyDescription.ignoreValidationDuringExecution
|
|
||||||
) {
|
|
||||||
validationResult = validateFieldType(
|
|
||||||
parameterName,
|
|
||||||
parameterValue,
|
|
||||||
propertyDescription.validateType,
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
propertyDescription.type === 'resourceMapper' &&
|
|
||||||
parameterPath[1] === 'value' &&
|
|
||||||
typeof parameterValue === 'object'
|
|
||||||
) {
|
|
||||||
validationResult = validateResourceMapperValue(
|
|
||||||
parameterName,
|
|
||||||
parameterValue as { [key: string]: unknown },
|
|
||||||
node,
|
|
||||||
propertyDescription.typeOptions?.resourceMapper?.mode !== 'add',
|
|
||||||
);
|
|
||||||
} else if (['fixedCollection', 'collection'].includes(propertyDescription.type)) {
|
|
||||||
validationResult = validateCollection(
|
|
||||||
node,
|
|
||||||
runIndex,
|
|
||||||
itemIndex,
|
|
||||||
propertyDescription,
|
|
||||||
parameterPath,
|
|
||||||
validationResult,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!validationResult.valid) {
|
|
||||||
throw new ExpressionError(
|
|
||||||
`Invalid input for '${
|
|
||||||
validationResult.fieldName
|
|
||||||
? String(validationResult.fieldName)
|
|
||||||
: propertyDescription.displayName
|
|
||||||
}' [item ${itemIndex}]`,
|
|
||||||
{
|
|
||||||
description: validationResult.errorMessage,
|
|
||||||
runIndex,
|
|
||||||
itemIndex,
|
|
||||||
nodeCause: node.name,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
|
||||||
return validationResult.newValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ensureType(
|
|
||||||
toType: EnsureTypeOptions,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
parameterValue: any,
|
|
||||||
parameterName: string,
|
|
||||||
errorOptions?: { itemIndex?: number; runIndex?: number; nodeCause?: string },
|
|
||||||
): string | number | boolean | object {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
||||||
let returnData = parameterValue;
|
|
||||||
|
|
||||||
if (returnData === null) {
|
|
||||||
throw new ExpressionError(`Parameter '${parameterName}' must not be null`, errorOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (returnData === undefined) {
|
|
||||||
throw new ExpressionError(
|
|
||||||
`Parameter '${parameterName}' could not be 'undefined'`,
|
|
||||||
errorOptions,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (['object', 'array', 'json'].includes(toType)) {
|
|
||||||
if (typeof returnData !== 'object') {
|
|
||||||
// if value is not an object and is string try to parse it, else throw an error
|
|
||||||
if (typeof returnData === 'string' && returnData.length) {
|
|
||||||
try {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
||||||
const parsedValue = JSON.parse(returnData);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
||||||
returnData = parsedValue;
|
|
||||||
} catch (error) {
|
|
||||||
throw new ExpressionError(`Parameter '${parameterName}' could not be parsed`, {
|
|
||||||
...errorOptions,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
|
||||||
description: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new ExpressionError(
|
|
||||||
`Parameter '${parameterName}' must be an ${toType}, but we got '${String(parameterValue)}'`,
|
|
||||||
errorOptions,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (toType === 'json') {
|
|
||||||
// value is an object, make sure it is valid JSON
|
|
||||||
try {
|
|
||||||
JSON.stringify(returnData);
|
|
||||||
} catch (error) {
|
|
||||||
throw new ExpressionError(`Parameter '${parameterName}' is not valid JSON`, {
|
|
||||||
...errorOptions,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
|
||||||
description: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toType === 'array' && !Array.isArray(returnData)) {
|
|
||||||
// value is not an array, but has to be
|
|
||||||
throw new ExpressionError(
|
|
||||||
`Parameter '${parameterName}' must be an array, but we got object`,
|
|
||||||
errorOptions,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (toType === 'string') {
|
|
||||||
if (typeof returnData === 'object') {
|
|
||||||
returnData = JSON.stringify(returnData);
|
|
||||||
} else {
|
|
||||||
returnData = String(returnData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toType === 'number') {
|
|
||||||
returnData = Number(returnData);
|
|
||||||
if (Number.isNaN(returnData)) {
|
|
||||||
throw new ExpressionError(
|
|
||||||
`Parameter '${parameterName}' must be a number, but we got '${parameterValue}'`,
|
|
||||||
errorOptions,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toType === 'boolean') {
|
|
||||||
returnData = Boolean(returnData);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ExpressionError) throw error;
|
|
||||||
|
|
||||||
throw new ExpressionError(`Parameter '${parameterName}' could not be converted to ${toType}`, {
|
|
||||||
...errorOptions,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
|
||||||
description: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
|
||||||
return returnData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the additional keys for Expressions and Function-Nodes */
|
|
||||||
export function getAdditionalKeys(
|
|
||||||
additionalData: IWorkflowExecuteAdditionalData,
|
|
||||||
mode: WorkflowExecuteMode,
|
|
||||||
runExecutionData: IRunExecutionData | null,
|
|
||||||
options?: { secretsEnabled?: boolean },
|
|
||||||
): IWorkflowDataProxyAdditionalKeys {
|
|
||||||
const executionId = additionalData.executionId ?? PLACEHOLDER_EMPTY_EXECUTION_ID;
|
|
||||||
const resumeUrl = `${additionalData.webhookWaitingBaseUrl}/${executionId}`;
|
|
||||||
const resumeFormUrl = `${additionalData.formWaitingBaseUrl}/${executionId}`;
|
|
||||||
return {
|
|
||||||
$execution: {
|
|
||||||
id: executionId,
|
|
||||||
mode: mode === 'manual' ? 'test' : 'production',
|
|
||||||
resumeUrl,
|
|
||||||
resumeFormUrl,
|
|
||||||
customData: runExecutionData
|
|
||||||
? {
|
|
||||||
set(key: string, value: string): void {
|
|
||||||
try {
|
|
||||||
setWorkflowExecutionMetadata(runExecutionData, key, value);
|
|
||||||
} catch (e) {
|
|
||||||
if (mode === 'manual') {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
|
|
||||||
LoggerProxy.debug(e.message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setAll(obj: Record<string, string>): void {
|
|
||||||
try {
|
|
||||||
setAllWorkflowExecutionMetadata(runExecutionData, obj);
|
|
||||||
} catch (e) {
|
|
||||||
if (mode === 'manual') {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
|
|
||||||
LoggerProxy.debug(e.message);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
get(key: string): string {
|
|
||||||
return getWorkflowExecutionMetadata(runExecutionData, key);
|
|
||||||
},
|
|
||||||
getAll(): Record<string, string> {
|
|
||||||
return getAllWorkflowExecutionMetadata(runExecutionData);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
$vars: additionalData.variables,
|
|
||||||
$secrets: options?.secretsEnabled ? getSecretsProxy(additionalData) : undefined,
|
|
||||||
|
|
||||||
// deprecated
|
|
||||||
$executionId: executionId,
|
|
||||||
$resumeWebhookUrl: resumeUrl,
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
import toPlainObject from 'lodash/toPlainObject';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import type { NodeParameterValue } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { cleanupParameterData } from '../cleanupParameterData';
|
||||||
|
|
||||||
|
describe('cleanupParameterData', () => {
|
||||||
|
it('should stringify Luxon dates in-place', () => {
|
||||||
|
const input = { x: 1, y: DateTime.now() as unknown as NodeParameterValue };
|
||||||
|
expect(typeof input.y).toBe('object');
|
||||||
|
cleanupParameterData(input);
|
||||||
|
expect(typeof input.y).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stringify plain Luxon dates in-place', () => {
|
||||||
|
const input = {
|
||||||
|
x: 1,
|
||||||
|
y: toPlainObject(DateTime.now()),
|
||||||
|
};
|
||||||
|
expect(typeof input.y).toBe('object');
|
||||||
|
cleanupParameterData(input);
|
||||||
|
expect(typeof input.y).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle objects with nameless constructors', () => {
|
||||||
|
const input = { x: 1, y: { constructor: {} } as NodeParameterValue };
|
||||||
|
expect(typeof input.y).toBe('object');
|
||||||
|
cleanupParameterData(input);
|
||||||
|
expect(typeof input.y).toBe('object');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle objects without a constructor', () => {
|
||||||
|
const input = { x: 1, y: { constructor: undefined } as unknown as NodeParameterValue };
|
||||||
|
expect(typeof input.y).toBe('object');
|
||||||
|
cleanupParameterData(input);
|
||||||
|
expect(typeof input.y).toBe('object');
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { ExpressionError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { ensureType } from '../ensureType';
|
||||||
|
|
||||||
|
describe('ensureType', () => {
|
||||||
|
it('throws error for null value', () => {
|
||||||
|
expect(() => ensureType('string', null, 'myParam')).toThrowError(
|
||||||
|
new ExpressionError("Parameter 'myParam' must not be null"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for undefined value', () => {
|
||||||
|
expect(() => ensureType('string', undefined, 'myParam')).toThrowError(
|
||||||
|
new ExpressionError("Parameter 'myParam' could not be 'undefined'"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns string value without modification', () => {
|
||||||
|
const value = 'hello';
|
||||||
|
const expectedValue = value;
|
||||||
|
const result = ensureType('string', value, 'myParam');
|
||||||
|
expect(result).toBe(expectedValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns number value without modification', () => {
|
||||||
|
const value = 42;
|
||||||
|
const expectedValue = value;
|
||||||
|
const result = ensureType('number', value, 'myParam');
|
||||||
|
expect(result).toBe(expectedValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns boolean value without modification', () => {
|
||||||
|
const value = true;
|
||||||
|
const expectedValue = value;
|
||||||
|
const result = ensureType('boolean', value, 'myParam');
|
||||||
|
expect(result).toBe(expectedValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts object to string if toType is string', () => {
|
||||||
|
const value = { name: 'John' };
|
||||||
|
const expectedValue = JSON.stringify(value);
|
||||||
|
const result = ensureType('string', value, 'myParam');
|
||||||
|
expect(result).toBe(expectedValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts string to number if toType is number', () => {
|
||||||
|
const value = '10';
|
||||||
|
const expectedValue = 10;
|
||||||
|
const result = ensureType('number', value, 'myParam');
|
||||||
|
expect(result).toBe(expectedValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for invalid conversion to number', () => {
|
||||||
|
const value = 'invalid';
|
||||||
|
expect(() => ensureType('number', value, 'myParam')).toThrowError(
|
||||||
|
new ExpressionError("Parameter 'myParam' must be a number, but we got 'invalid'"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses valid JSON string to object if toType is object', () => {
|
||||||
|
const value = '{"name": "Alice"}';
|
||||||
|
const expectedValue = JSON.parse(value);
|
||||||
|
const result = ensureType('object', value, 'myParam');
|
||||||
|
expect(result).toEqual(expectedValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for invalid JSON string to object conversion', () => {
|
||||||
|
const value = 'invalid_json';
|
||||||
|
expect(() => ensureType('object', value, 'myParam')).toThrowError(
|
||||||
|
new ExpressionError("Parameter 'myParam' could not be parsed"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error for non-array value if toType is array', () => {
|
||||||
|
const value = { name: 'Alice' };
|
||||||
|
expect(() => ensureType('array', value, 'myParam')).toThrowError(
|
||||||
|
new ExpressionError("Parameter 'myParam' must be an array, but we got object"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import { LoggerProxy } from 'n8n-workflow';
|
||||||
|
import type {
|
||||||
|
IDataObject,
|
||||||
|
IRunExecutionData,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
SecretsHelpersBase,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { PLACEHOLDER_EMPTY_EXECUTION_ID } from '@/Constants';
|
||||||
|
|
||||||
|
import { getAdditionalKeys } from '../getAdditionalKeys';
|
||||||
|
|
||||||
|
describe('getAdditionalKeys', () => {
|
||||||
|
const secretsHelpers = mock<SecretsHelpersBase>();
|
||||||
|
const additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||||
|
executionId: '123',
|
||||||
|
webhookWaitingBaseUrl: 'https://webhook.test',
|
||||||
|
formWaitingBaseUrl: 'https://form.test',
|
||||||
|
variables: { testVar: 'value' },
|
||||||
|
secretsHelpers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runExecutionData = mock<IRunExecutionData>({
|
||||||
|
resultData: {
|
||||||
|
runData: {},
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
LoggerProxy.init(mock());
|
||||||
|
secretsHelpers.hasProvider.mockReturnValue(true);
|
||||||
|
secretsHelpers.hasSecret.mockReturnValue(true);
|
||||||
|
secretsHelpers.getSecret.mockReturnValue('secret-value');
|
||||||
|
secretsHelpers.listSecrets.mockReturnValue(['secret1']);
|
||||||
|
secretsHelpers.listProviders.mockReturnValue(['provider1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use placeholder execution ID when none provided', () => {
|
||||||
|
const noIdData = { ...additionalData, executionId: undefined };
|
||||||
|
const result = getAdditionalKeys(noIdData, 'manual', null);
|
||||||
|
|
||||||
|
expect(result.$execution?.id).toBe(PLACEHOLDER_EMPTY_EXECUTION_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return production mode when not manual', () => {
|
||||||
|
const result = getAdditionalKeys(additionalData, 'internal', null);
|
||||||
|
|
||||||
|
expect(result.$execution?.mode).toBe('production');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include customData methods when runExecutionData is provided', () => {
|
||||||
|
const result = getAdditionalKeys(additionalData, 'manual', runExecutionData);
|
||||||
|
|
||||||
|
expect(result.$execution?.customData).toBeDefined();
|
||||||
|
expect(typeof result.$execution?.customData?.set).toBe('function');
|
||||||
|
expect(typeof result.$execution?.customData?.setAll).toBe('function');
|
||||||
|
expect(typeof result.$execution?.customData?.get).toBe('function');
|
||||||
|
expect(typeof result.$execution?.customData?.getAll).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle customData operations correctly', () => {
|
||||||
|
const result = getAdditionalKeys(additionalData, 'manual', runExecutionData);
|
||||||
|
const customData = result.$execution?.customData;
|
||||||
|
|
||||||
|
customData?.set('testKey', 'testValue');
|
||||||
|
expect(customData?.get('testKey')).toBe('testValue');
|
||||||
|
|
||||||
|
customData?.setAll({ key1: 'value1', key2: 'value2' });
|
||||||
|
const allData = customData?.getAll();
|
||||||
|
expect(allData).toEqual({
|
||||||
|
testKey: 'testValue',
|
||||||
|
key1: 'value1',
|
||||||
|
key2: 'value2',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include secrets when enabled', () => {
|
||||||
|
const result = getAdditionalKeys(additionalData, 'manual', null, { secretsEnabled: true });
|
||||||
|
|
||||||
|
expect(result.$secrets).toBeDefined();
|
||||||
|
expect((result.$secrets?.provider1 as IDataObject).secret1).toEqual('secret-value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include secrets when disabled', () => {
|
||||||
|
const result = getAdditionalKeys(additionalData, 'manual', null, { secretsEnabled: false });
|
||||||
|
|
||||||
|
expect(result.$secrets).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw errors in manual mode', () => {
|
||||||
|
const result = getAdditionalKeys(additionalData, 'manual', runExecutionData);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
result.$execution?.customData?.set('invalid*key', 'value');
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly set resume URLs', () => {
|
||||||
|
const result = getAdditionalKeys(additionalData, 'manual', null);
|
||||||
|
|
||||||
|
expect(result.$execution?.resumeUrl).toBe('https://webhook.test/123');
|
||||||
|
expect(result.$execution?.resumeFormUrl).toBe('https://form.test/123');
|
||||||
|
expect(result.$resumeWebhookUrl).toBe('https://webhook.test/123'); // Test deprecated property
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return test mode when manual', () => {
|
||||||
|
const result = getAdditionalKeys(additionalData, 'manual', null);
|
||||||
|
|
||||||
|
expect(result.$execution?.mode).toBe('test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return variables from additionalData', () => {
|
||||||
|
const result = getAdditionalKeys(additionalData, 'manual', null);
|
||||||
|
expect(result.$vars?.testVar).toEqual('value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle errors in non-manual mode without throwing', () => {
|
||||||
|
const result = getAdditionalKeys(additionalData, 'internal', runExecutionData);
|
||||||
|
const customData = result.$execution?.customData;
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
customData?.set('invalid*key', 'value');
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined customData when runExecutionData is null', () => {
|
||||||
|
const result = getAdditionalKeys(additionalData, 'manual', null);
|
||||||
|
|
||||||
|
expect(result.$execution?.customData).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect metadata KV limit', () => {
|
||||||
|
const result = getAdditionalKeys(additionalData, 'manual', runExecutionData);
|
||||||
|
const customData = result.$execution?.customData;
|
||||||
|
|
||||||
|
// Add 11 key-value pairs (exceeding the limit of 10)
|
||||||
|
for (let i = 0; i < 11; i++) {
|
||||||
|
customData?.set(`key${i}`, `value${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allData = customData?.getAll() ?? {};
|
||||||
|
expect(Object.keys(allData)).toHaveLength(10);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,366 @@
|
||||||
|
import type { Tool } from '@langchain/core/tools';
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type {
|
||||||
|
INode,
|
||||||
|
ITaskDataConnections,
|
||||||
|
IRunExecutionData,
|
||||||
|
INodeExecutionData,
|
||||||
|
IExecuteData,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
Workflow,
|
||||||
|
INodeType,
|
||||||
|
INodeTypes,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { ExecuteContext } from '../../execute-context';
|
||||||
|
|
||||||
|
describe('getInputConnectionData', () => {
|
||||||
|
const agentNode = mock<INode>({
|
||||||
|
name: 'Test Agent',
|
||||||
|
type: 'test.agent',
|
||||||
|
parameters: {},
|
||||||
|
});
|
||||||
|
const agentNodeType = mock<INodeType>({
|
||||||
|
description: {
|
||||||
|
inputs: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const nodeTypes = mock<INodeTypes>();
|
||||||
|
const workflow = mock<Workflow>({
|
||||||
|
id: 'test-workflow',
|
||||||
|
active: false,
|
||||||
|
nodeTypes,
|
||||||
|
});
|
||||||
|
const runExecutionData = mock<IRunExecutionData>({
|
||||||
|
resultData: { runData: {} },
|
||||||
|
});
|
||||||
|
const connectionInputData = [] as INodeExecutionData[];
|
||||||
|
const inputData = {} as ITaskDataConnections;
|
||||||
|
const executeData = {} as IExecuteData;
|
||||||
|
|
||||||
|
const hooks = mock<Required<IWorkflowExecuteAdditionalData['hooks']>>();
|
||||||
|
const additionalData = mock<IWorkflowExecuteAdditionalData>({ hooks });
|
||||||
|
|
||||||
|
let executeContext: ExecuteContext;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
executeContext = new ExecuteContext(
|
||||||
|
workflow,
|
||||||
|
agentNode,
|
||||||
|
additionalData,
|
||||||
|
'internal',
|
||||||
|
runExecutionData,
|
||||||
|
0,
|
||||||
|
connectionInputData,
|
||||||
|
inputData,
|
||||||
|
executeData,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
jest.spyOn(executeContext, 'getNode').mockReturnValue(agentNode);
|
||||||
|
nodeTypes.getByNameAndVersion
|
||||||
|
.calledWith(agentNode.type, expect.anything())
|
||||||
|
.mockReturnValue(agentNodeType);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.each([
|
||||||
|
NodeConnectionType.AiAgent,
|
||||||
|
NodeConnectionType.AiChain,
|
||||||
|
NodeConnectionType.AiDocument,
|
||||||
|
NodeConnectionType.AiEmbedding,
|
||||||
|
NodeConnectionType.AiLanguageModel,
|
||||||
|
NodeConnectionType.AiMemory,
|
||||||
|
NodeConnectionType.AiOutputParser,
|
||||||
|
NodeConnectionType.AiRetriever,
|
||||||
|
NodeConnectionType.AiTextSplitter,
|
||||||
|
NodeConnectionType.AiVectorStore,
|
||||||
|
] as const)('%s', (connectionType) => {
|
||||||
|
const response = mock();
|
||||||
|
const node = mock<INode>({
|
||||||
|
name: 'First Node',
|
||||||
|
type: 'test.type',
|
||||||
|
disabled: false,
|
||||||
|
});
|
||||||
|
const secondNode = mock<INode>({ name: 'Second Node', disabled: false });
|
||||||
|
const supplyData = jest.fn().mockResolvedValue({ response });
|
||||||
|
const nodeType = mock<INodeType>({ supplyData });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
nodeTypes.getByNameAndVersion
|
||||||
|
.calledWith(node.type, expect.anything())
|
||||||
|
.mockReturnValue(nodeType);
|
||||||
|
workflow.getParentNodes
|
||||||
|
.calledWith(agentNode.name, connectionType)
|
||||||
|
.mockReturnValue([node.name]);
|
||||||
|
workflow.getNode.calledWith(node.name).mockReturnValue(node);
|
||||||
|
workflow.getNode.calledWith(secondNode.name).mockReturnValue(secondNode);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw when no inputs are defined', async () => {
|
||||||
|
agentNodeType.description.inputs = [];
|
||||||
|
|
||||||
|
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
|
||||||
|
'Node does not have input of type',
|
||||||
|
);
|
||||||
|
expect(supplyData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined when no nodes are connected and input is not required', async () => {
|
||||||
|
agentNodeType.description.inputs = [
|
||||||
|
{
|
||||||
|
type: connectionType,
|
||||||
|
maxConnections: 1,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
workflow.getParentNodes.mockReturnValueOnce([]);
|
||||||
|
|
||||||
|
const result = await executeContext.getInputConnectionData(connectionType, 0);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(supplyData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw when too many nodes are connected', async () => {
|
||||||
|
agentNodeType.description.inputs = [
|
||||||
|
{
|
||||||
|
type: connectionType,
|
||||||
|
maxConnections: 1,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
workflow.getParentNodes.mockReturnValueOnce([node.name, secondNode.name]);
|
||||||
|
|
||||||
|
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
|
||||||
|
`Only 1 ${connectionType} sub-nodes are/is allowed to be connected`,
|
||||||
|
);
|
||||||
|
expect(supplyData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw when required node is not connected', async () => {
|
||||||
|
agentNodeType.description.inputs = [
|
||||||
|
{
|
||||||
|
type: connectionType,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
workflow.getParentNodes.mockReturnValueOnce([]);
|
||||||
|
|
||||||
|
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
|
||||||
|
'must be connected and enabled',
|
||||||
|
);
|
||||||
|
expect(supplyData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle disabled nodes', async () => {
|
||||||
|
agentNodeType.description.inputs = [
|
||||||
|
{
|
||||||
|
type: connectionType,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const disabledNode = mock<INode>({
|
||||||
|
name: 'Disabled Node',
|
||||||
|
type: 'test.type',
|
||||||
|
disabled: true,
|
||||||
|
});
|
||||||
|
workflow.getParentNodes.mockReturnValueOnce([disabledNode.name]);
|
||||||
|
workflow.getNode.calledWith(disabledNode.name).mockReturnValue(disabledNode);
|
||||||
|
|
||||||
|
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
|
||||||
|
'must be connected and enabled',
|
||||||
|
);
|
||||||
|
expect(supplyData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle node execution errors', async () => {
|
||||||
|
agentNodeType.description.inputs = [
|
||||||
|
{
|
||||||
|
type: connectionType,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
supplyData.mockRejectedValueOnce(new Error('supplyData error'));
|
||||||
|
|
||||||
|
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
|
||||||
|
`Error in sub-node ${node.name}`,
|
||||||
|
);
|
||||||
|
expect(supplyData).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should propagate configuration errors', async () => {
|
||||||
|
agentNodeType.description.inputs = [
|
||||||
|
{
|
||||||
|
type: connectionType,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const configError = new NodeOperationError(node, 'Config Error in node', {
|
||||||
|
functionality: 'configuration-node',
|
||||||
|
});
|
||||||
|
supplyData.mockRejectedValueOnce(configError);
|
||||||
|
|
||||||
|
await expect(executeContext.getInputConnectionData(connectionType, 0)).rejects.toThrow(
|
||||||
|
configError.message,
|
||||||
|
);
|
||||||
|
expect(nodeType.supplyData).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle close functions', async () => {
|
||||||
|
agentNodeType.description.inputs = [
|
||||||
|
{
|
||||||
|
type: connectionType,
|
||||||
|
maxConnections: 1,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const closeFunction = jest.fn();
|
||||||
|
supplyData.mockResolvedValueOnce({ response, closeFunction });
|
||||||
|
|
||||||
|
const result = await executeContext.getInputConnectionData(connectionType, 0);
|
||||||
|
expect(result).toBe(response);
|
||||||
|
expect(supplyData).toHaveBeenCalled();
|
||||||
|
// @ts-expect-error private property
|
||||||
|
expect(executeContext.closeFunctions).toContain(closeFunction);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(NodeConnectionType.AiTool, () => {
|
||||||
|
const mockTool = mock<Tool>();
|
||||||
|
const toolNode = mock<INode>({
|
||||||
|
name: 'Test Tool',
|
||||||
|
type: 'test.tool',
|
||||||
|
disabled: false,
|
||||||
|
});
|
||||||
|
const supplyData = jest.fn().mockResolvedValue({ response: mockTool });
|
||||||
|
const toolNodeType = mock<INodeType>({ supplyData });
|
||||||
|
|
||||||
|
const secondToolNode = mock<INode>({ name: 'test.secondTool', disabled: false });
|
||||||
|
const secondMockTool = mock<Tool>();
|
||||||
|
const secondToolNodeType = mock<INodeType>({
|
||||||
|
supplyData: jest.fn().mockResolvedValue({ response: secondMockTool }),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
nodeTypes.getByNameAndVersion
|
||||||
|
.calledWith(toolNode.type, expect.anything())
|
||||||
|
.mockReturnValue(toolNodeType);
|
||||||
|
workflow.getParentNodes
|
||||||
|
.calledWith(agentNode.name, NodeConnectionType.AiTool)
|
||||||
|
.mockReturnValue([toolNode.name]);
|
||||||
|
workflow.getNode.calledWith(toolNode.name).mockReturnValue(toolNode);
|
||||||
|
workflow.getNode.calledWith(secondToolNode.name).mockReturnValue(secondToolNode);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when no tools are connected and input is not required', async () => {
|
||||||
|
agentNodeType.description.inputs = [
|
||||||
|
{
|
||||||
|
type: NodeConnectionType.AiTool,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
workflow.getParentNodes.mockReturnValueOnce([]);
|
||||||
|
|
||||||
|
const result = await executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(supplyData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw when required tool node is not connected', async () => {
|
||||||
|
agentNodeType.description.inputs = [
|
||||||
|
{
|
||||||
|
type: NodeConnectionType.AiTool,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
workflow.getParentNodes.mockReturnValueOnce([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0),
|
||||||
|
).rejects.toThrow('must be connected and enabled');
|
||||||
|
expect(supplyData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle disabled tool nodes', async () => {
|
||||||
|
const disabledToolNode = mock<INode>({
|
||||||
|
name: 'Disabled Tool',
|
||||||
|
type: 'test.tool',
|
||||||
|
disabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
agentNodeType.description.inputs = [
|
||||||
|
{
|
||||||
|
type: NodeConnectionType.AiTool,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
workflow.getParentNodes
|
||||||
|
.calledWith(agentNode.name, NodeConnectionType.AiTool)
|
||||||
|
.mockReturnValue([disabledToolNode.name]);
|
||||||
|
workflow.getNode.calledWith(disabledToolNode.name).mockReturnValue(disabledToolNode);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0),
|
||||||
|
).rejects.toThrow('must be connected and enabled');
|
||||||
|
expect(supplyData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple connected tools', async () => {
|
||||||
|
agentNodeType.description.inputs = [
|
||||||
|
{
|
||||||
|
type: NodeConnectionType.AiTool,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
nodeTypes.getByNameAndVersion
|
||||||
|
.calledWith(secondToolNode.type, expect.anything())
|
||||||
|
.mockReturnValue(secondToolNodeType);
|
||||||
|
|
||||||
|
workflow.getParentNodes
|
||||||
|
.calledWith(agentNode.name, NodeConnectionType.AiTool)
|
||||||
|
.mockReturnValue([toolNode.name, secondToolNode.name]);
|
||||||
|
|
||||||
|
const result = await executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0);
|
||||||
|
expect(result).toEqual([mockTool, secondMockTool]);
|
||||||
|
expect(supplyData).toHaveBeenCalled();
|
||||||
|
expect(secondToolNodeType.supplyData).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle tool execution errors', async () => {
|
||||||
|
supplyData.mockRejectedValueOnce(new Error('Tool execution error'));
|
||||||
|
|
||||||
|
agentNodeType.description.inputs = [
|
||||||
|
{
|
||||||
|
type: NodeConnectionType.AiTool,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0),
|
||||||
|
).rejects.toThrow(`Error in sub-node ${toolNode.name}`);
|
||||||
|
expect(supplyData).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the tool when there are no issues', async () => {
|
||||||
|
agentNodeType.description.inputs = [
|
||||||
|
{
|
||||||
|
type: NodeConnectionType.AiTool,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await executeContext.getInputConnectionData(NodeConnectionType.AiTool, 0);
|
||||||
|
expect(result).toEqual([mockTool]);
|
||||||
|
expect(supplyData).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,119 +1,6 @@
|
||||||
import toPlainObject from 'lodash/toPlainObject';
|
import type { IDataObject, INode, INodeType } from 'n8n-workflow';
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import type { IDataObject, INode, INodeType, NodeParameterValue } from 'n8n-workflow';
|
|
||||||
import { ExpressionError } from 'n8n-workflow';
|
|
||||||
|
|
||||||
import { cleanupParameterData, ensureType, validateValueAgainstSchema } from '../utils';
|
import { validateValueAgainstSchema } from '../validateValueAgainstSchema';
|
||||||
|
|
||||||
describe('cleanupParameterData', () => {
|
|
||||||
it('should stringify Luxon dates in-place', () => {
|
|
||||||
const input = { x: 1, y: DateTime.now() as unknown as NodeParameterValue };
|
|
||||||
expect(typeof input.y).toBe('object');
|
|
||||||
cleanupParameterData(input);
|
|
||||||
expect(typeof input.y).toBe('string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should stringify plain Luxon dates in-place', () => {
|
|
||||||
const input = {
|
|
||||||
x: 1,
|
|
||||||
y: toPlainObject(DateTime.now()),
|
|
||||||
};
|
|
||||||
expect(typeof input.y).toBe('object');
|
|
||||||
cleanupParameterData(input);
|
|
||||||
expect(typeof input.y).toBe('string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle objects with nameless constructors', () => {
|
|
||||||
const input = { x: 1, y: { constructor: {} } as NodeParameterValue };
|
|
||||||
expect(typeof input.y).toBe('object');
|
|
||||||
cleanupParameterData(input);
|
|
||||||
expect(typeof input.y).toBe('object');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle objects without a constructor', () => {
|
|
||||||
const input = { x: 1, y: { constructor: undefined } as unknown as NodeParameterValue };
|
|
||||||
expect(typeof input.y).toBe('object');
|
|
||||||
cleanupParameterData(input);
|
|
||||||
expect(typeof input.y).toBe('object');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ensureType', () => {
|
|
||||||
it('throws error for null value', () => {
|
|
||||||
expect(() => ensureType('string', null, 'myParam')).toThrowError(
|
|
||||||
new ExpressionError("Parameter 'myParam' must not be null"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error for undefined value', () => {
|
|
||||||
expect(() => ensureType('string', undefined, 'myParam')).toThrowError(
|
|
||||||
new ExpressionError("Parameter 'myParam' could not be 'undefined'"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns string value without modification', () => {
|
|
||||||
const value = 'hello';
|
|
||||||
const expectedValue = value;
|
|
||||||
const result = ensureType('string', value, 'myParam');
|
|
||||||
expect(result).toBe(expectedValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns number value without modification', () => {
|
|
||||||
const value = 42;
|
|
||||||
const expectedValue = value;
|
|
||||||
const result = ensureType('number', value, 'myParam');
|
|
||||||
expect(result).toBe(expectedValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns boolean value without modification', () => {
|
|
||||||
const value = true;
|
|
||||||
const expectedValue = value;
|
|
||||||
const result = ensureType('boolean', value, 'myParam');
|
|
||||||
expect(result).toBe(expectedValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('converts object to string if toType is string', () => {
|
|
||||||
const value = { name: 'John' };
|
|
||||||
const expectedValue = JSON.stringify(value);
|
|
||||||
const result = ensureType('string', value, 'myParam');
|
|
||||||
expect(result).toBe(expectedValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('converts string to number if toType is number', () => {
|
|
||||||
const value = '10';
|
|
||||||
const expectedValue = 10;
|
|
||||||
const result = ensureType('number', value, 'myParam');
|
|
||||||
expect(result).toBe(expectedValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error for invalid conversion to number', () => {
|
|
||||||
const value = 'invalid';
|
|
||||||
expect(() => ensureType('number', value, 'myParam')).toThrowError(
|
|
||||||
new ExpressionError("Parameter 'myParam' must be a number, but we got 'invalid'"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses valid JSON string to object if toType is object', () => {
|
|
||||||
const value = '{"name": "Alice"}';
|
|
||||||
const expectedValue = JSON.parse(value);
|
|
||||||
const result = ensureType('object', value, 'myParam');
|
|
||||||
expect(result).toEqual(expectedValue);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error for invalid JSON string to object conversion', () => {
|
|
||||||
const value = 'invalid_json';
|
|
||||||
expect(() => ensureType('object', value, 'myParam')).toThrowError(
|
|
||||||
new ExpressionError("Parameter 'myParam' could not be parsed"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error for non-array value if toType is array', () => {
|
|
||||||
const value = { name: 'Alice' };
|
|
||||||
expect(() => ensureType('array', value, 'myParam')).toThrowError(
|
|
||||||
new ExpressionError("Parameter 'myParam' must be an array, but we got object"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('validateValueAgainstSchema', () => {
|
describe('validateValueAgainstSchema', () => {
|
||||||
test('should validate fixedCollection values parameter', () => {
|
test('should validate fixedCollection values parameter', () => {
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import type { INodeParameters, NodeParameterValueType } from 'n8n-workflow';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up parameter data to make sure that only valid data gets returned
|
||||||
|
* INFO: Currently only converts Luxon Dates as we know for sure it will not be breaking
|
||||||
|
*/
|
||||||
|
export function cleanupParameterData(inputData: NodeParameterValueType): void {
|
||||||
|
if (typeof inputData !== 'object' || inputData === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(inputData)) {
|
||||||
|
inputData.forEach((value) => cleanupParameterData(value as NodeParameterValueType));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof inputData === 'object') {
|
||||||
|
Object.keys(inputData).forEach((key) => {
|
||||||
|
const value = (inputData as INodeParameters)[key];
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
if (DateTime.isDateTime(value)) {
|
||||||
|
// Is a special luxon date so convert to string
|
||||||
|
(inputData as INodeParameters)[key] = value.toString();
|
||||||
|
} else {
|
||||||
|
cleanupParameterData(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
103
packages/core/src/node-execution-context/utils/ensureType.ts
Normal file
103
packages/core/src/node-execution-context/utils/ensureType.ts
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
import type { EnsureTypeOptions } from 'n8n-workflow';
|
||||||
|
import { ExpressionError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export function ensureType(
|
||||||
|
toType: EnsureTypeOptions,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
parameterValue: any,
|
||||||
|
parameterName: string,
|
||||||
|
errorOptions?: { itemIndex?: number; runIndex?: number; nodeCause?: string },
|
||||||
|
): string | number | boolean | object {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
let returnData = parameterValue;
|
||||||
|
|
||||||
|
if (returnData === null) {
|
||||||
|
throw new ExpressionError(`Parameter '${parameterName}' must not be null`, errorOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (returnData === undefined) {
|
||||||
|
throw new ExpressionError(
|
||||||
|
`Parameter '${parameterName}' could not be 'undefined'`,
|
||||||
|
errorOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['object', 'array', 'json'].includes(toType)) {
|
||||||
|
if (typeof returnData !== 'object') {
|
||||||
|
// if value is not an object and is string try to parse it, else throw an error
|
||||||
|
if (typeof returnData === 'string' && returnData.length) {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
const parsedValue = JSON.parse(returnData);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
returnData = parsedValue;
|
||||||
|
} catch (error) {
|
||||||
|
throw new ExpressionError(`Parameter '${parameterName}' could not be parsed`, {
|
||||||
|
...errorOptions,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new ExpressionError(
|
||||||
|
`Parameter '${parameterName}' must be an ${toType}, but we got '${String(parameterValue)}'`,
|
||||||
|
errorOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (toType === 'json') {
|
||||||
|
// value is an object, make sure it is valid JSON
|
||||||
|
try {
|
||||||
|
JSON.stringify(returnData);
|
||||||
|
} catch (error) {
|
||||||
|
throw new ExpressionError(`Parameter '${parameterName}' is not valid JSON`, {
|
||||||
|
...errorOptions,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toType === 'array' && !Array.isArray(returnData)) {
|
||||||
|
// value is not an array, but has to be
|
||||||
|
throw new ExpressionError(
|
||||||
|
`Parameter '${parameterName}' must be an array, but we got object`,
|
||||||
|
errorOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (toType === 'string') {
|
||||||
|
if (typeof returnData === 'object') {
|
||||||
|
returnData = JSON.stringify(returnData);
|
||||||
|
} else {
|
||||||
|
returnData = String(returnData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toType === 'number') {
|
||||||
|
returnData = Number(returnData);
|
||||||
|
if (Number.isNaN(returnData)) {
|
||||||
|
throw new ExpressionError(
|
||||||
|
`Parameter '${parameterName}' must be a number, but we got '${parameterValue}'`,
|
||||||
|
errorOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toType === 'boolean') {
|
||||||
|
returnData = Boolean(returnData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ExpressionError) throw error;
|
||||||
|
|
||||||
|
throw new ExpressionError(`Parameter '${parameterName}' could not be converted to ${toType}`, {
|
||||||
|
...errorOptions,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
|
return returnData;
|
||||||
|
}
|
|
@ -0,0 +1,74 @@
|
||||||
|
import type {
|
||||||
|
IRunExecutionData,
|
||||||
|
IWorkflowDataProxyAdditionalKeys,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
WorkflowExecuteMode,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { LoggerProxy } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { PLACEHOLDER_EMPTY_EXECUTION_ID } from '@/Constants';
|
||||||
|
import {
|
||||||
|
setWorkflowExecutionMetadata,
|
||||||
|
setAllWorkflowExecutionMetadata,
|
||||||
|
getWorkflowExecutionMetadata,
|
||||||
|
getAllWorkflowExecutionMetadata,
|
||||||
|
} from '@/ExecutionMetadata';
|
||||||
|
import { getSecretsProxy } from '@/Secrets';
|
||||||
|
|
||||||
|
/** Returns the additional keys for Expressions and Function-Nodes */
|
||||||
|
export function getAdditionalKeys(
|
||||||
|
additionalData: IWorkflowExecuteAdditionalData,
|
||||||
|
mode: WorkflowExecuteMode,
|
||||||
|
runExecutionData: IRunExecutionData | null,
|
||||||
|
options?: { secretsEnabled?: boolean },
|
||||||
|
): IWorkflowDataProxyAdditionalKeys {
|
||||||
|
const executionId = additionalData.executionId ?? PLACEHOLDER_EMPTY_EXECUTION_ID;
|
||||||
|
const resumeUrl = `${additionalData.webhookWaitingBaseUrl}/${executionId}`;
|
||||||
|
const resumeFormUrl = `${additionalData.formWaitingBaseUrl}/${executionId}`;
|
||||||
|
return {
|
||||||
|
$execution: {
|
||||||
|
id: executionId,
|
||||||
|
mode: mode === 'manual' ? 'test' : 'production',
|
||||||
|
resumeUrl,
|
||||||
|
resumeFormUrl,
|
||||||
|
customData: runExecutionData
|
||||||
|
? {
|
||||||
|
set(key: string, value: string): void {
|
||||||
|
try {
|
||||||
|
setWorkflowExecutionMetadata(runExecutionData, key, value);
|
||||||
|
} catch (e) {
|
||||||
|
if (mode === 'manual') {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
|
||||||
|
LoggerProxy.debug(e.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setAll(obj: Record<string, string>): void {
|
||||||
|
try {
|
||||||
|
setAllWorkflowExecutionMetadata(runExecutionData, obj);
|
||||||
|
} catch (e) {
|
||||||
|
if (mode === 'manual') {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
|
||||||
|
LoggerProxy.debug(e.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
get(key: string): string {
|
||||||
|
return getWorkflowExecutionMetadata(runExecutionData, key);
|
||||||
|
},
|
||||||
|
getAll(): Record<string, string> {
|
||||||
|
return getAllWorkflowExecutionMetadata(runExecutionData);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
$vars: additionalData.variables,
|
||||||
|
$secrets: options?.secretsEnabled ? getSecretsProxy(additionalData) : undefined,
|
||||||
|
|
||||||
|
// deprecated
|
||||||
|
$executionId: executionId,
|
||||||
|
$resumeWebhookUrl: resumeUrl,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,184 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||||
|
import type {
|
||||||
|
CloseFunction,
|
||||||
|
IExecuteData,
|
||||||
|
IExecuteFunctions,
|
||||||
|
INodeExecutionData,
|
||||||
|
IRunExecutionData,
|
||||||
|
ITaskDataConnections,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
Workflow,
|
||||||
|
WorkflowExecuteMode,
|
||||||
|
SupplyData,
|
||||||
|
AINodeConnectionType,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import {
|
||||||
|
NodeConnectionType,
|
||||||
|
NodeOperationError,
|
||||||
|
ExecutionBaseError,
|
||||||
|
ApplicationError,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { createNodeAsTool } from '@/CreateNodeAsTool';
|
||||||
|
// eslint-disable-next-line import/no-cycle
|
||||||
|
import { SupplyDataContext } from '@/node-execution-context';
|
||||||
|
import type { ExecuteContext, WebhookContext } from '@/node-execution-context';
|
||||||
|
|
||||||
|
export async function getInputConnectionData(
|
||||||
|
this: ExecuteContext | WebhookContext | SupplyDataContext,
|
||||||
|
workflow: Workflow,
|
||||||
|
runExecutionData: IRunExecutionData,
|
||||||
|
parentRunIndex: number,
|
||||||
|
connectionInputData: INodeExecutionData[],
|
||||||
|
parentInputData: ITaskDataConnections,
|
||||||
|
additionalData: IWorkflowExecuteAdditionalData,
|
||||||
|
executeData: IExecuteData,
|
||||||
|
mode: WorkflowExecuteMode,
|
||||||
|
closeFunctions: CloseFunction[],
|
||||||
|
connectionType: AINodeConnectionType,
|
||||||
|
itemIndex: number,
|
||||||
|
abortSignal?: AbortSignal,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const parentNode = this.getNode();
|
||||||
|
|
||||||
|
const inputConfiguration = this.nodeInputs.find((input) => input.type === connectionType);
|
||||||
|
if (inputConfiguration === undefined) {
|
||||||
|
throw new ApplicationError('Node does not have input of type', {
|
||||||
|
extra: { nodeName: parentNode.name, connectionType },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectedNodes = this.getConnectedNodes(connectionType);
|
||||||
|
if (connectedNodes.length === 0) {
|
||||||
|
if (inputConfiguration.required) {
|
||||||
|
throw new NodeOperationError(
|
||||||
|
parentNode,
|
||||||
|
`A ${inputConfiguration?.displayName ?? connectionType} sub-node must be connected and enabled`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return inputConfiguration.maxConnections === 1 ? undefined : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
inputConfiguration.maxConnections !== undefined &&
|
||||||
|
connectedNodes.length > inputConfiguration.maxConnections
|
||||||
|
) {
|
||||||
|
throw new NodeOperationError(
|
||||||
|
parentNode,
|
||||||
|
`Only ${inputConfiguration.maxConnections} ${connectionType} sub-nodes are/is allowed to be connected`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes: SupplyData[] = [];
|
||||||
|
for (const connectedNode of connectedNodes) {
|
||||||
|
const connectedNodeType = workflow.nodeTypes.getByNameAndVersion(
|
||||||
|
connectedNode.type,
|
||||||
|
connectedNode.typeVersion,
|
||||||
|
);
|
||||||
|
const contextFactory = (runIndex: number, inputData: ITaskDataConnections) =>
|
||||||
|
new SupplyDataContext(
|
||||||
|
workflow,
|
||||||
|
connectedNode,
|
||||||
|
additionalData,
|
||||||
|
mode,
|
||||||
|
runExecutionData,
|
||||||
|
runIndex,
|
||||||
|
connectionInputData,
|
||||||
|
inputData,
|
||||||
|
connectionType,
|
||||||
|
executeData,
|
||||||
|
closeFunctions,
|
||||||
|
abortSignal,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!connectedNodeType.supplyData) {
|
||||||
|
if (connectedNodeType.description.outputs.includes(NodeConnectionType.AiTool)) {
|
||||||
|
/**
|
||||||
|
* This keeps track of how many times this specific AI tool node has been invoked.
|
||||||
|
* It is incremented on every invocation of the tool to keep the output of each invocation separate from each other.
|
||||||
|
*/
|
||||||
|
let toolRunIndex = 0;
|
||||||
|
const supplyData = createNodeAsTool({
|
||||||
|
node: connectedNode,
|
||||||
|
nodeType: connectedNodeType,
|
||||||
|
handleToolInvocation: async (toolArgs) => {
|
||||||
|
const runIndex = toolRunIndex++;
|
||||||
|
const context = contextFactory(runIndex, {});
|
||||||
|
context.addInputData(NodeConnectionType.AiTool, [[{ json: toolArgs }]]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Execute the sub-node with the proxied context
|
||||||
|
const result = await connectedNodeType.execute?.call(
|
||||||
|
context as unknown as IExecuteFunctions,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process and map the results
|
||||||
|
const mappedResults = result?.[0]?.flatMap((item) => item.json);
|
||||||
|
|
||||||
|
// Add output data to the context
|
||||||
|
context.addOutputData(NodeConnectionType.AiTool, runIndex, [
|
||||||
|
[{ json: { response: mappedResults } }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Return the stringified results
|
||||||
|
return JSON.stringify(mappedResults);
|
||||||
|
} catch (error) {
|
||||||
|
const nodeError = new NodeOperationError(connectedNode, error as Error);
|
||||||
|
context.addOutputData(NodeConnectionType.AiTool, runIndex, nodeError);
|
||||||
|
return 'Error during node execution: ' + nodeError.description;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
nodes.push(supplyData);
|
||||||
|
} else {
|
||||||
|
throw new ApplicationError('Node does not have a `supplyData` method defined', {
|
||||||
|
extra: { nodeName: connectedNode.name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const context = contextFactory(parentRunIndex, parentInputData);
|
||||||
|
try {
|
||||||
|
const supplyData = await connectedNodeType.supplyData.call(context, itemIndex);
|
||||||
|
if (supplyData.closeFunction) {
|
||||||
|
closeFunctions.push(supplyData.closeFunction);
|
||||||
|
}
|
||||||
|
nodes.push(supplyData);
|
||||||
|
} catch (error) {
|
||||||
|
// Propagate errors from sub-nodes
|
||||||
|
if (error instanceof ExecutionBaseError) {
|
||||||
|
if (error.functionality === 'configuration-node') throw error;
|
||||||
|
} else {
|
||||||
|
error = new NodeOperationError(connectedNode, error, {
|
||||||
|
itemIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentNodeRunIndex = 0;
|
||||||
|
if (runExecutionData.resultData.runData.hasOwnProperty(parentNode.name)) {
|
||||||
|
currentNodeRunIndex = runExecutionData.resultData.runData[parentNode.name].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display the error on the node which is causing it
|
||||||
|
await context.addExecutionDataFunctions(
|
||||||
|
'input',
|
||||||
|
error,
|
||||||
|
connectionType,
|
||||||
|
parentNode.name,
|
||||||
|
currentNodeRunIndex,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display on the calling node which node has the error
|
||||||
|
throw new NodeOperationError(connectedNode, `Error in sub-node ${connectedNode.name}`, {
|
||||||
|
itemIndex,
|
||||||
|
functionality: 'configuration-node',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputConfiguration.maxConnections === 1
|
||||||
|
? (nodes || [])[0]?.response
|
||||||
|
: nodes.map((node) => node.response);
|
||||||
|
}
|
|
@ -0,0 +1,218 @@
|
||||||
|
import type {
|
||||||
|
FieldType,
|
||||||
|
IDataObject,
|
||||||
|
INode,
|
||||||
|
INodeProperties,
|
||||||
|
INodePropertyCollection,
|
||||||
|
INodePropertyOptions,
|
||||||
|
INodeType,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import {
|
||||||
|
ExpressionError,
|
||||||
|
isResourceMapperValue,
|
||||||
|
NodeHelpers,
|
||||||
|
validateFieldType,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
import type { ExtendedValidationResult } from '@/Interfaces';
|
||||||
|
|
||||||
|
const validateResourceMapperValue = (
|
||||||
|
parameterName: string,
|
||||||
|
paramValues: { [key: string]: unknown },
|
||||||
|
node: INode,
|
||||||
|
skipRequiredCheck = false,
|
||||||
|
): ExtendedValidationResult => {
|
||||||
|
const result: ExtendedValidationResult = { valid: true, newValue: paramValues };
|
||||||
|
const paramNameParts = parameterName.split('.');
|
||||||
|
if (paramNameParts.length !== 2) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const resourceMapperParamName = paramNameParts[0];
|
||||||
|
const resourceMapperField = node.parameters[resourceMapperParamName];
|
||||||
|
if (!resourceMapperField || !isResourceMapperValue(resourceMapperField)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const schema = resourceMapperField.schema;
|
||||||
|
const paramValueNames = Object.keys(paramValues);
|
||||||
|
for (let i = 0; i < paramValueNames.length; i++) {
|
||||||
|
const key = paramValueNames[i];
|
||||||
|
const resolvedValue = paramValues[key];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
|
||||||
|
const schemaEntry = schema.find((s) => s.id === key);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!skipRequiredCheck &&
|
||||||
|
schemaEntry?.required === true &&
|
||||||
|
schemaEntry.type !== 'boolean' &&
|
||||||
|
!resolvedValue
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errorMessage: `The value "${String(key)}" is required but not set`,
|
||||||
|
fieldName: key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||||
|
if (schemaEntry?.type) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||||
|
const validationResult = validateFieldType(key, resolvedValue, schemaEntry.type, {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
||||||
|
valueOptions: schemaEntry.options,
|
||||||
|
});
|
||||||
|
if (!validationResult.valid) {
|
||||||
|
return { ...validationResult, fieldName: key };
|
||||||
|
} else {
|
||||||
|
// If it's valid, set the casted value
|
||||||
|
paramValues[key] = validationResult.newValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateCollection = (
|
||||||
|
node: INode,
|
||||||
|
runIndex: number,
|
||||||
|
itemIndex: number,
|
||||||
|
propertyDescription: INodeProperties,
|
||||||
|
parameterPath: string[],
|
||||||
|
validationResult: ExtendedValidationResult,
|
||||||
|
): ExtendedValidationResult => {
|
||||||
|
let nestedDescriptions: INodeProperties[] | undefined;
|
||||||
|
|
||||||
|
if (propertyDescription.type === 'fixedCollection') {
|
||||||
|
nestedDescriptions = (propertyDescription.options as INodePropertyCollection[]).find(
|
||||||
|
(entry) => entry.name === parameterPath[1],
|
||||||
|
)?.values;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (propertyDescription.type === 'collection') {
|
||||||
|
nestedDescriptions = propertyDescription.options as INodeProperties[];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nestedDescriptions) {
|
||||||
|
return validationResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationMap: {
|
||||||
|
[key: string]: { type: FieldType; displayName: string; options?: INodePropertyOptions[] };
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
for (const prop of nestedDescriptions) {
|
||||||
|
if (!prop.validateType || prop.ignoreValidationDuringExecution) continue;
|
||||||
|
|
||||||
|
validationMap[prop.name] = {
|
||||||
|
type: prop.validateType,
|
||||||
|
displayName: prop.displayName,
|
||||||
|
options:
|
||||||
|
prop.validateType === 'options' ? (prop.options as INodePropertyOptions[]) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Object.keys(validationMap).length) {
|
||||||
|
return validationResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validationResult.valid) {
|
||||||
|
for (const value of Array.isArray(validationResult.newValue)
|
||||||
|
? (validationResult.newValue as IDataObject[])
|
||||||
|
: [validationResult.newValue as IDataObject]) {
|
||||||
|
for (const key of Object.keys(value)) {
|
||||||
|
if (!validationMap[key]) continue;
|
||||||
|
|
||||||
|
const fieldValidationResult = validateFieldType(key, value[key], validationMap[key].type, {
|
||||||
|
valueOptions: validationMap[key].options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fieldValidationResult.valid) {
|
||||||
|
throw new ExpressionError(
|
||||||
|
`Invalid input for field '${validationMap[key].displayName}' inside '${propertyDescription.displayName}' in [item ${itemIndex}]`,
|
||||||
|
{
|
||||||
|
description: fieldValidationResult.errorMessage,
|
||||||
|
runIndex,
|
||||||
|
itemIndex,
|
||||||
|
nodeCause: node.name,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
value[key] = fieldValidationResult.newValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return validationResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateValueAgainstSchema = (
|
||||||
|
node: INode,
|
||||||
|
nodeType: INodeType,
|
||||||
|
parameterValue: string | number | boolean | object | null | undefined,
|
||||||
|
parameterName: string,
|
||||||
|
runIndex: number,
|
||||||
|
itemIndex: number,
|
||||||
|
) => {
|
||||||
|
const parameterPath = parameterName.split('.');
|
||||||
|
|
||||||
|
const propertyDescription = nodeType.description.properties.find(
|
||||||
|
(prop) =>
|
||||||
|
parameterPath[0] === prop.name && NodeHelpers.displayParameter(node.parameters, prop, node),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!propertyDescription) {
|
||||||
|
return parameterValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let validationResult: ExtendedValidationResult = { valid: true, newValue: parameterValue };
|
||||||
|
|
||||||
|
if (
|
||||||
|
parameterPath.length === 1 &&
|
||||||
|
propertyDescription.validateType &&
|
||||||
|
!propertyDescription.ignoreValidationDuringExecution
|
||||||
|
) {
|
||||||
|
validationResult = validateFieldType(
|
||||||
|
parameterName,
|
||||||
|
parameterValue,
|
||||||
|
propertyDescription.validateType,
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
propertyDescription.type === 'resourceMapper' &&
|
||||||
|
parameterPath[1] === 'value' &&
|
||||||
|
typeof parameterValue === 'object'
|
||||||
|
) {
|
||||||
|
validationResult = validateResourceMapperValue(
|
||||||
|
parameterName,
|
||||||
|
parameterValue as { [key: string]: unknown },
|
||||||
|
node,
|
||||||
|
propertyDescription.typeOptions?.resourceMapper?.mode !== 'add',
|
||||||
|
);
|
||||||
|
} else if (['fixedCollection', 'collection'].includes(propertyDescription.type)) {
|
||||||
|
validationResult = validateCollection(
|
||||||
|
node,
|
||||||
|
runIndex,
|
||||||
|
itemIndex,
|
||||||
|
propertyDescription,
|
||||||
|
parameterPath,
|
||||||
|
validationResult,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validationResult.valid) {
|
||||||
|
throw new ExpressionError(
|
||||||
|
`Invalid input for '${
|
||||||
|
validationResult.fieldName
|
||||||
|
? String(validationResult.fieldName)
|
||||||
|
: propertyDescription.displayName
|
||||||
|
}' [item ${itemIndex}]`,
|
||||||
|
{
|
||||||
|
description: validationResult.errorMessage,
|
||||||
|
runIndex,
|
||||||
|
itemIndex,
|
||||||
|
nodeCause: node.name,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
|
return validationResult.newValue;
|
||||||
|
};
|
|
@ -22,13 +22,13 @@ import { ApplicationError, createDeferredPromise } from 'n8n-workflow';
|
||||||
import {
|
import {
|
||||||
copyBinaryFile,
|
copyBinaryFile,
|
||||||
getBinaryHelperFunctions,
|
getBinaryHelperFunctions,
|
||||||
getInputConnectionData,
|
|
||||||
getNodeWebhookUrl,
|
getNodeWebhookUrl,
|
||||||
getRequestHelperFunctions,
|
getRequestHelperFunctions,
|
||||||
returnJsonArray,
|
returnJsonArray,
|
||||||
} from '@/NodeExecuteFunctions';
|
} from '@/NodeExecuteFunctions';
|
||||||
|
|
||||||
import { NodeExecutionContext } from './node-execution-context';
|
import { NodeExecutionContext } from './node-execution-context';
|
||||||
|
import { getInputConnectionData } from './utils/getInputConnectionData';
|
||||||
|
|
||||||
export class WebhookContext extends NodeExecutionContext implements IWebhookFunctions {
|
export class WebhookContext extends NodeExecutionContext implements IWebhookFunctions {
|
||||||
readonly helpers: IWebhookFunctions['helpers'];
|
readonly helpers: IWebhookFunctions['helpers'];
|
||||||
|
|
|
@ -1992,7 +1992,28 @@ export interface IWorkflowDataProxyData {
|
||||||
constructor: any;
|
constructor: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IWorkflowDataProxyAdditionalKeys = IDataObject;
|
export type IWorkflowDataProxyAdditionalKeys = IDataObject & {
|
||||||
|
$execution?: {
|
||||||
|
id: string;
|
||||||
|
mode: 'test' | 'production';
|
||||||
|
resumeUrl: string;
|
||||||
|
resumeFormUrl: string;
|
||||||
|
customData?: {
|
||||||
|
set(key: string, value: string): void;
|
||||||
|
setAll(obj: Record<string, string>): void;
|
||||||
|
get(key: string): string;
|
||||||
|
getAll(): Record<string, string>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
$vars?: IDataObject;
|
||||||
|
$secrets?: IDataObject;
|
||||||
|
$pageCount?: number;
|
||||||
|
|
||||||
|
/** @deprecated */
|
||||||
|
$executionId?: string;
|
||||||
|
/** @deprecated */
|
||||||
|
$resumeWebhookUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface IWorkflowMetadata {
|
export interface IWorkflowMetadata {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|
Loading…
Reference in a new issue