refactor(core): Reorganize n8n-core and enforce file-name casing (no-changelog) (#12667)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™
2025-01-17 15:17:25 +01:00
committed by GitHub
parent e7f00bcb7f
commit 05858c2153
132 changed files with 459 additions and 441 deletions

View File

@@ -0,0 +1,218 @@
import { mock } from 'jest-mock-extended';
import type {
INode,
IWorkflowExecuteAdditionalData,
IRunExecutionData,
INodeExecutionData,
ITaskDataConnections,
IExecuteData,
Workflow,
WorkflowExecuteMode,
ICredentialsHelper,
Expression,
INodeType,
INodeTypes,
ICredentialDataDecryptedObject,
} from 'n8n-workflow';
import { ApplicationError, ExpressionError, NodeConnectionType } from 'n8n-workflow';
import { describeCommonTests } from './shared-tests';
import { ExecuteContext } from '../execute-context';
describe('ExecuteContext', () => {
const testCredentialType = 'testCredential';
const nodeType = mock<INodeType>({
description: {
credentials: [
{
name: testCredentialType,
required: true,
},
],
properties: [
{
name: 'testParameter',
required: true,
},
],
},
});
const nodeTypes = mock<INodeTypes>();
const expression = mock<Expression>();
const workflow = mock<Workflow>({ expression, nodeTypes });
const node = mock<INode>({
name: 'Test Node',
credentials: {
[testCredentialType]: {
id: 'testCredentialId',
},
},
});
node.parameters = {
testParameter: 'testValue',
nullParameter: null,
};
const credentialsHelper = mock<ICredentialsHelper>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
const mode: WorkflowExecuteMode = 'manual';
const runExecutionData = mock<IRunExecutionData>();
const connectionInputData: INodeExecutionData[] = [];
const inputData: ITaskDataConnections = { main: [[{ json: { test: 'data' } }]] };
const executeData = mock<IExecuteData>();
const runIndex = 0;
const closeFn = jest.fn();
const abortSignal = mock<AbortSignal>();
const executeContext = new ExecuteContext(
workflow,
node,
additionalData,
mode,
runExecutionData,
runIndex,
connectionInputData,
inputData,
executeData,
[closeFn],
abortSignal,
);
beforeEach(() => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
expression.getParameterValue.mockImplementation((value) => value);
});
describeCommonTests(executeContext, {
abortSignal,
node,
workflow,
executeData,
runExecutionData,
});
describe('getInputData', () => {
const inputIndex = 0;
const connectionType = NodeConnectionType.Main;
afterEach(() => {
inputData[connectionType] = [[{ json: { test: 'data' } }]];
});
it('should return the input data correctly', () => {
const expectedData = [{ json: { test: 'data' } }];
expect(executeContext.getInputData(inputIndex, connectionType)).toEqual(expectedData);
});
it('should return an empty array if the input name does not exist', () => {
const connectionType = 'nonExistent';
expect(executeContext.getInputData(inputIndex, connectionType as NodeConnectionType)).toEqual(
[],
);
});
it('should throw an error if the input index is out of range', () => {
const inputIndex = 2;
expect(() => executeContext.getInputData(inputIndex, connectionType)).toThrow(
ApplicationError,
);
});
it('should throw an error if the input index was not set', () => {
inputData.main[inputIndex] = null;
expect(() => executeContext.getInputData(inputIndex, connectionType)).toThrow(
ApplicationError,
);
});
});
describe('getNodeParameter', () => {
beforeEach(() => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
expression.getParameterValue.mockImplementation((value) => value);
});
it('should throw if parameter is not defined on the node.parameters', () => {
expect(() => executeContext.getNodeParameter('invalidParameter', 0)).toThrow(
'Could not get parameter',
);
});
it('should return null if the parameter exists but has a null value', () => {
const parameter = executeContext.getNodeParameter('nullParameter', 0);
expect(parameter).toBeNull();
});
it('should return parameter value when it exists', () => {
const parameter = executeContext.getNodeParameter('testParameter', 0);
expect(parameter).toBe('testValue');
});
it('should return the fallback value when the parameter does not exist', () => {
const parameter = executeContext.getNodeParameter('otherParameter', 0, 'fallback');
expect(parameter).toBe('fallback');
});
it('should handle expression evaluation errors', () => {
const error = new ExpressionError('Invalid expression');
expression.getParameterValue.mockImplementationOnce(() => {
throw error;
});
expect(() => executeContext.getNodeParameter('testParameter', 0)).toThrow(error);
expect(error.context.parameter).toEqual('testParameter');
});
it('should handle expression errors on Set nodes (Ticket #PAY-684)', () => {
node.type = 'n8n-nodes-base.set';
node.continueOnFail = true;
expression.getParameterValue.mockImplementationOnce(() => {
throw new ExpressionError('Invalid expression');
});
const parameter = executeContext.getNodeParameter('testParameter', 0);
expect(parameter).toEqual([{ name: undefined, value: undefined }]);
});
});
describe('getCredentials', () => {
it('should get decrypted credentials', async () => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
const credentials = await executeContext.getCredentials<ICredentialDataDecryptedObject>(
testCredentialType,
0,
);
expect(credentials).toEqual({ secret: 'token' });
});
});
describe('getExecuteData', () => {
it('should return the execute data correctly', () => {
expect(executeContext.getExecuteData()).toEqual(executeData);
});
});
describe('getWorkflowDataProxy', () => {
it('should return the workflow data proxy correctly', () => {
const workflowDataProxy = executeContext.getWorkflowDataProxy(0);
expect(workflowDataProxy.isProxy).toBe(true);
expect(Object.keys(workflowDataProxy.$input)).toEqual([
'all',
'context',
'first',
'item',
'last',
'params',
]);
});
});
});

View File

@@ -0,0 +1,199 @@
import { mock } from 'jest-mock-extended';
import type {
INode,
IWorkflowExecuteAdditionalData,
IRunExecutionData,
INodeExecutionData,
ITaskDataConnections,
IExecuteData,
Workflow,
WorkflowExecuteMode,
ICredentialsHelper,
Expression,
INodeType,
INodeTypes,
ICredentialDataDecryptedObject,
} from 'n8n-workflow';
import { ApplicationError, NodeConnectionType } from 'n8n-workflow';
import { describeCommonTests } from './shared-tests';
import { ExecuteSingleContext } from '../execute-single-context';
describe('ExecuteSingleContext', () => {
const testCredentialType = 'testCredential';
const nodeType = mock<INodeType>({
description: {
credentials: [
{
name: testCredentialType,
required: true,
},
],
properties: [
{
name: 'testParameter',
required: true,
},
],
},
});
const nodeTypes = mock<INodeTypes>();
const expression = mock<Expression>();
const workflow = mock<Workflow>({ expression, nodeTypes });
const node = mock<INode>({
name: 'Test Node',
credentials: {
[testCredentialType]: {
id: 'testCredentialId',
},
},
});
node.parameters = {
testParameter: 'testValue',
};
const credentialsHelper = mock<ICredentialsHelper>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
const mode: WorkflowExecuteMode = 'manual';
const runExecutionData = mock<IRunExecutionData>();
const connectionInputData: INodeExecutionData[] = [];
const inputData: ITaskDataConnections = { main: [[{ json: { test: 'data' } }]] };
const executeData = mock<IExecuteData>();
const runIndex = 0;
const itemIndex = 0;
const abortSignal = mock<AbortSignal>();
const executeSingleContext = new ExecuteSingleContext(
workflow,
node,
additionalData,
mode,
runExecutionData,
runIndex,
connectionInputData,
inputData,
itemIndex,
executeData,
abortSignal,
);
beforeEach(() => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
expression.getParameterValue.mockImplementation((value) => value);
});
describeCommonTests(executeSingleContext, {
abortSignal,
node,
workflow,
executeData,
runExecutionData,
});
describe('getInputData', () => {
const inputIndex = 0;
const connectionType = NodeConnectionType.Main;
afterEach(() => {
inputData[connectionType] = [[{ json: { test: 'data' } }]];
});
it('should return the input data correctly', () => {
const expectedData = { json: { test: 'data' } };
expect(executeSingleContext.getInputData(inputIndex, connectionType)).toEqual(expectedData);
});
it('should return an empty object if the input name does not exist', () => {
const connectionType = 'nonExistent';
const expectedData = { json: {} };
expect(
executeSingleContext.getInputData(inputIndex, connectionType as NodeConnectionType),
).toEqual(expectedData);
});
it('should throw an error if the input index is out of range', () => {
const inputIndex = 1;
expect(() => executeSingleContext.getInputData(inputIndex, connectionType)).toThrow(
ApplicationError,
);
});
it('should throw an error if the input index was not set', () => {
inputData.main[inputIndex] = null;
expect(() => executeSingleContext.getInputData(inputIndex, connectionType)).toThrow(
ApplicationError,
);
});
it('should throw an error if the value of input with given index was not set', () => {
delete inputData.main[inputIndex]![itemIndex];
expect(() => executeSingleContext.getInputData(inputIndex, connectionType)).toThrow(
ApplicationError,
);
});
});
describe('getItemIndex', () => {
it('should return the item index correctly', () => {
expect(executeSingleContext.getItemIndex()).toEqual(itemIndex);
});
});
describe('getNodeParameter', () => {
beforeEach(() => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
expression.getParameterValue.mockImplementation((value) => value);
});
it('should return parameter value when it exists', () => {
const parameter = executeSingleContext.getNodeParameter('testParameter');
expect(parameter).toBe('testValue');
});
it('should return the fallback value when the parameter does not exist', () => {
const parameter = executeSingleContext.getNodeParameter('otherParameter', 'fallback');
expect(parameter).toBe('fallback');
});
});
describe('getCredentials', () => {
it('should get decrypted credentials', async () => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
const credentials =
await executeSingleContext.getCredentials<ICredentialDataDecryptedObject>(
testCredentialType,
);
expect(credentials).toEqual({ secret: 'token' });
});
});
describe('getExecuteData', () => {
it('should return the execute data correctly', () => {
expect(executeSingleContext.getExecuteData()).toEqual(executeData);
});
});
describe('getWorkflowDataProxy', () => {
it('should return the workflow data proxy correctly', () => {
const workflowDataProxy = executeSingleContext.getWorkflowDataProxy();
expect(workflowDataProxy.isProxy).toBe(true);
expect(Object.keys(workflowDataProxy.$input)).toEqual([
'all',
'context',
'first',
'item',
'last',
'params',
]);
});
});
});

View File

@@ -0,0 +1,147 @@
import { mock } from 'jest-mock-extended';
import type {
Expression,
ICredentialDataDecryptedObject,
ICredentialsHelper,
INode,
INodeType,
INodeTypes,
IWebhookDescription,
IWebhookData,
IWorkflowExecuteAdditionalData,
Workflow,
WorkflowActivateMode,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { ApplicationError } from 'n8n-workflow';
import { HookContext } from '../hook-context';
describe('HookContext', () => {
const testCredentialType = 'testCredential';
const webhookDescription: IWebhookDescription = {
name: 'default',
httpMethod: 'GET',
responseMode: 'onReceived',
path: 'testPath',
};
const nodeType = mock<INodeType>({
description: {
credentials: [
{
name: testCredentialType,
required: true,
},
],
properties: [
{
name: 'testParameter',
required: true,
},
],
},
});
nodeType.description.webhooks = [webhookDescription];
const nodeTypes = mock<INodeTypes>();
const expression = mock<Expression>();
const workflow = mock<Workflow>({ expression, nodeTypes });
const node = mock<INode>({
credentials: {
[testCredentialType]: {
id: 'testCredentialId',
},
},
});
node.parameters = {
testParameter: 'testValue',
};
const credentialsHelper = mock<ICredentialsHelper>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
const mode: WorkflowExecuteMode = 'manual';
const activation: WorkflowActivateMode = 'init';
const webhookData = mock<IWebhookData>({
webhookDescription: {
name: 'default',
isFullPath: true,
},
});
const hookContext = new HookContext(
workflow,
node,
additionalData,
mode,
activation,
webhookData,
);
beforeEach(() => {
jest.clearAllMocks();
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
expression.getParameterValue.mockImplementation((value) => value);
expression.getSimpleParameterValue.mockImplementation((_, value) => value);
});
describe('getActivationMode', () => {
it('should return the activation property', () => {
const result = hookContext.getActivationMode();
expect(result).toBe(activation);
});
});
describe('getCredentials', () => {
it('should get decrypted credentials', async () => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
const credentials =
await hookContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
expect(credentials).toEqual({ secret: 'token' });
});
});
describe('getNodeParameter', () => {
it('should return parameter value when it exists', () => {
const parameter = hookContext.getNodeParameter('testParameter');
expect(parameter).toBe('testValue');
});
});
describe('getNodeWebhookUrl', () => {
it('should return node webhook url', () => {
const url = hookContext.getNodeWebhookUrl('default');
expect(url).toContain('testPath');
});
});
describe('getWebhookName', () => {
it('should return webhook name', () => {
const name = hookContext.getWebhookName();
expect(name).toBe('default');
});
it('should throw an error if webhookData is undefined', () => {
const hookContextWithoutWebhookData = new HookContext(
workflow,
node,
additionalData,
mode,
activation,
);
expect(() => hookContextWithoutWebhookData.getWebhookName()).toThrow(ApplicationError);
});
});
describe('getWebhookDescription', () => {
it('should return webhook description', () => {
const description = hookContext.getWebhookDescription('default');
expect(description).toEqual<IWebhookDescription>(webhookDescription);
});
});
});

View File

@@ -0,0 +1,102 @@
import { mock } from 'jest-mock-extended';
import type {
Expression,
ICredentialDataDecryptedObject,
ICredentialsHelper,
INode,
INodeType,
INodeTypes,
IWorkflowExecuteAdditionalData,
Workflow,
} from 'n8n-workflow';
import { LoadOptionsContext } from '../load-options-context';
describe('LoadOptionsContext', () => {
const testCredentialType = 'testCredential';
const nodeType = mock<INodeType>({
description: {
credentials: [
{
name: testCredentialType,
required: true,
},
],
properties: [
{
name: 'testParameter',
required: true,
},
],
},
});
const nodeTypes = mock<INodeTypes>();
const expression = mock<Expression>();
const workflow = mock<Workflow>({ expression, nodeTypes });
const node = mock<INode>({
credentials: {
[testCredentialType]: {
id: 'testCredentialId',
},
},
});
node.parameters = {
testParameter: 'testValue',
};
const credentialsHelper = mock<ICredentialsHelper>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
const path = 'testPath';
const loadOptionsContext = new LoadOptionsContext(workflow, node, additionalData, path);
beforeEach(() => {
jest.clearAllMocks();
});
describe('getCredentials', () => {
it('should get decrypted credentials', async () => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
const credentials =
await loadOptionsContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
expect(credentials).toEqual({ secret: 'token' });
});
});
describe('getCurrentNodeParameter', () => {
beforeEach(() => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
});
it('should return parameter value when it exists', () => {
additionalData.currentNodeParameters = {
testParameter: 'testValue',
};
const parameter = loadOptionsContext.getCurrentNodeParameter('testParameter');
expect(parameter).toBe('testValue');
});
});
describe('getNodeParameter', () => {
beforeEach(() => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
expression.getParameterValue.mockImplementation((value) => value);
});
it('should return parameter value when it exists', () => {
const parameter = loadOptionsContext.getNodeParameter('testParameter');
expect(parameter).toBe('testValue');
});
it('should return the fallback value when the parameter does not exist', () => {
const parameter = loadOptionsContext.getNodeParameter('otherParameter', 'fallback');
expect(parameter).toBe('fallback');
});
});
});

View File

@@ -0,0 +1,338 @@
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import type {
Expression,
INode,
INodeType,
INodeTypes,
INodeExecutionData,
IWorkflowExecuteAdditionalData,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow';
import { InstanceSettings } from '@/instance-settings';
import { NodeExecutionContext } from '../node-execution-context';
class TestContext extends NodeExecutionContext {}
describe('NodeExecutionContext', () => {
const instanceSettings = mock<InstanceSettings>({ instanceId: 'abc123' });
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>({
id: '123',
name: 'Test Workflow',
active: true,
nodeTypes,
timezone: 'UTC',
expression,
});
let additionalData = mock<IWorkflowExecuteAdditionalData>({
credentialsHelper: mock(),
});
const mode: WorkflowExecuteMode = 'manual';
let testContext: TestContext;
beforeEach(() => {
jest.clearAllMocks();
testContext = new TestContext(workflow, node, additionalData, mode);
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
});
describe('getNode', () => {
it('should return a deep copy of the node', () => {
const result = testContext.getNode();
expect(result).not.toBe(node);
expect(JSON.stringify(result)).toEqual(JSON.stringify(node));
});
});
describe('getWorkflow', () => {
it('should return the id, name, and active properties of the workflow', () => {
const result = testContext.getWorkflow();
expect(result).toEqual({ id: '123', name: 'Test Workflow', active: true });
});
});
describe('getMode', () => {
it('should return the mode property', () => {
const result = testContext.getMode();
expect(result).toBe(mode);
});
});
describe('getWorkflowStaticData', () => {
it('should call getStaticData method of workflow', () => {
testContext.getWorkflowStaticData('testType');
expect(workflow.getStaticData).toHaveBeenCalledWith('testType', node);
});
});
describe('getChildNodes', () => {
it('should return an array of NodeTypeAndVersion objects for the child nodes of the given node', () => {
const childNode1 = mock<INode>({ name: 'Child Node 1', type: 'testType1', typeVersion: 1 });
const childNode2 = mock<INode>({ name: 'Child Node 2', type: 'testType2', typeVersion: 2 });
workflow.getChildNodes.mockReturnValue(['Child Node 1', 'Child Node 2']);
workflow.nodes = {
'Child Node 1': childNode1,
'Child Node 2': childNode2,
};
const result = testContext.getChildNodes('Test Node');
expect(result).toMatchObject([
{ name: 'Child Node 1', type: 'testType1', typeVersion: 1 },
{ name: 'Child Node 2', type: 'testType2', typeVersion: 2 },
]);
});
});
describe('getParentNodes', () => {
it('should return an array of NodeTypeAndVersion objects for the parent nodes of the given node', () => {
const parentNode1 = mock<INode>({ name: 'Parent Node 1', type: 'testType1', typeVersion: 1 });
const parentNode2 = mock<INode>({ name: 'Parent Node 2', type: 'testType2', typeVersion: 2 });
workflow.getParentNodes.mockReturnValue(['Parent Node 1', 'Parent Node 2']);
workflow.nodes = {
'Parent Node 1': parentNode1,
'Parent Node 2': parentNode2,
};
const result = testContext.getParentNodes('Test Node');
expect(result).toMatchObject([
{ name: 'Parent Node 1', type: 'testType1', typeVersion: 1 },
{ name: 'Parent Node 2', type: 'testType2', typeVersion: 2 },
]);
});
});
describe('getKnownNodeTypes', () => {
it('should call getKnownTypes method of nodeTypes', () => {
testContext.getKnownNodeTypes();
expect(nodeTypes.getKnownTypes).toHaveBeenCalled();
});
});
describe('getRestApiUrl', () => {
it('should return the restApiUrl property of additionalData', () => {
additionalData.restApiUrl = 'https://example.com/api';
const result = testContext.getRestApiUrl();
expect(result).toBe('https://example.com/api');
});
});
describe('getInstanceBaseUrl', () => {
it('should return the instanceBaseUrl property of additionalData', () => {
additionalData.instanceBaseUrl = 'https://example.com';
const result = testContext.getInstanceBaseUrl();
expect(result).toBe('https://example.com');
});
});
describe('getInstanceId', () => {
it('should return the instanceId property of instanceSettings', () => {
const result = testContext.getInstanceId();
expect(result).toBe('abc123');
});
});
describe('getTimezone', () => {
it('should return the timezone property of workflow', () => {
const result = testContext.getTimezone();
expect(result).toBe('UTC');
});
});
describe('getCredentialsProperties', () => {
it('should call getCredentialsProperties method of additionalData.credentialsHelper', () => {
testContext.getCredentialsProperties('testType');
expect(additionalData.credentialsHelper.getCredentialsProperties).toHaveBeenCalledWith(
'testType',
);
});
});
describe('prepareOutputData', () => {
it('should return the input array wrapped in another array', async () => {
const outputData = [mock<INodeExecutionData>(), mock<INodeExecutionData>()];
const result = await testContext.prepareOutputData(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]);
});
});
});

View File

@@ -0,0 +1,96 @@
import { mock } from 'jest-mock-extended';
import type {
Expression,
ICredentialDataDecryptedObject,
ICredentialsHelper,
INode,
INodeType,
INodeTypes,
IWorkflowExecuteAdditionalData,
Workflow,
WorkflowActivateMode,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { PollContext } from '../poll-context';
describe('PollContext', () => {
const testCredentialType = 'testCredential';
const nodeType = mock<INodeType>({
description: {
credentials: [
{
name: testCredentialType,
required: true,
},
],
properties: [
{
name: 'testParameter',
required: true,
},
],
},
});
const nodeTypes = mock<INodeTypes>();
const expression = mock<Expression>();
const workflow = mock<Workflow>({ expression, nodeTypes });
const node = mock<INode>({
credentials: {
[testCredentialType]: {
id: 'testCredentialId',
},
},
});
node.parameters = {
testParameter: 'testValue',
};
const credentialsHelper = mock<ICredentialsHelper>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
const mode: WorkflowExecuteMode = 'manual';
const activation: WorkflowActivateMode = 'init';
const pollContext = new PollContext(workflow, node, additionalData, mode, activation);
beforeEach(() => {
jest.clearAllMocks();
});
describe('getActivationMode', () => {
it('should return the activation property', () => {
const result = pollContext.getActivationMode();
expect(result).toBe(activation);
});
});
describe('getCredentials', () => {
it('should get decrypted credentials', async () => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
const credentials =
await pollContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
expect(credentials).toEqual({ secret: 'token' });
});
});
describe('getNodeParameter', () => {
beforeEach(() => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
expression.getParameterValue.mockImplementation((value) => value);
});
it('should return parameter value when it exists', () => {
const parameter = pollContext.getNodeParameter('testParameter');
expect(parameter).toBe('testValue');
});
it('should return the fallback value when the parameter does not exist', () => {
const parameter = pollContext.getNodeParameter('otherParameter', 'fallback');
expect(parameter).toBe('fallback');
});
});
});

View File

@@ -0,0 +1,242 @@
import { Container } from '@n8n/di';
import { captor, mock, type MockProxy } from 'jest-mock-extended';
import type {
IRunExecutionData,
ContextType,
IContextObject,
INode,
OnError,
Workflow,
ITaskMetadata,
ISourceData,
IExecuteData,
IWorkflowExecuteAdditionalData,
ExecuteWorkflowData,
RelatedExecution,
IExecuteWorkflowInfo,
} from 'n8n-workflow';
import { ApplicationError, NodeHelpers, WAIT_INDEFINITELY } from 'n8n-workflow';
import { BinaryDataService } from '@/binary-data/binary-data.service';
import type { BaseExecuteContext } from '../base-execute-context';
const binaryDataService = mock<BinaryDataService>();
Container.set(BinaryDataService, binaryDataService);
export const describeCommonTests = (
context: BaseExecuteContext,
{
abortSignal,
node,
workflow,
runExecutionData,
executeData,
}: {
abortSignal: AbortSignal;
node: INode;
workflow: Workflow;
runExecutionData: IRunExecutionData;
executeData: IExecuteData;
},
) => {
// @ts-expect-error `additionalData` is private
const additionalData = context.additionalData as MockProxy<IWorkflowExecuteAdditionalData>;
describe('getExecutionCancelSignal', () => {
it('should return the abort signal', () => {
expect(context.getExecutionCancelSignal()).toBe(abortSignal);
});
});
describe('onExecutionCancellation', () => {
const handler = jest.fn();
context.onExecutionCancellation(handler);
const fnCaptor = captor<() => void>();
expect(abortSignal.addEventListener).toHaveBeenCalledWith('abort', fnCaptor);
expect(handler).not.toHaveBeenCalled();
fnCaptor.value();
expect(abortSignal.removeEventListener).toHaveBeenCalledWith('abort', fnCaptor);
expect(handler).toHaveBeenCalled();
});
describe('continueOnFail', () => {
afterEach(() => {
node.onError = undefined;
node.continueOnFail = false;
});
it('should return false for nodes by default', () => {
expect(context.continueOnFail()).toEqual(false);
});
it('should return true if node has continueOnFail set to true', () => {
node.continueOnFail = true;
expect(context.continueOnFail()).toEqual(true);
});
test.each([
['continueRegularOutput', true],
['continueErrorOutput', true],
['stopWorkflow', false],
])('if node has onError set to %s, it should return %s', (onError, expected) => {
node.onError = onError as OnError;
expect(context.continueOnFail()).toEqual(expected);
});
});
describe('getContext', () => {
it('should return the context object', () => {
const contextType: ContextType = 'node';
const expectedContext = mock<IContextObject>();
const getContextSpy = jest.spyOn(NodeHelpers, 'getContext');
getContextSpy.mockReturnValue(expectedContext);
expect(context.getContext(contextType)).toEqual(expectedContext);
expect(getContextSpy).toHaveBeenCalledWith(runExecutionData, contextType, node);
getContextSpy.mockRestore();
});
});
describe('sendMessageToUI', () => {
it('should send console messages to the frontend', () => {
context.sendMessageToUI('Testing', 1, 2, {});
expect(additionalData.sendDataToUI).toHaveBeenCalledWith('sendConsoleMessage', {
source: '[Node: "Test Node"]',
messages: ['Testing', 1, 2, {}],
});
});
});
describe('logAiEvent', () => {
it('should log the AI event correctly', () => {
const eventName = 'ai-tool-called';
const msg = 'test message';
context.logAiEvent(eventName, msg);
expect(additionalData.logAiEvent).toHaveBeenCalledWith(eventName, {
executionId: additionalData.executionId,
nodeName: node.name,
workflowName: workflow.name,
nodeType: node.type,
workflowId: workflow.id,
msg,
});
});
});
describe('getInputSourceData', () => {
it('should return the input source data correctly', () => {
const inputSourceData = mock<ISourceData>();
executeData.source = { main: [inputSourceData] };
expect(context.getInputSourceData()).toEqual(inputSourceData);
});
it('should throw an error if the source data is missing', () => {
executeData.source = null;
expect(() => context.getInputSourceData()).toThrow(ApplicationError);
});
});
describe('setMetadata', () => {
it('sets metadata on execution data', () => {
const metadata: ITaskMetadata = {
subExecution: {
workflowId: '123',
executionId: 'xyz',
},
};
expect(context.getExecuteData().metadata?.subExecution).toEqual(undefined);
context.setMetadata(metadata);
expect(context.getExecuteData().metadata?.subExecution).toEqual(metadata.subExecution);
});
});
describe('evaluateExpression', () => {
it('should evaluate the expression correctly', () => {
const expression = '$json.test';
const expectedResult = 'data';
const resolveSimpleParameterValueSpy = jest.spyOn(
workflow.expression,
'resolveSimpleParameterValue',
);
resolveSimpleParameterValueSpy.mockReturnValue(expectedResult);
expect(context.evaluateExpression(expression, 0)).toEqual(expectedResult);
expect(resolveSimpleParameterValueSpy).toHaveBeenCalledWith(
`=${expression}`,
{},
runExecutionData,
0,
0,
node.name,
[],
'manual',
expect.objectContaining({}),
executeData,
);
resolveSimpleParameterValueSpy.mockRestore();
});
});
describe('putExecutionToWait', () => {
it('should set waitTill and execution status', async () => {
const waitTill = new Date();
await context.putExecutionToWait(waitTill);
expect(runExecutionData.waitTill).toEqual(waitTill);
expect(additionalData.setExecutionStatus).toHaveBeenCalledWith('waiting');
});
});
describe('executeWorkflow', () => {
const data = [[{ json: { test: true } }]];
const executeWorkflowData = mock<ExecuteWorkflowData>();
const workflowInfo = mock<IExecuteWorkflowInfo>();
const parentExecution: RelatedExecution = {
executionId: 'parent_execution_id',
workflowId: 'parent_workflow_id',
};
it('should execute workflow and return data', async () => {
additionalData.executeWorkflow.mockResolvedValue(executeWorkflowData);
binaryDataService.duplicateBinaryData.mockResolvedValue(data);
const result = await context.executeWorkflow(workflowInfo, undefined, undefined, {
parentExecution,
});
expect(result.data).toEqual(data);
expect(binaryDataService.duplicateBinaryData).toHaveBeenCalledWith(
workflow.id,
additionalData.executionId,
executeWorkflowData.data,
);
});
it('should put execution to wait if waitTill is returned', async () => {
const waitTill = new Date();
additionalData.executeWorkflow.mockResolvedValue({ ...executeWorkflowData, waitTill });
binaryDataService.duplicateBinaryData.mockResolvedValue(data);
const result = await context.executeWorkflow(workflowInfo, undefined, undefined, {
parentExecution,
});
expect(additionalData.setExecutionStatus).toHaveBeenCalledWith('waiting');
expect(runExecutionData.waitTill).toEqual(WAIT_INDEFINITELY);
expect(result.waitTill).toBe(waitTill);
});
});
};

View File

@@ -0,0 +1,178 @@
import { mock } from 'jest-mock-extended';
import type {
INode,
IWorkflowExecuteAdditionalData,
IRunExecutionData,
INodeExecutionData,
ITaskDataConnections,
IExecuteData,
Workflow,
WorkflowExecuteMode,
ICredentialsHelper,
Expression,
INodeType,
INodeTypes,
ICredentialDataDecryptedObject,
} from 'n8n-workflow';
import { ApplicationError, NodeConnectionType } from 'n8n-workflow';
import { describeCommonTests } from './shared-tests';
import { SupplyDataContext } from '../supply-data-context';
describe('SupplyDataContext', () => {
const testCredentialType = 'testCredential';
const nodeType = mock<INodeType>({
description: {
credentials: [
{
name: testCredentialType,
required: true,
},
],
properties: [
{
name: 'testParameter',
required: true,
},
],
},
});
const nodeTypes = mock<INodeTypes>();
const expression = mock<Expression>();
const workflow = mock<Workflow>({ expression, nodeTypes });
const node = mock<INode>({
name: 'Test Node',
credentials: {
[testCredentialType]: {
id: 'testCredentialId',
},
},
});
node.parameters = {
testParameter: 'testValue',
};
const credentialsHelper = mock<ICredentialsHelper>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
const mode: WorkflowExecuteMode = 'manual';
const runExecutionData = mock<IRunExecutionData>();
const connectionInputData: INodeExecutionData[] = [];
const connectionType = NodeConnectionType.Main;
const inputData: ITaskDataConnections = { [connectionType]: [[{ json: { test: 'data' } }]] };
const executeData = mock<IExecuteData>();
const runIndex = 0;
const closeFn = jest.fn();
const abortSignal = mock<AbortSignal>();
const supplyDataContext = new SupplyDataContext(
workflow,
node,
additionalData,
mode,
runExecutionData,
runIndex,
connectionInputData,
inputData,
connectionType,
executeData,
[closeFn],
abortSignal,
);
beforeEach(() => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
expression.getParameterValue.mockImplementation((value) => value);
});
describeCommonTests(supplyDataContext, {
abortSignal,
node,
workflow,
executeData,
runExecutionData,
});
describe('getInputData', () => {
const inputIndex = 0;
afterEach(() => {
inputData[connectionType] = [[{ json: { test: 'data' } }]];
});
it('should return the input data correctly', () => {
const expectedData = [{ json: { test: 'data' } }];
expect(supplyDataContext.getInputData(inputIndex, connectionType)).toEqual(expectedData);
});
it('should return an empty array if the input name does not exist', () => {
const connectionType = 'nonExistent';
expect(
supplyDataContext.getInputData(inputIndex, connectionType as NodeConnectionType),
).toEqual([]);
});
it('should throw an error if the input index is out of range', () => {
const inputIndex = 2;
expect(() => supplyDataContext.getInputData(inputIndex, connectionType)).toThrow(
ApplicationError,
);
});
it('should throw an error if the input index was not set', () => {
inputData.main[inputIndex] = null;
expect(() => supplyDataContext.getInputData(inputIndex, connectionType)).toThrow(
ApplicationError,
);
});
});
describe('getNodeParameter', () => {
beforeEach(() => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
expression.getParameterValue.mockImplementation((value) => value);
});
it('should return parameter value when it exists', () => {
const parameter = supplyDataContext.getNodeParameter('testParameter', 0);
expect(parameter).toBe('testValue');
});
it('should return the fallback value when the parameter does not exist', () => {
const parameter = supplyDataContext.getNodeParameter('otherParameter', 0, 'fallback');
expect(parameter).toBe('fallback');
});
});
describe('getCredentials', () => {
it('should get decrypted credentials', async () => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
const credentials = await supplyDataContext.getCredentials<ICredentialDataDecryptedObject>(
testCredentialType,
0,
);
expect(credentials).toEqual({ secret: 'token' });
});
});
describe('getWorkflowDataProxy', () => {
it('should return the workflow data proxy correctly', () => {
const workflowDataProxy = supplyDataContext.getWorkflowDataProxy(0);
expect(workflowDataProxy.isProxy).toBe(true);
expect(Object.keys(workflowDataProxy.$input)).toEqual([
'all',
'context',
'first',
'item',
'last',
'params',
]);
});
});
});

View File

@@ -0,0 +1,96 @@
import { mock } from 'jest-mock-extended';
import type {
Expression,
ICredentialDataDecryptedObject,
ICredentialsHelper,
INode,
INodeType,
INodeTypes,
IWorkflowExecuteAdditionalData,
Workflow,
WorkflowActivateMode,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { TriggerContext } from '../trigger-context';
describe('TriggerContext', () => {
const testCredentialType = 'testCredential';
const nodeType = mock<INodeType>({
description: {
credentials: [
{
name: testCredentialType,
required: true,
},
],
properties: [
{
name: 'testParameter',
required: true,
},
],
},
});
const nodeTypes = mock<INodeTypes>();
const expression = mock<Expression>();
const workflow = mock<Workflow>({ expression, nodeTypes });
const node = mock<INode>({
credentials: {
[testCredentialType]: {
id: 'testCredentialId',
},
},
});
node.parameters = {
testParameter: 'testValue',
};
const credentialsHelper = mock<ICredentialsHelper>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
const mode: WorkflowExecuteMode = 'manual';
const activation: WorkflowActivateMode = 'init';
const triggerContext = new TriggerContext(workflow, node, additionalData, mode, activation);
beforeEach(() => {
jest.clearAllMocks();
});
describe('getActivationMode', () => {
it('should return the activation property', () => {
const result = triggerContext.getActivationMode();
expect(result).toBe(activation);
});
});
describe('getCredentials', () => {
it('should get decrypted credentials', async () => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
const credentials =
await triggerContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
expect(credentials).toEqual({ secret: 'token' });
});
});
describe('getNodeParameter', () => {
beforeEach(() => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
expression.getParameterValue.mockImplementation((value) => value);
});
it('should return parameter value when it exists', () => {
const parameter = triggerContext.getNodeParameter('testParameter');
expect(parameter).toBe('testValue');
});
it('should return the fallback value when the parameter does not exist', () => {
const parameter = triggerContext.getNodeParameter('otherParameter', 'fallback');
expect(parameter).toBe('fallback');
});
});
});

View File

@@ -0,0 +1,161 @@
import type { Request, Response } from 'express';
import { mock } from 'jest-mock-extended';
import type {
Expression,
ICredentialDataDecryptedObject,
ICredentialsHelper,
INode,
INodeType,
INodeTypes,
IWebhookData,
IWorkflowExecuteAdditionalData,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { WebhookContext } from '../webhook-context';
describe('WebhookContext', () => {
const testCredentialType = 'testCredential';
const nodeType = mock<INodeType>({
description: {
credentials: [
{
name: testCredentialType,
required: true,
},
],
properties: [
{
name: 'testParameter',
required: true,
},
],
},
});
const nodeTypes = mock<INodeTypes>();
const expression = mock<Expression>();
const workflow = mock<Workflow>({ expression, nodeTypes });
const node = mock<INode>({
credentials: {
[testCredentialType]: {
id: 'testCredentialId',
},
},
});
node.parameters = {
testParameter: 'testValue',
};
const credentialsHelper = mock<ICredentialsHelper>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({
credentialsHelper,
});
additionalData.httpRequest = {
body: { test: 'body' },
headers: { test: 'header' },
params: { test: 'param' },
query: { test: 'query' },
} as unknown as Request;
additionalData.httpResponse = mock<Response>();
const mode: WorkflowExecuteMode = 'manual';
const webhookData = mock<IWebhookData>({
webhookDescription: {
name: 'default',
},
});
const runExecutionData = null;
const webhookContext = new WebhookContext(
workflow,
node,
additionalData,
mode,
webhookData,
[],
runExecutionData,
);
beforeEach(() => {
jest.clearAllMocks();
});
describe('getCredentials', () => {
it('should get decrypted credentials', async () => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
const credentials =
await webhookContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
expect(credentials).toEqual({ secret: 'token' });
});
});
describe('getBodyData', () => {
it('should return the body data of the request', () => {
const bodyData = webhookContext.getBodyData();
expect(bodyData).toEqual({ test: 'body' });
});
});
describe('getHeaderData', () => {
it('should return the header data of the request', () => {
const headerData = webhookContext.getHeaderData();
expect(headerData).toEqual({ test: 'header' });
});
});
describe('getParamsData', () => {
it('should return the params data of the request', () => {
const paramsData = webhookContext.getParamsData();
expect(paramsData).toEqual({ test: 'param' });
});
});
describe('getQueryData', () => {
it('should return the query data of the request', () => {
const queryData = webhookContext.getQueryData();
expect(queryData).toEqual({ test: 'query' });
});
});
describe('getRequestObject', () => {
it('should return the request object', () => {
const request = webhookContext.getRequestObject();
expect(request).toBe(additionalData.httpRequest);
});
});
describe('getResponseObject', () => {
it('should return the response object', () => {
const response = webhookContext.getResponseObject();
expect(response).toBe(additionalData.httpResponse);
});
});
describe('getWebhookName', () => {
it('should return the name of the webhook', () => {
const webhookName = webhookContext.getWebhookName();
expect(webhookName).toBe('default');
});
});
describe('getNodeParameter', () => {
beforeEach(() => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
expression.getParameterValue.mockImplementation((value) => value);
});
it('should return parameter value when it exists', () => {
const parameter = webhookContext.getNodeParameter('testParameter');
expect(parameter).toBe('testValue');
});
it('should return the fallback value when the parameter does not exist', () => {
const parameter = webhookContext.getNodeParameter('otherParameter', 'fallback');
expect(parameter).toBe('fallback');
});
});
});

View File

@@ -0,0 +1,227 @@
import { Container } from '@n8n/di';
import { get } from 'lodash';
import type {
Workflow,
INode,
IWorkflowExecuteAdditionalData,
WorkflowExecuteMode,
IRunExecutionData,
INodeExecutionData,
ITaskDataConnections,
IExecuteData,
ICredentialDataDecryptedObject,
CallbackManager,
IExecuteWorkflowInfo,
RelatedExecution,
ExecuteWorkflowData,
ITaskMetadata,
ContextType,
IContextObject,
IWorkflowDataProxyData,
ISourceData,
AiEvent,
} from 'n8n-workflow';
import {
ApplicationError,
NodeHelpers,
NodeConnectionType,
WAIT_INDEFINITELY,
WorkflowDataProxy,
} from 'n8n-workflow';
import { BinaryDataService } from '@/binary-data/binary-data.service';
import { NodeExecutionContext } from './node-execution-context';
export class BaseExecuteContext extends NodeExecutionContext {
protected readonly binaryDataService = Container.get(BinaryDataService);
constructor(
workflow: Workflow,
node: INode,
additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode,
protected readonly runExecutionData: IRunExecutionData,
runIndex: number,
protected readonly connectionInputData: INodeExecutionData[],
protected readonly inputData: ITaskDataConnections,
protected readonly executeData: IExecuteData,
protected readonly abortSignal?: AbortSignal,
) {
super(workflow, node, additionalData, mode, runExecutionData, runIndex);
}
getExecutionCancelSignal() {
return this.abortSignal;
}
onExecutionCancellation(handler: () => unknown) {
const fn = () => {
this.abortSignal?.removeEventListener('abort', fn);
handler();
};
this.abortSignal?.addEventListener('abort', fn);
}
getExecuteData() {
return this.executeData;
}
setMetadata(metadata: ITaskMetadata): void {
this.executeData.metadata = {
...(this.executeData.metadata ?? {}),
...metadata,
};
}
getContext(type: ContextType): IContextObject {
return NodeHelpers.getContext(this.runExecutionData, type, this.node);
}
/** Returns if execution should be continued even if there was an error */
continueOnFail(): boolean {
const onError = get(this.node, 'onError', undefined);
if (onError === undefined) {
return get(this.node, 'continueOnFail', false);
}
return ['continueRegularOutput', 'continueErrorOutput'].includes(onError);
}
async getCredentials<T extends object = ICredentialDataDecryptedObject>(
type: string,
itemIndex: number,
) {
return await this._getCredentials<T>(
type,
this.executeData,
this.connectionInputData,
itemIndex,
);
}
async putExecutionToWait(waitTill: Date): Promise<void> {
this.runExecutionData.waitTill = waitTill;
if (this.additionalData.setExecutionStatus) {
this.additionalData.setExecutionStatus('waiting');
}
}
async executeWorkflow(
workflowInfo: IExecuteWorkflowInfo,
inputData?: INodeExecutionData[],
parentCallbackManager?: CallbackManager,
options?: {
doNotWaitToFinish?: boolean;
parentExecution?: RelatedExecution;
},
): Promise<ExecuteWorkflowData> {
const result = await this.additionalData.executeWorkflow(workflowInfo, this.additionalData, {
...options,
parentWorkflowId: this.workflow.id,
inputData,
parentWorkflowSettings: this.workflow.settings,
node: this.node,
parentCallbackManager,
});
// If a sub-workflow execution goes into the waiting state
if (result.waitTill) {
// then put the parent workflow execution also into the waiting state,
// but do not use the sub-workflow `waitTill` to avoid WaitTracker resuming the parent execution at the same time as the sub-workflow
await this.putExecutionToWait(WAIT_INDEFINITELY);
}
const data = await this.binaryDataService.duplicateBinaryData(
this.workflow.id,
this.additionalData.executionId!,
result.data,
);
return { ...result, data };
}
protected getInputItems(inputIndex: number, connectionType: NodeConnectionType) {
const inputData = this.inputData[connectionType];
if (inputData.length < inputIndex) {
throw new ApplicationError('Could not get input with given index', {
extra: { inputIndex, connectionType },
});
}
const allItems = inputData[inputIndex] as INodeExecutionData[] | null | undefined;
if (allItems === null) {
throw new ApplicationError('Input index was not set', {
extra: { inputIndex, connectionType },
});
}
return allItems;
}
getInputSourceData(inputIndex = 0, connectionType = NodeConnectionType.Main): ISourceData {
if (this.executeData?.source === null) {
// Should never happen as n8n sets it automatically
throw new ApplicationError('Source data is missing');
}
return this.executeData.source[connectionType][inputIndex]!;
}
getWorkflowDataProxy(itemIndex: number): IWorkflowDataProxyData {
return new WorkflowDataProxy(
this.workflow,
this.runExecutionData,
this.runIndex,
itemIndex,
this.node.name,
this.connectionInputData,
{},
this.mode,
this.additionalKeys,
this.executeData,
).getDataProxy();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sendMessageToUI(...args: any[]): void {
if (this.mode !== 'manual') {
return;
}
try {
if (this.additionalData.sendDataToUI) {
args = args.map((arg) => {
// prevent invalid dates from being logged as null
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return
if (arg.isLuxonDateTime && arg.invalidReason) return { ...arg };
// log valid dates in human readable format, as in browser
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument
if (arg.isLuxonDateTime) return new Date(arg.ts).toString();
if (arg instanceof Date) return arg.toString();
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return arg;
});
this.additionalData.sendDataToUI('sendConsoleMessage', {
source: `[Node: "${this.node.name}"]`,
messages: args,
});
}
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
this.logger.warn(`There was a problem sending message to UI: ${error.message}`);
}
}
logAiEvent(eventName: AiEvent, msg: string) {
return this.additionalData.logAiEvent(eventName, {
executionId: this.additionalData.executionId ?? 'unsaved-execution',
nodeName: this.node.name,
workflowName: this.workflow.name ?? 'Unnamed workflow',
nodeType: this.node.type,
workflowId: this.workflow.id ?? 'unsaved-workflow',
msg,
});
}
}

View File

@@ -0,0 +1,211 @@
import type {
AINodeConnectionType,
CallbackManager,
CloseFunction,
IExecuteData,
IExecuteFunctions,
IExecuteResponsePromiseData,
IGetNodeParameterOptions,
INode,
INodeExecutionData,
IRunExecutionData,
ITaskDataConnections,
IWorkflowExecuteAdditionalData,
Result,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
import {
ApplicationError,
createDeferredPromise,
createEnvProviderState,
NodeConnectionType,
} from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import {
returnJsonArray,
copyInputItems,
normalizeItems,
constructExecutionMetaData,
assertBinaryData,
getBinaryDataBuffer,
copyBinaryFile,
getRequestHelperFunctions,
getBinaryHelperFunctions,
getSSHTunnelFunctions,
getFileSystemHelperFunctions,
getCheckProcessedHelperFunctions,
detectBinaryEncoding,
} from '@/node-execute-functions';
import { BaseExecuteContext } from './base-execute-context';
import { getInputConnectionData } from './utils/get-input-connection-data';
export class ExecuteContext extends BaseExecuteContext implements IExecuteFunctions {
readonly helpers: IExecuteFunctions['helpers'];
readonly nodeHelpers: IExecuteFunctions['nodeHelpers'];
readonly getNodeParameter: IExecuteFunctions['getNodeParameter'];
constructor(
workflow: Workflow,
node: INode,
additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode,
runExecutionData: IRunExecutionData,
runIndex: number,
connectionInputData: INodeExecutionData[],
inputData: ITaskDataConnections,
executeData: IExecuteData,
private readonly closeFunctions: CloseFunction[],
abortSignal?: AbortSignal,
) {
super(
workflow,
node,
additionalData,
mode,
runExecutionData,
runIndex,
connectionInputData,
inputData,
executeData,
abortSignal,
);
this.helpers = {
createDeferredPromise,
returnJsonArray,
copyInputItems,
normalizeItems,
constructExecutionMetaData,
...getRequestHelperFunctions(
workflow,
node,
additionalData,
runExecutionData,
connectionInputData,
),
...getBinaryHelperFunctions(additionalData, workflow.id),
...getSSHTunnelFunctions(),
...getFileSystemHelperFunctions(node),
...getCheckProcessedHelperFunctions(workflow, node),
assertBinaryData: (itemIndex, propertyName) =>
assertBinaryData(inputData, node, itemIndex, propertyName, 0),
getBinaryDataBuffer: async (itemIndex, propertyName) =>
await getBinaryDataBuffer(inputData, itemIndex, propertyName, 0),
detectBinaryEncoding: (buffer: Buffer) => detectBinaryEncoding(buffer),
};
this.nodeHelpers = {
copyBinaryFile: async (filePath, fileName, mimeType) =>
await copyBinaryFile(
this.workflow.id,
this.additionalData.executionId!,
filePath,
fileName,
mimeType,
),
};
this.getNodeParameter = ((
parameterName: string,
itemIndex: number,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fallbackValue?: any,
options?: IGetNodeParameterOptions,
) =>
this._getNodeParameter(
parameterName,
itemIndex,
fallbackValue,
options,
)) as IExecuteFunctions['getNodeParameter'];
}
async startJob<T = unknown, E = unknown>(
jobType: string,
settings: unknown,
itemIndex: number,
): Promise<Result<T, E>> {
return await this.additionalData.startRunnerTask<T, E>(
this.additionalData,
jobType,
settings,
this,
this.inputData,
this.node,
this.workflow,
this.runExecutionData,
this.runIndex,
itemIndex,
this.node.name,
this.connectionInputData,
{},
this.mode,
createEnvProviderState(),
this.executeData,
);
}
async getInputConnectionData(
connectionType: AINodeConnectionType,
itemIndex: number,
): Promise<unknown> {
return await getInputConnectionData.call(
this,
this.workflow,
this.runExecutionData,
this.runIndex,
this.connectionInputData,
this.inputData,
this.additionalData,
this.executeData,
this.mode,
this.closeFunctions,
connectionType,
itemIndex,
this.abortSignal,
);
}
getInputData(inputIndex = 0, connectionType = NodeConnectionType.Main) {
if (!this.inputData.hasOwnProperty(connectionType)) {
// Return empty array because else it would throw error when nothing is connected to input
return [];
}
return super.getInputItems(inputIndex, connectionType) ?? [];
}
logNodeOutput(...args: unknown[]): void {
if (this.mode === 'manual') {
this.sendMessageToUI(...args);
return;
}
if (process.env.CODE_ENABLE_STDOUT === 'true') {
console.log(`[Workflow "${this.getWorkflow().id}"][Node "${this.node.name}"]`, ...args);
}
}
async sendResponse(response: IExecuteResponsePromiseData): Promise<void> {
await this.additionalData.hooks?.executeHookFunctions('sendResponse', [response]);
}
/** @deprecated use ISupplyDataFunctions.addInputData */
addInputData(): { index: number } {
throw new ApplicationError('addInputData should not be called on IExecuteFunctions');
}
/** @deprecated use ISupplyDataFunctions.addOutputData */
addOutputData(): void {
throw new ApplicationError('addOutputData should not be called on IExecuteFunctions');
}
getParentCallbackManager(): CallbackManager | undefined {
return this.additionalData.parentCallbackManager;
}
}

View File

@@ -0,0 +1,115 @@
import type {
ICredentialDataDecryptedObject,
IGetNodeParameterOptions,
INode,
INodeExecutionData,
IRunExecutionData,
IExecuteSingleFunctions,
IWorkflowExecuteAdditionalData,
Workflow,
WorkflowExecuteMode,
ITaskDataConnections,
IExecuteData,
} from 'n8n-workflow';
import { ApplicationError, createDeferredPromise, NodeConnectionType } from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import {
assertBinaryData,
detectBinaryEncoding,
getBinaryDataBuffer,
getBinaryHelperFunctions,
getRequestHelperFunctions,
returnJsonArray,
} from '@/node-execute-functions';
import { BaseExecuteContext } from './base-execute-context';
export class ExecuteSingleContext extends BaseExecuteContext implements IExecuteSingleFunctions {
readonly helpers: IExecuteSingleFunctions['helpers'];
constructor(
workflow: Workflow,
node: INode,
additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode,
runExecutionData: IRunExecutionData,
runIndex: number,
connectionInputData: INodeExecutionData[],
inputData: ITaskDataConnections,
private readonly itemIndex: number,
executeData: IExecuteData,
abortSignal?: AbortSignal,
) {
super(
workflow,
node,
additionalData,
mode,
runExecutionData,
runIndex,
connectionInputData,
inputData,
executeData,
abortSignal,
);
this.helpers = {
createDeferredPromise,
returnJsonArray,
...getRequestHelperFunctions(
workflow,
node,
additionalData,
runExecutionData,
connectionInputData,
),
...getBinaryHelperFunctions(additionalData, workflow.id),
assertBinaryData: (propertyName, inputIndex = 0) =>
assertBinaryData(inputData, node, itemIndex, propertyName, inputIndex),
getBinaryDataBuffer: async (propertyName, inputIndex = 0) =>
await getBinaryDataBuffer(inputData, itemIndex, propertyName, inputIndex),
detectBinaryEncoding: (buffer) => detectBinaryEncoding(buffer),
};
}
evaluateExpression(expression: string, itemIndex: number = this.itemIndex) {
return super.evaluateExpression(expression, itemIndex);
}
getInputData(inputIndex = 0, connectionType = NodeConnectionType.Main) {
if (!this.inputData.hasOwnProperty(connectionType)) {
// Return empty array because else it would throw error when nothing is connected to input
return { json: {} };
}
const allItems = super.getInputItems(inputIndex, connectionType);
const data = allItems?.[this.itemIndex];
if (data === undefined) {
throw new ApplicationError('Value of input with given index was not set', {
extra: { inputIndex, connectionType, itemIndex: this.itemIndex },
});
}
return data;
}
getItemIndex() {
return this.itemIndex;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getNodeParameter(parameterName: string, fallbackValue?: any, options?: IGetNodeParameterOptions) {
return this._getNodeParameter(parameterName, this.itemIndex, fallbackValue, options);
}
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
return await super.getCredentials<T>(type, this.itemIndex);
}
getWorkflowDataProxy() {
return super.getWorkflowDataProxy(this.itemIndex);
}
}

View File

@@ -0,0 +1,69 @@
import type {
ICredentialDataDecryptedObject,
INode,
IHookFunctions,
IWorkflowExecuteAdditionalData,
Workflow,
WorkflowActivateMode,
WorkflowExecuteMode,
IWebhookData,
WebhookType,
} from 'n8n-workflow';
import { ApplicationError } from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import {
getNodeWebhookUrl,
getRequestHelperFunctions,
getWebhookDescription,
} from '@/node-execute-functions';
import { NodeExecutionContext } from './node-execution-context';
export class HookContext extends NodeExecutionContext implements IHookFunctions {
readonly helpers: IHookFunctions['helpers'];
constructor(
workflow: Workflow,
node: INode,
additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode,
private readonly activation: WorkflowActivateMode,
private readonly webhookData?: IWebhookData,
) {
super(workflow, node, additionalData, mode);
this.helpers = getRequestHelperFunctions(workflow, node, additionalData);
}
getActivationMode() {
return this.activation;
}
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
return await this._getCredentials<T>(type);
}
getNodeWebhookUrl(name: WebhookType): string | undefined {
return getNodeWebhookUrl(
name,
this.workflow,
this.node,
this.additionalData,
this.mode,
this.additionalKeys,
this.webhookData?.isTest,
);
}
getWebhookName(): string {
if (this.webhookData === undefined) {
throw new ApplicationError('Only supported in webhook functions');
}
return this.webhookData.webhookDescription.name;
}
getWebhookDescription(name: WebhookType) {
return getWebhookDescription(name, this.workflow, this.node);
}
}

View File

@@ -0,0 +1,13 @@
// eslint-disable-next-line import/no-cycle
export { ExecuteContext } from './execute-context';
export { ExecuteSingleContext } from './execute-single-context';
export { HookContext } from './hook-context';
export { LoadOptionsContext } from './load-options-context';
export { LocalLoadOptionsContext } from './local-load-options-context';
export { PollContext } from './poll-context';
// eslint-disable-next-line import/no-cycle
export { SupplyDataContext } from './supply-data-context';
export { TriggerContext } from './trigger-context';
export { WebhookContext } from './webhook-context';
export { getAdditionalKeys } from './utils/get-additional-keys';

View File

@@ -0,0 +1,71 @@
import { get } from 'lodash';
import type {
ICredentialDataDecryptedObject,
IGetNodeParameterOptions,
INode,
ILoadOptionsFunctions,
IWorkflowExecuteAdditionalData,
NodeParameterValueType,
Workflow,
} from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import { getRequestHelperFunctions, getSSHTunnelFunctions } from '@/node-execute-functions';
import { NodeExecutionContext } from './node-execution-context';
import { extractValue } from './utils/extract-value';
export class LoadOptionsContext extends NodeExecutionContext implements ILoadOptionsFunctions {
readonly helpers: ILoadOptionsFunctions['helpers'];
constructor(
workflow: Workflow,
node: INode,
additionalData: IWorkflowExecuteAdditionalData,
private readonly path: string,
) {
super(workflow, node, additionalData, 'internal');
this.helpers = {
...getSSHTunnelFunctions(),
...getRequestHelperFunctions(workflow, node, additionalData),
};
}
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
return await this._getCredentials<T>(type);
}
getCurrentNodeParameter(
parameterPath: string,
options?: IGetNodeParameterOptions,
): NodeParameterValueType | object | undefined {
const nodeParameters = this.additionalData.currentNodeParameters;
if (parameterPath.charAt(0) === '&') {
parameterPath = `${this.path.split('.').slice(1, -1).join('.')}.${parameterPath.slice(1)}`;
}
let returnData = get(nodeParameters, parameterPath);
// This is outside the try/catch because it throws errors with proper messages
if (options?.extractValue) {
const nodeType = this.workflow.nodeTypes.getByNameAndVersion(
this.node.type,
this.node.typeVersion,
);
returnData = extractValue(
returnData,
parameterPath,
this.node,
nodeType,
) as NodeParameterValueType;
}
return returnData;
}
getCurrentNodeParameters() {
return this.additionalData.currentNodeParameters;
}
}

View File

@@ -0,0 +1,70 @@
import lodash from 'lodash';
import { ApplicationError, Workflow } from 'n8n-workflow';
import type {
INodeParameterResourceLocator,
IWorkflowExecuteAdditionalData,
NodeParameterValueType,
ILocalLoadOptionsFunctions,
IWorkflowLoader,
IWorkflowNodeContext,
INodeTypes,
} from 'n8n-workflow';
import { LoadWorkflowNodeContext } from './workflow-node-context';
export class LocalLoadOptionsContext implements ILocalLoadOptionsFunctions {
constructor(
private nodeTypes: INodeTypes,
private additionalData: IWorkflowExecuteAdditionalData,
private path: string,
private workflowLoader: IWorkflowLoader,
) {}
async getWorkflowNodeContext(nodeType: string): Promise<IWorkflowNodeContext | null> {
const { value: workflowId } = this.getCurrentNodeParameter(
'workflowId',
) as INodeParameterResourceLocator;
if (typeof workflowId !== 'string' || !workflowId) {
throw new ApplicationError(`No workflowId parameter defined on node of type "${nodeType}"!`);
}
const dbWorkflow = await this.workflowLoader.get(workflowId);
const selectedWorkflowNode = dbWorkflow.nodes.find((node) => node.type === nodeType);
if (selectedWorkflowNode) {
const selectedSingleNodeWorkflow = new Workflow({
nodes: [selectedWorkflowNode],
connections: {},
active: false,
nodeTypes: this.nodeTypes,
});
const workflowAdditionalData = {
...this.additionalData,
currentNodeParameters: selectedWorkflowNode.parameters,
};
return new LoadWorkflowNodeContext(
selectedSingleNodeWorkflow,
selectedWorkflowNode,
workflowAdditionalData,
);
}
return null;
}
getCurrentNodeParameter(parameterPath: string): NodeParameterValueType | object | undefined {
const nodeParameters = this.additionalData.currentNodeParameters;
if (parameterPath.startsWith('&')) {
parameterPath = `${this.path.split('.').slice(1, -1).join('.')}.${parameterPath.slice(1)}`;
}
const returnData = lodash.get(nodeParameters, parameterPath);
return returnData;
}
}

View File

@@ -0,0 +1,427 @@
import { Container } from '@n8n/di';
import { get } from 'lodash';
import type {
FunctionsBase,
ICredentialDataDecryptedObject,
ICredentialsExpressionResolveValues,
IExecuteData,
IGetNodeParameterOptions,
INode,
INodeCredentialDescription,
INodeCredentialsDetails,
INodeExecutionData,
INodeInputConfiguration,
INodeOutputConfiguration,
IRunExecutionData,
IWorkflowExecuteAdditionalData,
NodeConnectionType,
NodeParameterValueType,
NodeTypeAndVersion,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
import {
ApplicationError,
deepCopy,
ExpressionError,
NodeHelpers,
NodeOperationError,
} from 'n8n-workflow';
import { HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_TOOL_NODE_TYPE } from '@/constants';
import { Memoized } from '@/decorators';
import { InstanceSettings } from '@/instance-settings';
import { Logger } from '@/logging/logger';
import { cleanupParameterData } from './utils/cleanup-parameter-data';
import { ensureType } from './utils/ensure-type';
import { extractValue } from './utils/extract-value';
import { getAdditionalKeys } from './utils/get-additional-keys';
import { validateValueAgainstSchema } from './utils/validate-value-against-schema';
export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCredentials'> {
protected readonly instanceSettings = Container.get(InstanceSettings);
constructor(
protected readonly workflow: Workflow,
protected readonly node: INode,
protected readonly additionalData: IWorkflowExecuteAdditionalData,
protected readonly mode: WorkflowExecuteMode,
protected readonly runExecutionData: IRunExecutionData | null = null,
protected readonly runIndex = 0,
protected readonly connectionInputData: INodeExecutionData[] = [],
protected readonly executeData?: IExecuteData,
) {}
@Memoized
get logger() {
return Container.get(Logger);
}
getExecutionId() {
return this.additionalData.executionId!;
}
getNode(): INode {
return deepCopy(this.node);
}
getWorkflow() {
const { id, name, active } = this.workflow;
return { id, name, active };
}
getMode() {
return this.mode;
}
getWorkflowStaticData(type: string) {
return this.workflow.getStaticData(type, this.node);
}
getChildNodes(nodeName: string) {
const output: NodeTypeAndVersion[] = [];
const nodeNames = this.workflow.getChildNodes(nodeName);
for (const n of nodeNames) {
const node = this.workflow.nodes[n];
output.push({
name: node.name,
type: node.type,
typeVersion: node.typeVersion,
disabled: node.disabled ?? false,
});
}
return output;
}
getParentNodes(nodeName: string) {
const output: NodeTypeAndVersion[] = [];
const nodeNames = this.workflow.getParentNodes(nodeName);
for (const n of nodeNames) {
const node = this.workflow.nodes[n];
output.push({
name: node.name,
type: node.type,
typeVersion: node.typeVersion,
disabled: node.disabled ?? false,
});
}
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() {
return this.workflow.nodeTypes.getKnownTypes();
}
getRestApiUrl() {
return this.additionalData.restApiUrl;
}
getInstanceBaseUrl() {
return this.additionalData.instanceBaseUrl;
}
getInstanceId() {
return this.instanceSettings.instanceId;
}
getTimezone() {
return this.workflow.timezone;
}
getCredentialsProperties(type: string) {
return this.additionalData.credentialsHelper.getCredentialsProperties(type);
}
/** Returns the requested decrypted credentials if the node has access to them */
protected async _getCredentials<T extends object = ICredentialDataDecryptedObject>(
type: string,
executeData?: IExecuteData,
connectionInputData?: INodeExecutionData[],
itemIndex?: number,
): Promise<T> {
const { workflow, node, additionalData, mode, runExecutionData, runIndex } = this;
// Get the NodeType as it has the information if the credentials are required
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
// Hardcode for now for security reasons that only a single node can access
// all credentials
const fullAccess = [HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_TOOL_NODE_TYPE].includes(node.type);
let nodeCredentialDescription: INodeCredentialDescription | undefined;
if (!fullAccess) {
if (nodeType.description.credentials === undefined) {
throw new NodeOperationError(
node,
`Node type "${node.type}" does not have any credentials defined`,
{ level: 'warning' },
);
}
nodeCredentialDescription = nodeType.description.credentials.find(
(credentialTypeDescription) => credentialTypeDescription.name === type,
);
if (nodeCredentialDescription === undefined) {
throw new NodeOperationError(
node,
`Node type "${node.type}" does not have any credentials of type "${type}" defined`,
{ level: 'warning' },
);
}
if (
!NodeHelpers.displayParameter(
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
additionalData.currentNodeParameters || node.parameters,
nodeCredentialDescription,
node,
node.parameters,
)
) {
// Credentials should not be displayed even if they would be defined
throw new NodeOperationError(node, 'Credentials not found');
}
}
// Check if node has any credentials defined
if (!fullAccess && !node.credentials?.[type]) {
// If none are defined check if the credentials are required or not
if (nodeCredentialDescription?.required === true) {
// Credentials are required so error
if (!node.credentials) {
throw new NodeOperationError(node, 'Node does not have any credentials set', {
level: 'warning',
});
}
if (!node.credentials[type]) {
throw new NodeOperationError(
node,
`Node does not have any credentials set for "${type}"`,
{
level: 'warning',
},
);
}
} else {
// Credentials are not required
throw new NodeOperationError(node, 'Node does not require credentials');
}
}
if (fullAccess && !node.credentials?.[type]) {
// Make sure that fullAccess nodes still behave like before that if they
// request access to credentials that are currently not set it returns undefined
throw new NodeOperationError(node, 'Credentials not found');
}
let expressionResolveValues: ICredentialsExpressionResolveValues | undefined;
if (connectionInputData && runExecutionData && runIndex !== undefined) {
expressionResolveValues = {
connectionInputData,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
itemIndex: itemIndex || 0,
node,
runExecutionData,
runIndex,
workflow,
} as ICredentialsExpressionResolveValues;
}
const nodeCredentials = node.credentials
? node.credentials[type]
: ({} as INodeCredentialsDetails);
// TODO: solve using credentials via expression
// if (name.charAt(0) === '=') {
// // If the credential name is an expression resolve it
// const additionalKeys = getAdditionalKeys(additionalData, mode);
// name = workflow.expression.getParameterValue(
// name,
// runExecutionData || null,
// runIndex || 0,
// itemIndex || 0,
// node.name,
// connectionInputData || [],
// mode,
// additionalKeys,
// ) as string;
// }
const decryptedDataObject = await additionalData.credentialsHelper.getDecrypted(
additionalData,
nodeCredentials,
type,
mode,
executeData,
false,
expressionResolveValues,
);
return decryptedDataObject as T;
}
@Memoized
protected get additionalKeys() {
return getAdditionalKeys(this.additionalData, this.mode, this.runExecutionData);
}
/** Returns the requested resolved (all expressions replaced) node parameters. */
getNodeParameter(
parameterName: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fallbackValue?: any,
options?: IGetNodeParameterOptions,
): NodeParameterValueType | object {
const itemIndex = 0;
return this._getNodeParameter(parameterName, itemIndex, fallbackValue, options);
}
protected _getNodeParameter(
parameterName: string,
itemIndex: number,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fallbackValue?: any,
options?: IGetNodeParameterOptions,
): NodeParameterValueType | object {
const { workflow, node, mode, runExecutionData, runIndex, connectionInputData, executeData } =
this;
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const value = get(node.parameters, parameterName, fallbackValue);
if (value === undefined) {
throw new ApplicationError('Could not get parameter', { extra: { parameterName } });
}
if (options?.rawExpressions) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return value;
}
const { additionalKeys } = this;
let returnData;
try {
returnData = workflow.expression.getParameterValue(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
value,
runExecutionData,
runIndex,
itemIndex,
node.name,
connectionInputData,
mode,
additionalKeys,
executeData,
false,
{},
options?.contextNode?.name,
);
cleanupParameterData(returnData);
} catch (e) {
if (
e instanceof ExpressionError &&
node.continueOnFail &&
node.type === 'n8n-nodes-base.set'
) {
// https://linear.app/n8n/issue/PAY-684
returnData = [{ name: undefined, value: undefined }];
} else {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (e.context) e.context.parameter = parameterName;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
e.cause = value;
throw e;
}
}
// This is outside the try/catch because it throws errors with proper messages
if (options?.extractValue) {
returnData = extractValue(returnData, parameterName, node, nodeType, itemIndex);
}
// Make sure parameter value is the type specified in the ensureType option, if needed convert it
if (options?.ensureType) {
returnData = ensureType(options.ensureType, returnData, parameterName, {
itemIndex,
runIndex,
nodeCause: node.name,
});
}
// Validate parameter value if it has a schema defined(RMC) or validateType defined
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
returnData = validateValueAgainstSchema(
node,
nodeType,
returnData,
parameterName,
runIndex,
itemIndex,
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return returnData;
}
evaluateExpression(expression: string, itemIndex: number = 0) {
return this.workflow.expression.resolveSimpleParameterValue(
`=${expression}`,
{},
this.runExecutionData,
this.runIndex,
itemIndex,
this.node.name,
this.connectionInputData,
this.mode,
this.additionalKeys,
this.executeData,
);
}
async prepareOutputData(outputData: INodeExecutionData[]) {
return [outputData];
}
}

View File

@@ -0,0 +1,60 @@
import type {
ICredentialDataDecryptedObject,
INode,
IPollFunctions,
IWorkflowExecuteAdditionalData,
Workflow,
WorkflowActivateMode,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { ApplicationError, createDeferredPromise } from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import {
getBinaryHelperFunctions,
getRequestHelperFunctions,
getSchedulingFunctions,
returnJsonArray,
} from '@/node-execute-functions';
import { NodeExecutionContext } from './node-execution-context';
const throwOnEmit = () => {
throw new ApplicationError('Overwrite PollContext.__emit function');
};
const throwOnEmitError = () => {
throw new ApplicationError('Overwrite PollContext.__emitError function');
};
export class PollContext extends NodeExecutionContext implements IPollFunctions {
readonly helpers: IPollFunctions['helpers'];
constructor(
workflow: Workflow,
node: INode,
additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode,
private readonly activation: WorkflowActivateMode,
readonly __emit: IPollFunctions['__emit'] = throwOnEmit,
readonly __emitError: IPollFunctions['__emitError'] = throwOnEmitError,
) {
super(workflow, node, additionalData, mode);
this.helpers = {
createDeferredPromise,
returnJsonArray,
...getRequestHelperFunctions(workflow, node, additionalData),
...getBinaryHelperFunctions(additionalData, workflow.id),
...getSchedulingFunctions(workflow),
};
}
getActivationMode() {
return this.activation;
}
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
return await this._getCredentials<T>(type);
}
}

View File

@@ -0,0 +1,293 @@
import get from 'lodash/get';
import type {
AINodeConnectionType,
CloseFunction,
ExecutionBaseError,
IExecuteData,
IGetNodeParameterOptions,
INode,
INodeExecutionData,
IRunExecutionData,
ISupplyDataFunctions,
ITaskData,
ITaskDataConnections,
ITaskMetadata,
IWorkflowExecuteAdditionalData,
NodeConnectionType,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { createDeferredPromise } from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import {
assertBinaryData,
constructExecutionMetaData,
copyInputItems,
detectBinaryEncoding,
getBinaryDataBuffer,
getBinaryHelperFunctions,
getCheckProcessedHelperFunctions,
getFileSystemHelperFunctions,
getRequestHelperFunctions,
getSSHTunnelFunctions,
normalizeItems,
returnJsonArray,
} from '@/node-execute-functions';
import { BaseExecuteContext } from './base-execute-context';
import { getInputConnectionData } from './utils/get-input-connection-data';
export class SupplyDataContext extends BaseExecuteContext implements ISupplyDataFunctions {
readonly helpers: ISupplyDataFunctions['helpers'];
readonly getNodeParameter: ISupplyDataFunctions['getNodeParameter'];
constructor(
workflow: Workflow,
node: INode,
additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode,
runExecutionData: IRunExecutionData,
runIndex: number,
connectionInputData: INodeExecutionData[],
inputData: ITaskDataConnections,
private readonly connectionType: NodeConnectionType,
executeData: IExecuteData,
private readonly closeFunctions: CloseFunction[],
abortSignal?: AbortSignal,
) {
super(
workflow,
node,
additionalData,
mode,
runExecutionData,
runIndex,
connectionInputData,
inputData,
executeData,
abortSignal,
);
this.helpers = {
createDeferredPromise,
copyInputItems,
...getRequestHelperFunctions(
workflow,
node,
additionalData,
runExecutionData,
connectionInputData,
),
...getSSHTunnelFunctions(),
...getFileSystemHelperFunctions(node),
...getBinaryHelperFunctions(additionalData, workflow.id),
...getCheckProcessedHelperFunctions(workflow, node),
assertBinaryData: (itemIndex, propertyName) =>
assertBinaryData(inputData, node, itemIndex, propertyName, 0),
getBinaryDataBuffer: async (itemIndex, propertyName) =>
await getBinaryDataBuffer(inputData, itemIndex, propertyName, 0),
detectBinaryEncoding: (buffer: Buffer) => detectBinaryEncoding(buffer),
returnJsonArray,
normalizeItems,
constructExecutionMetaData,
};
this.getNodeParameter = ((
parameterName: string,
itemIndex: number,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fallbackValue?: any,
options?: IGetNodeParameterOptions,
) =>
this._getNodeParameter(
parameterName,
itemIndex,
fallbackValue,
options,
)) as ISupplyDataFunctions['getNodeParameter'];
}
async getInputConnectionData(
connectionType: AINodeConnectionType,
itemIndex: number,
): Promise<unknown> {
return await getInputConnectionData.call(
this,
this.workflow,
this.runExecutionData,
this.runIndex,
this.connectionInputData,
this.inputData,
this.additionalData,
this.executeData,
this.mode,
this.closeFunctions,
connectionType,
itemIndex,
this.abortSignal,
);
}
getInputData(inputIndex = 0, connectionType = this.connectionType) {
if (!this.inputData.hasOwnProperty(connectionType)) {
// Return empty array because else it would throw error when nothing is connected to input
return [];
}
return super.getInputItems(inputIndex, connectionType) ?? [];
}
/** @deprecated create a context object with inputData for every runIndex */
addInputData(
connectionType: AINodeConnectionType,
data: INodeExecutionData[][],
): { index: number } {
const nodeName = this.node.name;
let currentNodeRunIndex = 0;
if (this.runExecutionData.resultData.runData.hasOwnProperty(nodeName)) {
currentNodeRunIndex = this.runExecutionData.resultData.runData[nodeName].length;
}
this.addExecutionDataFunctions(
'input',
data,
connectionType,
nodeName,
currentNodeRunIndex,
).catch((error) => {
this.logger.warn(
`There was a problem logging input data of node "${nodeName}": ${
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
error.message
}`,
);
});
return { index: currentNodeRunIndex };
}
/** @deprecated Switch to WorkflowExecute to store output on runExecutionData.resultData.runData */
addOutputData(
connectionType: AINodeConnectionType,
currentNodeRunIndex: number,
data: INodeExecutionData[][] | ExecutionBaseError,
metadata?: ITaskMetadata,
): void {
const nodeName = this.node.name;
this.addExecutionDataFunctions(
'output',
data,
connectionType,
nodeName,
currentNodeRunIndex,
metadata,
).catch((error) => {
this.logger.warn(
`There was a problem logging output data of node "${nodeName}": ${
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
error.message
}`,
);
});
}
async addExecutionDataFunctions(
type: 'input' | 'output',
data: INodeExecutionData[][] | ExecutionBaseError,
connectionType: AINodeConnectionType,
sourceNodeName: string,
currentNodeRunIndex: number,
metadata?: ITaskMetadata,
): Promise<void> {
const {
additionalData,
runExecutionData,
runIndex: sourceNodeRunIndex,
node: { name: nodeName },
} = this;
let taskData: ITaskData | undefined;
if (type === 'input') {
taskData = {
startTime: new Date().getTime(),
executionTime: 0,
executionStatus: 'running',
source: [null],
};
} else {
// At the moment we expect that there is always an input sent before the output
taskData = get(
runExecutionData,
['resultData', 'runData', nodeName, currentNodeRunIndex],
undefined,
);
if (taskData === undefined) {
return;
}
taskData.metadata = metadata;
}
taskData = taskData!;
if (data instanceof Error) {
taskData.executionStatus = 'error';
taskData.error = data;
} else {
if (type === 'output') {
taskData.executionStatus = 'success';
}
taskData.data = {
[connectionType]: data,
} as ITaskDataConnections;
}
if (type === 'input') {
if (!(data instanceof Error)) {
this.inputData[connectionType] = data;
// TODO: remove inputOverride
taskData.inputOverride = {
[connectionType]: data,
} as ITaskDataConnections;
}
if (!runExecutionData.resultData.runData.hasOwnProperty(nodeName)) {
runExecutionData.resultData.runData[nodeName] = [];
}
runExecutionData.resultData.runData[nodeName][currentNodeRunIndex] = taskData;
await additionalData.hooks?.executeHookFunctions('nodeExecuteBefore', [nodeName]);
} else {
// Outputs
taskData.executionTime = new Date().getTime() - taskData.startTime;
await additionalData.hooks?.executeHookFunctions('nodeExecuteAfter', [
nodeName,
taskData,
this.runExecutionData,
]);
if (get(runExecutionData, 'executionData.metadata', undefined) === undefined) {
runExecutionData.executionData!.metadata = {};
}
let sourceTaskData = runExecutionData.executionData?.metadata?.[sourceNodeName];
if (!sourceTaskData) {
runExecutionData.executionData!.metadata[sourceNodeName] = [];
sourceTaskData = runExecutionData.executionData!.metadata[sourceNodeName];
}
if (!sourceTaskData[sourceNodeRunIndex]) {
sourceTaskData[sourceNodeRunIndex] = {
subRun: [],
};
}
sourceTaskData[sourceNodeRunIndex].subRun!.push({
node: nodeName,
runIndex: currentNodeRunIndex,
});
}
}
}

View File

@@ -0,0 +1,62 @@
import type {
ICredentialDataDecryptedObject,
INode,
ITriggerFunctions,
IWorkflowExecuteAdditionalData,
Workflow,
WorkflowActivateMode,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { ApplicationError, createDeferredPromise } from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import {
getBinaryHelperFunctions,
getRequestHelperFunctions,
getSchedulingFunctions,
getSSHTunnelFunctions,
returnJsonArray,
} from '@/node-execute-functions';
import { NodeExecutionContext } from './node-execution-context';
const throwOnEmit = () => {
throw new ApplicationError('Overwrite TriggerContext.emit function');
};
const throwOnEmitError = () => {
throw new ApplicationError('Overwrite TriggerContext.emitError function');
};
export class TriggerContext extends NodeExecutionContext implements ITriggerFunctions {
readonly helpers: ITriggerFunctions['helpers'];
constructor(
workflow: Workflow,
node: INode,
additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode,
private readonly activation: WorkflowActivateMode,
readonly emit: ITriggerFunctions['emit'] = throwOnEmit,
readonly emitError: ITriggerFunctions['emitError'] = throwOnEmitError,
) {
super(workflow, node, additionalData, mode);
this.helpers = {
createDeferredPromise,
returnJsonArray,
...getSSHTunnelFunctions(),
...getRequestHelperFunctions(workflow, node, additionalData),
...getBinaryHelperFunctions(additionalData, workflow.id),
...getSchedulingFunctions(workflow),
};
}
getActivationMode() {
return this.activation;
}
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
return await this._getCredentials<T>(type);
}
}

View File

@@ -0,0 +1,38 @@
import toPlainObject from 'lodash/toPlainObject';
import { DateTime } from 'luxon';
import type { NodeParameterValue } from 'n8n-workflow';
import { cleanupParameterData } from '../cleanup-parameter-data';
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');
});
});

View File

@@ -0,0 +1,481 @@
import { mock } from 'jest-mock-extended';
import type { INodeType, ISupplyDataFunctions, INode } from 'n8n-workflow';
import { z } from 'zod';
import { createNodeAsTool } from '../create-node-as-tool';
jest.mock('@langchain/core/tools', () => ({
DynamicStructuredTool: jest.fn().mockImplementation((config) => ({
name: config.name,
description: config.description,
schema: config.schema,
func: config.func,
})),
}));
describe('createNodeAsTool', () => {
const context = mock<ISupplyDataFunctions>({
getNodeParameter: jest.fn(),
addInputData: jest.fn(),
addOutputData: jest.fn(),
getNode: jest.fn(),
});
const handleToolInvocation = jest.fn();
const nodeType = mock<INodeType>({
description: {
name: 'TestNode',
description: 'Test node description',
},
});
const node = mock<INode>({ name: 'Test_Node' });
const options = { node, nodeType, handleToolInvocation };
beforeEach(() => {
jest.clearAllMocks();
(context.addInputData as jest.Mock).mockReturnValue({ index: 0 });
(context.getNode as jest.Mock).mockReturnValue(node);
(nodeType.execute as jest.Mock).mockResolvedValue([[{ json: { result: 'test' } }]]);
node.parameters = {
param1: "={{$fromAI('param1', 'Test parameter', 'string') }}",
param2: 'static value',
nestedParam: {
subParam: "={{ $fromAI('subparam', 'Nested parameter', 'string') }}",
},
descriptionType: 'auto',
resource: 'testResource',
operation: 'testOperation',
};
});
describe('Tool Creation and Basic Properties', () => {
it('should create a DynamicStructuredTool with correct properties', () => {
const tool = createNodeAsTool(options).response;
expect(tool).toBeDefined();
expect(tool.name).toBe('Test_Node');
expect(tool.description).toBe(
'Test node description\n Resource: testResource\n Operation: testOperation',
);
expect(tool.schema).toBeDefined();
});
it('should use toolDescription if provided', () => {
node.parameters.descriptionType = 'manual';
node.parameters.toolDescription = 'Custom tool description';
const tool = createNodeAsTool(options).response;
expect(tool.description).toBe('Custom tool description');
});
});
describe('Schema Creation and Parameter Handling', () => {
it('should create a schema based on fromAI arguments in nodeParameters', () => {
const tool = createNodeAsTool(options).response;
expect(tool.schema).toBeDefined();
expect(tool.schema.shape).toHaveProperty('param1');
expect(tool.schema.shape).toHaveProperty('subparam');
expect(tool.schema.shape).not.toHaveProperty('param2');
});
it('should handle fromAI arguments correctly', () => {
const tool = createNodeAsTool(options).response;
expect(tool.schema.shape.param1).toBeInstanceOf(z.ZodString);
expect(tool.schema.shape.subparam).toBeInstanceOf(z.ZodString);
});
it('should handle default values correctly', () => {
node.parameters = {
paramWithDefault:
"={{ $fromAI('paramWithDefault', 'Parameter with default', 'string', 'default value') }}",
numberWithDefault:
"={{ $fromAI('numberWithDefault', 'Number with default', 'number', 42) }}",
booleanWithDefault:
"={{ $fromAI('booleanWithDefault', 'Boolean with default', 'boolean', true) }}",
};
const tool = createNodeAsTool(options).response;
expect(tool.schema.shape.paramWithDefault.description).toBe('Parameter with default');
expect(tool.schema.shape.numberWithDefault.description).toBe('Number with default');
expect(tool.schema.shape.booleanWithDefault.description).toBe('Boolean with default');
});
it('should handle nested parameters correctly', () => {
node.parameters = {
topLevel: "={{ $fromAI('topLevel', 'Top level parameter', 'string') }}",
nested: {
level1: "={{ $fromAI('level1', 'Nested level 1', 'string') }}",
deeperNested: {
level2: "={{ $fromAI('level2', 'Nested level 2', 'number') }}",
},
},
};
const tool = createNodeAsTool(options).response;
expect(tool.schema.shape.topLevel).toBeInstanceOf(z.ZodString);
expect(tool.schema.shape.level1).toBeInstanceOf(z.ZodString);
expect(tool.schema.shape.level2).toBeInstanceOf(z.ZodNumber);
});
it('should handle array parameters correctly', () => {
node.parameters = {
arrayParam: [
"={{ $fromAI('item1', 'First item', 'string') }}",
"={{ $fromAI('item2', 'Second item', 'number') }}",
],
};
const tool = createNodeAsTool(options).response;
expect(tool.schema.shape.item1).toBeInstanceOf(z.ZodString);
expect(tool.schema.shape.item2).toBeInstanceOf(z.ZodNumber);
});
});
describe('Error Handling and Edge Cases', () => {
it('should handle error during node execution', async () => {
nodeType.execute = jest.fn().mockRejectedValue(new Error('Execution failed'));
const tool = createNodeAsTool(options).response;
handleToolInvocation.mockReturnValue('Error during node execution: some random issue.');
const result = await tool.func({ param1: 'test value' });
expect(result).toContain('Error during node execution:');
});
it('should throw an error for invalid parameter names', () => {
node.parameters.invalidParam = "$fromAI('invalid param', 'Invalid parameter', 'string')";
expect(() => createNodeAsTool(options)).toThrow('Parameter key `invalid param` is invalid');
});
it('should throw an error for $fromAI calls with unsupported types', () => {
node.parameters = {
invalidTypeParam:
"={{ $fromAI('invalidType', 'Param with unsupported type', 'unsupportedType') }}",
};
expect(() => createNodeAsTool(options)).toThrow('Invalid type: unsupportedType');
});
it('should handle empty parameters and parameters with no fromAI calls', () => {
node.parameters = {
param1: 'static value 1',
param2: 'static value 2',
};
const tool = createNodeAsTool(options).response;
expect(tool.schema.shape).toEqual({});
});
});
describe('Parameter Name and Description Handling', () => {
it('should accept parameter names with underscores and hyphens', () => {
node.parameters = {
validName1:
"={{ $fromAI('param_name-1', 'Valid name with underscore and hyphen', 'string') }}",
validName2: "={{ $fromAI('param_name_2', 'Another valid name', 'number') }}",
};
const tool = createNodeAsTool(options).response;
expect(tool.schema.shape['param_name-1']).toBeInstanceOf(z.ZodString);
expect(tool.schema.shape['param_name-1'].description).toBe(
'Valid name with underscore and hyphen',
);
expect(tool.schema.shape.param_name_2).toBeInstanceOf(z.ZodNumber);
expect(tool.schema.shape.param_name_2.description).toBe('Another valid name');
});
it('should throw an error for parameter names with invalid special characters', () => {
node.parameters = {
invalidNameParam:
"={{ $fromAI('param@name!', 'Invalid name with special characters', 'string') }}",
};
expect(() => createNodeAsTool(options)).toThrow('Parameter key `param@name!` is invalid');
});
it('should throw an error for empty parameter name', () => {
node.parameters = {
invalidNameParam: "={{ $fromAI('', 'Invalid name with special characters', 'string') }}",
};
expect(() => createNodeAsTool(options)).toThrow(
'You must specify a key when using $fromAI()',
);
});
it('should handle parameter names with exact and exceeding character limits', () => {
const longName = 'a'.repeat(64);
const tooLongName = 'a'.repeat(65);
node.parameters = {
longNameParam: `={{ $fromAI('${longName}', 'Param with 64 character name', 'string') }}`,
};
const tool = createNodeAsTool(options).response;
expect(tool.schema.shape[longName]).toBeInstanceOf(z.ZodString);
expect(tool.schema.shape[longName].description).toBe('Param with 64 character name');
node.parameters = {
tooLongNameParam: `={{ $fromAI('${tooLongName}', 'Param with 65 character name', 'string') }}`,
};
expect(() => createNodeAsTool(options)).toThrow(
`Parameter key \`${tooLongName}\` is invalid`,
);
});
it('should handle $fromAI calls with empty description', () => {
node.parameters = {
emptyDescriptionParam: "={{ $fromAI('emptyDescription', '', 'number') }}",
};
const tool = createNodeAsTool(options).response;
expect(tool.schema.shape.emptyDescription).toBeInstanceOf(z.ZodNumber);
expect(tool.schema.shape.emptyDescription.description).toBeUndefined();
});
it('should throw an error for calls with the same parameter but different descriptions', () => {
node.parameters = {
duplicateParam1: "={{ $fromAI('duplicate', 'First duplicate', 'string') }}",
duplicateParam2: "={{ $fromAI('duplicate', 'Second duplicate', 'number') }}",
};
expect(() => createNodeAsTool(options)).toThrow(
"Duplicate key 'duplicate' found with different description or type",
);
});
it('should throw an error for calls with the same parameter but different types', () => {
node.parameters = {
duplicateParam1: "={{ $fromAI('duplicate', 'First duplicate', 'string') }}",
duplicateParam2: "={{ $fromAI('duplicate', 'First duplicate', 'number') }}",
};
expect(() => createNodeAsTool(options)).toThrow(
"Duplicate key 'duplicate' found with different description or type",
);
});
});
describe('Complex Parsing Scenarios', () => {
it('should correctly parse $fromAI calls with varying spaces, capitalization, and within template literals', () => {
node.parameters = {
varyingSpacing1: "={{$fromAI('param1','Description1','string')}}",
varyingSpacing2: "={{ $fromAI ( 'param2' , 'Description2' , 'number' ) }}",
varyingSpacing3: "={{ $FROMai('param3', 'Description3', 'boolean') }}",
wrongCapitalization: "={{$fromai('param4','Description4','number')}}",
templateLiteralParam:
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
"={{ `Value is: ${$fromAI('templatedParam', 'Templated param description', 'string')}` }}",
};
const tool = createNodeAsTool(options).response;
expect(tool.schema.shape.param1).toBeInstanceOf(z.ZodString);
expect(tool.schema.shape.param1.description).toBe('Description1');
expect(tool.schema.shape.param2).toBeInstanceOf(z.ZodNumber);
expect(tool.schema.shape.param2.description).toBe('Description2');
expect(tool.schema.shape.param3).toBeInstanceOf(z.ZodBoolean);
expect(tool.schema.shape.param3.description).toBe('Description3');
expect(tool.schema.shape.param4).toBeInstanceOf(z.ZodNumber);
expect(tool.schema.shape.param4.description).toBe('Description4');
expect(tool.schema.shape.templatedParam).toBeInstanceOf(z.ZodString);
expect(tool.schema.shape.templatedParam.description).toBe('Templated param description');
});
it('should correctly parse multiple $fromAI calls interleaved with regular text', () => {
node.parameters = {
interleavedParams:
"={{ 'Start ' + $fromAI('param1', 'First param', 'string') + ' Middle ' + $fromAI('param2', 'Second param', 'number') + ' End' }}",
};
const tool = createNodeAsTool(options).response;
expect(tool.schema.shape.param1).toBeInstanceOf(z.ZodString);
expect(tool.schema.shape.param1.description).toBe('First param');
expect(tool.schema.shape.param2).toBeInstanceOf(z.ZodNumber);
expect(tool.schema.shape.param2.description).toBe('Second param');
});
it('should correctly parse $fromAI calls with complex JSON default values', () => {
node.parameters = {
complexJsonDefault:
'={{ $fromAI(\'complexJson\', \'Param with complex JSON default\', \'json\', \'{"nested": {"key": "value"}, "array": [1, 2, 3]}\') }}',
};
const tool = createNodeAsTool(options).response;
expect(tool.schema.shape.complexJson._def.innerType).toBeInstanceOf(z.ZodRecord);
expect(tool.schema.shape.complexJson.description).toBe('Param with complex JSON default');
expect(tool.schema.shape.complexJson._def.defaultValue()).toEqual({
nested: { key: 'value' },
array: [1, 2, 3],
});
});
it('should ignore $fromAI calls embedded in non-string node parameters', () => {
node.parameters = {
numberParam: 42,
booleanParam: false,
objectParam: {
innerString: "={{ $fromAI('innerParam', 'Inner param', 'string') }}",
innerNumber: 100,
innerObject: {
deepParam: "={{ $fromAI('deepParam', 'Deep param', 'number') }}",
},
},
arrayParam: [
"={{ $fromAI('arrayParam1', 'First array param', 'string') }}",
200,
"={{ $fromAI('nestedArrayParam', 'Nested array param', 'boolean') }}",
],
};
const tool = createNodeAsTool(options).response;
expect(tool.schema.shape.innerParam).toBeInstanceOf(z.ZodString);
expect(tool.schema.shape.innerParam.description).toBe('Inner param');
expect(tool.schema.shape.deepParam).toBeInstanceOf(z.ZodNumber);
expect(tool.schema.shape.deepParam.description).toBe('Deep param');
expect(tool.schema.shape.arrayParam1).toBeInstanceOf(z.ZodString);
expect(tool.schema.shape.arrayParam1.description).toBe('First array param');
expect(tool.schema.shape.nestedArrayParam).toBeInstanceOf(z.ZodBoolean);
expect(tool.schema.shape.nestedArrayParam.description).toBe('Nested array param');
});
});
describe('Escaping and Special Characters', () => {
it('should handle escaped single quotes in parameter names and descriptions', () => {
node.parameters = {
escapedQuotesParam:
"={{ $fromAI('paramName', 'Description with \\'escaped\\' quotes', 'string') }}",
};
const tool = createNodeAsTool(options).response;
expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodString);
expect(tool.schema.shape.paramName.description).toBe("Description with 'escaped' quotes");
});
it('should handle escaped double quotes in parameter names and descriptions', () => {
node.parameters = {
escapedQuotesParam:
'={{ $fromAI("paramName", "Description with \\"escaped\\" quotes", "string") }}',
};
const tool = createNodeAsTool(options).response;
expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodString);
expect(tool.schema.shape.paramName.description).toBe('Description with "escaped" quotes');
});
it('should handle escaped backslashes in parameter names and descriptions', () => {
node.parameters = {
escapedBackslashesParam:
"={{ $fromAI('paramName', 'Description with \\\\ backslashes', 'string') }}",
};
const tool = createNodeAsTool(options).response;
expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodString);
expect(tool.schema.shape.paramName.description).toBe('Description with \\ backslashes');
});
it('should handle mixed escaped characters in parameter names and descriptions', () => {
node.parameters = {
mixedEscapesParam:
'={{ $fromAI(`paramName`, \'Description with \\\'mixed" characters\', "number") }}',
};
const tool = createNodeAsTool(options).response;
expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodNumber);
expect(tool.schema.shape.paramName.description).toBe('Description with \'mixed" characters');
});
});
describe('Edge Cases and Limitations', () => {
it('should ignore excess arguments in $fromAI calls beyond the fourth argument', () => {
node.parameters = {
excessArgsParam:
"={{ $fromAI('excessArgs', 'Param with excess arguments', 'string', 'default', 'extraArg1', 'extraArg2') }}",
};
const tool = createNodeAsTool(options).response;
expect(tool.schema.shape.excessArgs._def.innerType).toBeInstanceOf(z.ZodString);
expect(tool.schema.shape.excessArgs.description).toBe('Param with excess arguments');
expect(tool.schema.shape.excessArgs._def.defaultValue()).toBe('default');
});
it('should correctly parse $fromAI calls with nested parentheses', () => {
node.parameters = {
nestedParenthesesParam:
"={{ $fromAI('paramWithNested', 'Description with ((nested)) parentheses', 'string') }}",
};
const tool = createNodeAsTool(options).response;
expect(tool.schema.shape.paramWithNested).toBeInstanceOf(z.ZodString);
expect(tool.schema.shape.paramWithNested.description).toBe(
'Description with ((nested)) parentheses',
);
});
it('should handle $fromAI calls with very long descriptions', () => {
const longDescription = 'A'.repeat(1000);
node.parameters = {
longParam: `={{ $fromAI('longParam', '${longDescription}', 'string') }}`,
};
const tool = createNodeAsTool(options).response;
expect(tool.schema.shape.longParam).toBeInstanceOf(z.ZodString);
expect(tool.schema.shape.longParam.description).toBe(longDescription);
});
it('should handle $fromAI calls with only some parameters', () => {
node.parameters = {
partialParam1: "={{ $fromAI('partial1') }}",
partialParam2: "={{ $fromAI('partial2', 'Description only') }}",
partialParam3: "={{ $fromAI('partial3', '', 'number') }}",
};
const tool = createNodeAsTool(options).response;
expect(tool.schema.shape.partial1).toBeInstanceOf(z.ZodString);
expect(tool.schema.shape.partial2).toBeInstanceOf(z.ZodString);
expect(tool.schema.shape.partial3).toBeInstanceOf(z.ZodNumber);
});
});
describe('Unicode and Internationalization', () => {
it('should handle $fromAI calls with unicode characters', () => {
node.parameters = {
unicodeParam: "={{ $fromAI('unicodeParam', '🌈 Unicode parameter 你好', 'string') }}",
};
const tool = createNodeAsTool(options).response;
expect(tool.schema.shape.unicodeParam).toBeInstanceOf(z.ZodString);
expect(tool.schema.shape.unicodeParam.description).toBe('🌈 Unicode parameter 你好');
});
});
});

View File

@@ -0,0 +1,80 @@
import { ExpressionError } from 'n8n-workflow';
import { ensureType } from '../ensure-type';
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"),
);
});
});

View File

@@ -0,0 +1,219 @@
import type { IRunExecutionData } from 'n8n-workflow';
import { InvalidExecutionMetadataError } from '@/errors/invalid-execution-metadata.error';
import {
setWorkflowExecutionMetadata,
setAllWorkflowExecutionMetadata,
KV_LIMIT,
getWorkflowExecutionMetadata,
getAllWorkflowExecutionMetadata,
} from '../execution-metadata';
describe('Execution Metadata functions', () => {
test('setWorkflowExecutionMetadata will set a value', () => {
const metadata = {};
const executionData = {
resultData: {
metadata,
},
} as IRunExecutionData;
setWorkflowExecutionMetadata(executionData, 'test1', 'value1');
expect(metadata).toEqual({
test1: 'value1',
});
});
test('setAllWorkflowExecutionMetadata will set multiple values', () => {
const metadata = {};
const executionData = {
resultData: {
metadata,
},
} as IRunExecutionData;
setAllWorkflowExecutionMetadata(executionData, {
test1: 'value1',
test2: 'value2',
});
expect(metadata).toEqual({
test1: 'value1',
test2: 'value2',
});
});
test('setWorkflowExecutionMetadata should only convert numbers to strings', () => {
const metadata = {};
const executionData = {
resultData: {
metadata,
},
} as IRunExecutionData;
expect(() => setWorkflowExecutionMetadata(executionData, 'test1', 1234)).not.toThrow(
InvalidExecutionMetadataError,
);
expect(metadata).toEqual({
test1: '1234',
});
expect(() => setWorkflowExecutionMetadata(executionData, 'test2', {})).toThrow(
InvalidExecutionMetadataError,
);
expect(metadata).not.toEqual({
test1: '1234',
test2: {},
});
});
test('setAllWorkflowExecutionMetadata should not convert values to strings and should set other values correctly', () => {
const metadata = {};
const executionData = {
resultData: {
metadata,
},
} as IRunExecutionData;
expect(() =>
setAllWorkflowExecutionMetadata(executionData, {
test1: {} as unknown as string,
test2: [] as unknown as string,
test3: 'value3',
test4: 'value4',
}),
).toThrow(InvalidExecutionMetadataError);
expect(metadata).toEqual({
test3: 'value3',
test4: 'value4',
});
});
test('setWorkflowExecutionMetadata should validate key characters', () => {
const metadata = {};
const executionData = {
resultData: {
metadata,
},
} as IRunExecutionData;
expect(() => setWorkflowExecutionMetadata(executionData, 'te$t1$', 1234)).toThrow(
InvalidExecutionMetadataError,
);
expect(metadata).not.toEqual({
test1: '1234',
});
});
test('setWorkflowExecutionMetadata should limit the number of metadata entries', () => {
const metadata = {};
const executionData = {
resultData: {
metadata,
},
} as IRunExecutionData;
const expected: Record<string, string> = {};
for (let i = 0; i < KV_LIMIT; i++) {
expected[`test${i + 1}`] = `value${i + 1}`;
}
for (let i = 0; i < KV_LIMIT + 10; i++) {
setWorkflowExecutionMetadata(executionData, `test${i + 1}`, `value${i + 1}`);
}
expect(metadata).toEqual(expected);
});
test('getWorkflowExecutionMetadata should return a single value for an existing key', () => {
const metadata: Record<string, string> = { test1: 'value1' };
const executionData = {
resultData: {
metadata,
},
} as IRunExecutionData;
expect(getWorkflowExecutionMetadata(executionData, 'test1')).toBe('value1');
});
test('getWorkflowExecutionMetadata should return undefined for an unset key', () => {
const metadata: Record<string, string> = { test1: 'value1' };
const executionData = {
resultData: {
metadata,
},
} as IRunExecutionData;
expect(getWorkflowExecutionMetadata(executionData, 'test2')).toBeUndefined();
});
test('getAllWorkflowExecutionMetadata should return all metadata', () => {
const metadata: Record<string, string> = { test1: 'value1', test2: 'value2' };
const executionData = {
resultData: {
metadata,
},
} as IRunExecutionData;
expect(getAllWorkflowExecutionMetadata(executionData)).toEqual(metadata);
});
test('getAllWorkflowExecutionMetadata should not an object that modifies internal state', () => {
const metadata: Record<string, string> = { test1: 'value1', test2: 'value2' };
const executionData = {
resultData: {
metadata,
},
} as IRunExecutionData;
getAllWorkflowExecutionMetadata(executionData).test1 = 'changed';
expect(metadata.test1).not.toBe('changed');
expect(metadata.test1).toBe('value1');
});
test('setWorkflowExecutionMetadata should truncate long keys', () => {
const metadata = {};
const executionData = {
resultData: {
metadata,
},
} as IRunExecutionData;
setWorkflowExecutionMetadata(
executionData,
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab',
'value1',
);
expect(metadata).toEqual({
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: 'value1',
});
});
test('setWorkflowExecutionMetadata should truncate long values', () => {
const metadata = {};
const executionData = {
resultData: {
metadata,
},
} as IRunExecutionData;
setWorkflowExecutionMetadata(
executionData,
'test1',
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab',
);
expect(metadata).toEqual({
test1:
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
});
});
});

View File

@@ -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 '../get-additional-keys';
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);
});
});

View File

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

View File

@@ -0,0 +1,312 @@
import type { IDataObject, INode, INodeType } from 'n8n-workflow';
import { validateValueAgainstSchema } from '../validate-value-against-schema';
describe('validateValueAgainstSchema', () => {
test('should validate fixedCollection values parameter', () => {
const nodeType = {
description: {
properties: [
{
displayName: 'Fields to Set',
name: 'fields',
placeholder: 'Add Field',
type: 'fixedCollection',
description: 'Edit existing fields or add new ones to modify the output data',
typeOptions: {
multipleValues: true,
sortable: true,
},
default: {},
options: [
{
name: 'values',
displayName: 'Values',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
placeholder: 'e.g. fieldName',
description:
'Name of the field to set the value of. Supports dot-notation. Example: data.person[0].name.',
requiresDataPath: 'single',
},
{
displayName: 'Type',
name: 'type',
type: 'options',
description: 'The field value type',
options: [
{
name: 'String',
value: 'stringValue',
},
{
name: 'Number',
value: 'numberValue',
},
{
name: 'Boolean',
value: 'booleanValue',
},
{
name: 'Array',
value: 'arrayValue',
},
{
name: 'Object',
value: 'objectValue',
},
],
default: 'stringValue',
},
{
displayName: 'Value',
name: 'stringValue',
type: 'string',
default: '',
displayOptions: {
show: {
type: ['stringValue'],
},
},
validateType: 'string',
},
{
displayName: 'Value',
name: 'numberValue',
type: 'number',
default: 0,
displayOptions: {
show: {
type: ['numberValue'],
},
},
validateType: 'number',
},
{
displayName: 'Value',
name: 'booleanValue',
type: 'options',
default: 'true',
options: [
{
name: 'True',
value: 'true',
},
{
name: 'False',
value: 'false',
},
],
displayOptions: {
show: {
type: ['booleanValue'],
},
},
validateType: 'boolean',
},
{
displayName: 'Value',
name: 'arrayValue',
type: 'string',
default: '',
placeholder: 'e.g. [ arrayItem1, arrayItem2, arrayItem3 ]',
displayOptions: {
show: {
type: ['arrayValue'],
},
},
validateType: 'array',
},
{
displayName: 'Value',
name: 'objectValue',
type: 'json',
default: '={}',
typeOptions: {
rows: 2,
},
displayOptions: {
show: {
type: ['objectValue'],
},
},
validateType: 'object',
},
],
},
],
displayOptions: {
show: {
mode: ['manual'],
},
},
},
],
},
} as unknown as INodeType;
const node = {
parameters: {
mode: 'manual',
duplicateItem: false,
fields: {
values: [
{
name: 'num1',
type: 'numberValue',
numberValue: '=str',
},
],
},
include: 'none',
options: {},
},
name: 'Edit Fields2',
type: 'n8n-nodes-base.set',
typeVersion: 3,
} as unknown as INode;
const values = [
{
name: 'num1',
type: 'numberValue',
numberValue: '55',
},
{
name: 'str1',
type: 'stringValue',
stringValue: 42, //validateFieldType does not change the type of string value
},
{
name: 'arr1',
type: 'arrayValue',
arrayValue: "['foo', 'bar']",
},
{
name: 'obj',
type: 'objectValue',
objectValue: '{ "key": "value" }',
},
];
const parameterName = 'fields.values';
const result = validateValueAgainstSchema(node, nodeType, values, parameterName, 0, 0);
// value should be type number
expect(typeof (result as IDataObject[])[0].numberValue).toEqual('number');
// string value should remain unchanged
expect(typeof (result as IDataObject[])[1].stringValue).toEqual('number');
// value should be type array
expect(typeof (result as IDataObject[])[2].arrayValue).toEqual('object');
expect(Array.isArray((result as IDataObject[])[2].arrayValue)).toEqual(true);
// value should be type object
expect(typeof (result as IDataObject[])[3].objectValue).toEqual('object');
expect(((result as IDataObject[])[3].objectValue as IDataObject).key).toEqual('value');
});
test('should validate single value parameter', () => {
const nodeType = {
description: {
properties: [
{
displayName: 'Value',
name: 'numberValue',
type: 'number',
default: 0,
validateType: 'number',
},
],
},
} as unknown as INodeType;
const node = {
parameters: {
mode: 'manual',
duplicateItem: false,
numberValue: '777',
include: 'none',
options: {},
},
name: 'Edit Fields2',
type: 'n8n-nodes-base.set',
typeVersion: 3,
} as unknown as INode;
const value = '777';
const parameterName = 'numberValue';
const result = validateValueAgainstSchema(node, nodeType, value, parameterName, 0, 0);
// value should be type number
expect(typeof result).toEqual('number');
});
describe('when the mode is in Fixed mode, and the node is a resource mapper', () => {
const nodeType = {
description: {
properties: [
{
name: 'operation',
type: 'resourceMapper',
typeOptions: {
resourceMapper: {
mode: 'add',
},
},
},
],
},
} as unknown as INodeType;
const node = {
parameters: {
operation: {
schema: [
{ id: 'num', type: 'number', required: true },
{ id: 'str', type: 'string', required: true },
{ id: 'obj', type: 'object', required: true },
{ id: 'arr', type: 'array', required: true },
],
attemptToConvertTypes: true,
mappingMode: '',
value: '',
},
},
} as unknown as INode;
const parameterName = 'operation.value';
describe('should correctly validate values for', () => {
test.each([
{ num: 0 },
{ num: 23 },
{ num: -0 },
{ num: -Infinity },
{ num: Infinity },
{ str: '' },
{ str: ' ' },
{ str: 'hello' },
{ arr: [] },
{ obj: {} },
])('%s', (value) => {
expect(() =>
validateValueAgainstSchema(node, nodeType, value, parameterName, 0, 0),
).not.toThrow();
});
});
describe('should throw an error for', () => {
test.each([{ num: NaN }, { num: undefined }, { num: null }])('%s', (value) => {
expect(() =>
validateValueAgainstSchema(node, nodeType, value, parameterName, 0, 0),
).toThrow();
});
});
});
});

View File

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

View File

@@ -0,0 +1,419 @@
import { DynamicStructuredTool } from '@langchain/core/tools';
import type { IDataObject, INode, INodeType } from 'n8n-workflow';
import { jsonParse, NodeOperationError } from 'n8n-workflow';
import { z } from 'zod';
type AllowedTypes = 'string' | 'number' | 'boolean' | 'json';
interface FromAIArgument {
key: string;
description?: string;
type?: AllowedTypes;
defaultValue?: string | number | boolean | Record<string, unknown>;
}
type ParserOptions = {
node: INode;
nodeType: INodeType;
handleToolInvocation: (toolArgs: IDataObject) => Promise<unknown>;
};
// This file is temporarily duplicated in `packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/FromAIParser.ts`
// Please apply any changes in both files
/**
* AIParametersParser
*
* This class encapsulates the logic for parsing node parameters, extracting $fromAI calls,
* generating Zod schemas, and creating LangChain tools.
*/
class AIParametersParser {
/**
* Constructs an instance of AIParametersParser.
*/
constructor(private readonly options: ParserOptions) {}
/**
* Generates a Zod schema based on the provided FromAIArgument placeholder.
* @param placeholder The FromAIArgument object containing key, type, description, and defaultValue.
* @returns A Zod schema corresponding to the placeholder's type and constraints.
*/
private generateZodSchema(placeholder: FromAIArgument): z.ZodTypeAny {
let schema: z.ZodTypeAny;
switch (placeholder.type?.toLowerCase()) {
case 'string':
schema = z.string();
break;
case 'number':
schema = z.number();
break;
case 'boolean':
schema = z.boolean();
break;
case 'json':
schema = z.record(z.any());
break;
default:
schema = z.string();
}
if (placeholder.description) {
schema = schema.describe(`${schema.description ?? ''} ${placeholder.description}`.trim());
}
if (placeholder.defaultValue !== undefined) {
schema = schema.default(placeholder.defaultValue);
}
return schema;
}
/**
* Recursively traverses the nodeParameters object to find all $fromAI calls.
* @param payload The current object or value being traversed.
* @param collectedArgs The array collecting FromAIArgument objects.
*/
private traverseNodeParameters(payload: unknown, collectedArgs: FromAIArgument[]) {
if (typeof payload === 'string') {
const fromAICalls = this.extractFromAICalls(payload);
fromAICalls.forEach((call) => collectedArgs.push(call));
} else if (Array.isArray(payload)) {
payload.forEach((item: unknown) => this.traverseNodeParameters(item, collectedArgs));
} else if (typeof payload === 'object' && payload !== null) {
Object.values(payload).forEach((value) => this.traverseNodeParameters(value, collectedArgs));
}
}
/**
* Extracts all $fromAI calls from a given string
* @param str The string to search for $fromAI calls.
* @returns An array of FromAIArgument objects.
*
* This method uses a regular expression to find the start of each $fromAI function call
* in the input string. It then employs a character-by-character parsing approach to
* accurately extract the arguments of each call, handling nested parentheses and quoted strings.
*
* The parsing process:
* 1. Finds the starting position of a $fromAI call using regex.
* 2. Iterates through characters, keeping track of parentheses depth and quote status.
* 3. Handles escaped characters within quotes to avoid premature quote closing.
* 4. Builds the argument string until the matching closing parenthesis is found.
* 5. Parses the extracted argument string into a FromAIArgument object.
* 6. Repeats the process for all $fromAI calls in the input string.
*
*/
private extractFromAICalls(str: string): FromAIArgument[] {
const args: FromAIArgument[] = [];
// Regular expression to match the start of a $fromAI function call
const pattern = /\$fromAI\s*\(\s*/gi;
let match: RegExpExecArray | null;
while ((match = pattern.exec(str)) !== null) {
const startIndex = match.index + match[0].length;
let current = startIndex;
let inQuotes = false;
let quoteChar = '';
let parenthesesCount = 1;
let argsString = '';
// Parse the arguments string, handling nested parentheses and quotes
while (current < str.length && parenthesesCount > 0) {
const char = str[current];
if (inQuotes) {
// Handle characters inside quotes, including escaped characters
if (char === '\\' && current + 1 < str.length) {
argsString += char + str[current + 1];
current += 2;
continue;
}
if (char === quoteChar) {
inQuotes = false;
quoteChar = '';
}
argsString += char;
} else {
// Handle characters outside quotes
if (['"', "'", '`'].includes(char)) {
inQuotes = true;
quoteChar = char;
} else if (char === '(') {
parenthesesCount++;
} else if (char === ')') {
parenthesesCount--;
}
// Only add characters if we're still inside the main parentheses
if (parenthesesCount > 0 || char !== ')') {
argsString += char;
}
}
current++;
}
// If parentheses are balanced, parse the arguments
if (parenthesesCount === 0) {
try {
const parsedArgs = this.parseArguments(argsString);
args.push(parsedArgs);
} catch (error) {
// If parsing fails, throw an ApplicationError with details
throw new NodeOperationError(
this.options.node,
`Failed to parse $fromAI arguments: ${argsString}: ${error}`,
);
}
} else {
// Log an error if parentheses are unbalanced
throw new NodeOperationError(
this.options.node,
`Unbalanced parentheses while parsing $fromAI call: ${str.slice(startIndex)}`,
);
}
}
return args;
}
/**
* Parses the arguments of a single $fromAI function call.
* @param argsString The string containing the function arguments.
* @returns A FromAIArgument object.
*/
private parseArguments(argsString: string): FromAIArgument {
// Split arguments by commas not inside quotes
const args: string[] = [];
let currentArg = '';
let inQuotes = false;
let quoteChar = '';
let escapeNext = false;
for (let i = 0; i < argsString.length; i++) {
const char = argsString[i];
if (escapeNext) {
currentArg += char;
escapeNext = false;
continue;
}
if (char === '\\') {
escapeNext = true;
continue;
}
if (['"', "'", '`'].includes(char)) {
if (!inQuotes) {
inQuotes = true;
quoteChar = char;
currentArg += char;
} else if (char === quoteChar) {
inQuotes = false;
quoteChar = '';
currentArg += char;
} else {
currentArg += char;
}
continue;
}
if (char === ',' && !inQuotes) {
args.push(currentArg.trim());
currentArg = '';
continue;
}
currentArg += char;
}
if (currentArg) {
args.push(currentArg.trim());
}
// Remove surrounding quotes if present
const cleanArgs = args.map((arg) => {
const trimmed = arg.trim();
if (
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
(trimmed.startsWith('`') && trimmed.endsWith('`')) ||
(trimmed.startsWith('"') && trimmed.endsWith('"'))
) {
return trimmed
.slice(1, -1)
.replace(/\\'/g, "'")
.replace(/\\`/g, '`')
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\');
}
return trimmed;
});
const type = cleanArgs?.[2] || 'string';
if (!['string', 'number', 'boolean', 'json'].includes(type.toLowerCase())) {
throw new NodeOperationError(this.options.node, `Invalid type: ${type}`);
}
return {
key: cleanArgs[0] || '',
description: cleanArgs[1],
type: (cleanArgs?.[2] ?? 'string') as AllowedTypes,
defaultValue: this.parseDefaultValue(cleanArgs[3]),
};
}
/**
* Parses the default value, preserving its original type.
* @param value The default value as a string.
* @returns The parsed default value in its appropriate type.
*/
private parseDefaultValue(
value: string | undefined,
): string | number | boolean | Record<string, unknown> | undefined {
if (value === undefined || value === '') return undefined;
const lowerValue = value.toLowerCase();
if (lowerValue === 'true') return true;
if (lowerValue === 'false') return false;
if (!isNaN(Number(value))) return Number(value);
try {
return jsonParse(value);
} catch {
return value;
}
}
/**
* Retrieves and validates the Zod schema for the tool.
*
* This method:
* 1. Collects all $fromAI arguments from node parameters
* 2. Validates parameter keys against naming rules
* 3. Checks for duplicate keys and ensures consistency
* 4. Generates a Zod schema from the validated arguments
*
* @throws {NodeOperationError} When parameter keys are invalid or when duplicate keys have inconsistent definitions
* @returns {z.ZodObject} A Zod schema object representing the structure and validation rules for the node parameters
*/
private getSchema() {
const { node } = this.options;
const collectedArguments: FromAIArgument[] = [];
this.traverseNodeParameters(node.parameters, collectedArguments);
// Validate each collected argument
const nameValidationRegex = /^[a-zA-Z0-9_-]{1,64}$/;
const keyMap = new Map<string, FromAIArgument>();
for (const argument of collectedArguments) {
if (argument.key.length === 0 || !nameValidationRegex.test(argument.key)) {
const isEmptyError = 'You must specify a key when using $fromAI()';
const isInvalidError = `Parameter key \`${argument.key}\` is invalid`;
const error = new Error(argument.key.length === 0 ? isEmptyError : isInvalidError);
throw new NodeOperationError(node, error, {
description:
'Invalid parameter key, must be between 1 and 64 characters long and only contain letters, numbers, underscores, and hyphens',
});
}
if (keyMap.has(argument.key)) {
// If the key already exists in the Map
const existingArg = keyMap.get(argument.key)!;
// Check if the existing argument has the same description and type
if (
existingArg.description !== argument.description ||
existingArg.type !== argument.type
) {
// If not, throw an error for inconsistent duplicate keys
throw new NodeOperationError(
node,
`Duplicate key '${argument.key}' found with different description or type`,
{
description:
'Ensure all $fromAI() calls with the same key have consistent descriptions and types',
},
);
}
// If the duplicate key has consistent description and type, it's allowed (no action needed)
} else {
// If the key doesn't exist in the Map, add it
keyMap.set(argument.key, argument);
}
}
// Remove duplicate keys, latest occurrence takes precedence
const uniqueArgsMap = collectedArguments.reduce((map, arg) => {
map.set(arg.key, arg);
return map;
}, new Map<string, FromAIArgument>());
const uniqueArguments = Array.from(uniqueArgsMap.values());
// Generate Zod schema from unique arguments
const schemaObj = uniqueArguments.reduce((acc: Record<string, z.ZodTypeAny>, placeholder) => {
acc[placeholder.key] = this.generateZodSchema(placeholder);
return acc;
}, {});
return z.object(schemaObj).required();
}
/**
* Generates a description for a node based on the provided parameters.
* @param node The node type.
* @param nodeParameters The parameters of the node.
* @returns A string description for the node.
*/
private getDescription(): string {
const { node, nodeType } = this.options;
const manualDescription = node.parameters.toolDescription as string;
if (node.parameters.descriptionType === 'auto') {
const resource = node.parameters.resource as string;
const operation = node.parameters.operation as string;
let description = nodeType.description.description;
if (resource) {
description += `\n Resource: ${resource}`;
}
if (operation) {
description += `\n Operation: ${operation}`;
}
return description.trim();
}
if (node.parameters.descriptionType === 'manual') {
return manualDescription ?? nodeType.description.description;
}
return nodeType.description.description;
}
/**
* Creates a DynamicStructuredTool from a node.
* @returns A DynamicStructuredTool instance.
*/
createTool(): DynamicStructuredTool {
const { node, nodeType } = this.options;
const schema = this.getSchema();
const description = this.getDescription();
const nodeName = node.name.replace(/ /g, '_');
const name = nodeName || nodeType.description.name;
return new DynamicStructuredTool({
name,
description,
schema,
func: async (toolArgs: z.infer<typeof schema>) =>
await this.options.handleToolInvocation(toolArgs),
});
}
}
/**
* Converts node into LangChain tool by analyzing node parameters,
* identifying placeholders using the $fromAI function, and generating a Zod schema. It then creates
* a DynamicStructuredTool that can be used in LangChain workflows.
*/
export function createNodeAsTool(options: ParserOptions) {
const parser = new AIParametersParser(options);
return { response: parser.createTool() };
}

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

View File

@@ -0,0 +1,75 @@
import type { IRunExecutionData } from 'n8n-workflow';
import { LoggerProxy as Logger } from 'n8n-workflow';
import { InvalidExecutionMetadataError } from '@/errors/invalid-execution-metadata.error';
export const KV_LIMIT = 10;
export function setWorkflowExecutionMetadata(
executionData: IRunExecutionData,
key: string,
value: unknown,
) {
if (!executionData.resultData.metadata) {
executionData.resultData.metadata = {};
}
// Currently limited to 10 metadata KVs
if (
!(key in executionData.resultData.metadata) &&
Object.keys(executionData.resultData.metadata).length >= KV_LIMIT
) {
return;
}
if (typeof key !== 'string') {
throw new InvalidExecutionMetadataError('key', key);
}
if (key.replace(/[A-Za-z0-9_]/g, '').length !== 0) {
throw new InvalidExecutionMetadataError(
'key',
key,
`Custom date key can only contain characters "A-Za-z0-9_" (key "${key}")`,
);
}
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'bigint') {
throw new InvalidExecutionMetadataError('value', key);
}
const val = String(value);
if (key.length > 50) {
Logger.error('Custom data key over 50 characters long. Truncating to 50 characters.');
}
if (val.length > 255) {
Logger.error('Custom data value over 255 characters long. Truncating to 255 characters.');
}
executionData.resultData.metadata[key.slice(0, 50)] = val.slice(0, 255);
}
export function setAllWorkflowExecutionMetadata(
executionData: IRunExecutionData,
obj: Record<string, string>,
) {
const errors: Error[] = [];
Object.entries(obj).forEach(([key, value]) => {
try {
setWorkflowExecutionMetadata(executionData, key, value);
} catch (e) {
errors.push(e as Error);
}
});
if (errors.length) {
throw errors[0];
}
}
export function getAllWorkflowExecutionMetadata(
executionData: IRunExecutionData,
): Record<string, string> {
// Make a copy so it can't be modified directly
return executionData.resultData.metadata ? { ...executionData.resultData.metadata } : {};
}
export function getWorkflowExecutionMetadata(
executionData: IRunExecutionData,
key: string,
): string {
return getAllWorkflowExecutionMetadata(executionData)[String(key).slice(0, 50)];
}

View File

@@ -0,0 +1,207 @@
import get from 'lodash/get';
import {
ApplicationError,
LoggerProxy,
NodeHelpers,
NodeOperationError,
WorkflowOperationError,
executeFilter,
isFilterValue,
type INode,
type INodeParameters,
type INodeProperties,
type INodePropertyCollection,
type INodePropertyOptions,
type INodeType,
type NodeParameterValueType,
} from 'n8n-workflow';
function findPropertyFromParameterName(
parameterName: string,
nodeType: INodeType,
node: INode,
nodeParameters: INodeParameters,
): INodePropertyOptions | INodeProperties | INodePropertyCollection {
let property: INodePropertyOptions | INodeProperties | INodePropertyCollection | undefined;
const paramParts = parameterName.split('.');
let currentParamPath = '';
const findProp = (
name: string,
options: Array<INodePropertyOptions | INodeProperties | INodePropertyCollection>,
): INodePropertyOptions | INodeProperties | INodePropertyCollection | undefined => {
return options.find(
(i) =>
i.name === name &&
NodeHelpers.displayParameterPath(nodeParameters, i, currentParamPath, node),
);
};
for (const p of paramParts) {
const param = p.split('[')[0];
if (!property) {
property = findProp(param, nodeType.description.properties);
} else if ('options' in property && property.options) {
property = findProp(param, property.options);
currentParamPath += `.${param}`;
} else if ('values' in property) {
property = findProp(param, property.values);
currentParamPath += `.${param}`;
} else {
throw new ApplicationError('Could not find property', { extra: { parameterName } });
}
if (!property) {
throw new ApplicationError('Could not find property', { extra: { parameterName } });
}
}
if (!property) {
throw new ApplicationError('Could not find property', { extra: { parameterName } });
}
return property;
}
function executeRegexExtractValue(
value: string,
regex: RegExp,
parameterName: string,
parameterDisplayName: string,
): NodeParameterValueType | object {
const extracted = regex.exec(value);
if (!extracted) {
throw new WorkflowOperationError(
`ERROR: ${parameterDisplayName} parameter's value is invalid. This is likely because the URL entered is incorrect`,
);
}
if (extracted.length < 2 || extracted.length > 2) {
throw new WorkflowOperationError(
`Property "${parameterName}" has an invalid extractValue regex "${regex.source}". extractValue expects exactly one group to be returned.`,
);
}
return extracted[1];
}
function extractValueRLC(
value: NodeParameterValueType | object,
property: INodeProperties,
parameterName: string,
): NodeParameterValueType | object {
// Not an RLC value
if (typeof value !== 'object' || !value || !('mode' in value) || !('value' in value)) {
return value;
}
const modeProp = (property.modes ?? []).find((i) => i.name === value.mode);
if (!modeProp) {
return value.value;
}
if (!('extractValue' in modeProp) || !modeProp.extractValue) {
return value.value;
}
if (typeof value.value !== 'string') {
let typeName: string | undefined = value.value?.constructor.name;
if (value.value === null) {
typeName = 'null';
} else if (typeName === undefined) {
typeName = 'undefined';
}
LoggerProxy.error(
`Only strings can be passed to extractValue. Parameter "${parameterName}" passed "${typeName}"`,
);
throw new ApplicationError(
"ERROR: This parameter's value is invalid. Please enter a valid mode.",
{ extra: { parameter: property.displayName, modeProp: modeProp.displayName } },
);
}
if (modeProp.extractValue.type !== 'regex') {
throw new ApplicationError('Property with unknown `extractValue`', {
extra: { parameter: parameterName, extractValueType: modeProp.extractValue.type },
});
}
const regex = new RegExp(modeProp.extractValue.regex);
return executeRegexExtractValue(value.value, regex, parameterName, property.displayName);
}
function extractValueFilter(
value: NodeParameterValueType | object,
property: INodeProperties,
parameterName: string,
itemIndex: number,
): NodeParameterValueType | object {
if (!isFilterValue(value)) {
return value;
}
if (property.extractValue?.type) {
throw new ApplicationError(
`Property "${parameterName}" has an invalid extractValue type. Filter parameters only support extractValue: true`,
{ extra: { parameter: parameterName } },
);
}
return executeFilter(value, { itemIndex });
}
function extractValueOther(
value: NodeParameterValueType | object,
property: INodeProperties | INodePropertyCollection,
parameterName: string,
): NodeParameterValueType | object {
if (!('extractValue' in property) || !property.extractValue) {
return value;
}
if (typeof value !== 'string') {
let typeName: string | undefined = value?.constructor.name;
if (value === null) {
typeName = 'null';
} else if (typeName === undefined) {
typeName = 'undefined';
}
LoggerProxy.error(
`Only strings can be passed to extractValue. Parameter "${parameterName}" passed "${typeName}"`,
);
throw new ApplicationError("This parameter's value is invalid", {
extra: { parameter: property.displayName },
});
}
if (property.extractValue.type !== 'regex') {
throw new ApplicationError('Property with unknown `extractValue`', {
extra: { parameter: parameterName, extractValueType: property.extractValue.type },
});
}
const regex = new RegExp(property.extractValue.regex);
return executeRegexExtractValue(value, regex, parameterName, property.displayName);
}
export function extractValue(
value: NodeParameterValueType | object,
parameterName: string,
node: INode,
nodeType: INodeType,
itemIndex = 0,
): NodeParameterValueType | object {
let property: INodePropertyOptions | INodeProperties | INodePropertyCollection;
try {
property = findPropertyFromParameterName(parameterName, nodeType, node, node.parameters);
// Definitely doesn't have value extractor
if (!('type' in property)) {
return value;
}
if (property.type === 'resourceLocator') {
return extractValueRLC(value, property, parameterName);
} else if (property.type === 'filter') {
return extractValueFilter(value, property, parameterName, itemIndex);
}
return extractValueOther(value, property, parameterName);
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment
throw new NodeOperationError(node, error, { description: get(error, 'description') });
}
}

View File

@@ -0,0 +1,75 @@
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 './execution-metadata';
import { getSecretsProxy } from './get-secrets-proxy';
/** 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,
};
}

View File

@@ -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 './create-node-as-tool';
// 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);
}

View File

@@ -0,0 +1,76 @@
import type { IDataObject, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
import { ExpressionError } from 'n8n-workflow';
function buildSecretsValueProxy(value: IDataObject): unknown {
return new Proxy(value, {
get(_target, valueName) {
if (typeof valueName !== 'string') {
return;
}
if (!(valueName in value)) {
throw new ExpressionError('Could not load secrets', {
description:
'The credential in use tries to use secret from an external store that could not be found',
});
}
const retValue = value[valueName];
if (typeof retValue === 'object' && retValue !== null) {
return buildSecretsValueProxy(retValue as IDataObject);
}
return retValue;
},
});
}
export function getSecretsProxy(additionalData: IWorkflowExecuteAdditionalData): IDataObject {
const secretsHelpers = additionalData.secretsHelpers;
return new Proxy(
{},
{
get(_target, providerName) {
if (typeof providerName !== 'string') {
return {};
}
if (secretsHelpers.hasProvider(providerName)) {
return new Proxy(
{},
{
get(_target2, secretName) {
if (typeof secretName !== 'string') {
return;
}
if (!secretsHelpers.hasSecret(providerName, secretName)) {
throw new ExpressionError('Could not load secrets', {
description:
'The credential in use tries to use secret from an external store that could not be found',
});
}
const retValue = secretsHelpers.getSecret(providerName, secretName);
if (typeof retValue === 'object' && retValue !== null) {
return buildSecretsValueProxy(retValue as IDataObject);
}
return retValue;
},
set() {
return false;
},
ownKeys() {
return secretsHelpers.listSecrets(providerName);
},
},
);
}
throw new ExpressionError('Could not load secrets', {
description:
'The credential in use pulls secrets from an external store that is not reachable',
});
},
set() {
return false;
},
ownKeys() {
return secretsHelpers.listProviders();
},
},
);
}

View File

@@ -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 === undefined || resolvedValue === null)
) {
return {
valid: false,
errorMessage: `The value "${String(key)}" is required but not set`,
fieldName: key,
};
}
if (schemaEntry?.type) {
const validationResult = validateFieldType(key, resolvedValue, schemaEntry.type, {
valueOptions: schemaEntry.options,
strict: !resourceMapperField.attemptToConvertTypes,
parseStrings: !!resourceMapperField.convertFieldsToString,
});
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;
};

View File

@@ -0,0 +1,180 @@
import type { Request, Response } from 'express';
import type {
AINodeConnectionType,
CloseFunction,
ICredentialDataDecryptedObject,
IDataObject,
IExecuteData,
INode,
INodeExecutionData,
IRunExecutionData,
ITaskDataConnections,
IWebhookData,
IWebhookFunctions,
IWorkflowExecuteAdditionalData,
WebhookType,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { ApplicationError, createDeferredPromise } from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import {
copyBinaryFile,
getBinaryHelperFunctions,
getNodeWebhookUrl,
getRequestHelperFunctions,
returnJsonArray,
} from '@/node-execute-functions';
import { NodeExecutionContext } from './node-execution-context';
import { getInputConnectionData } from './utils/get-input-connection-data';
export class WebhookContext extends NodeExecutionContext implements IWebhookFunctions {
readonly helpers: IWebhookFunctions['helpers'];
readonly nodeHelpers: IWebhookFunctions['nodeHelpers'];
constructor(
workflow: Workflow,
node: INode,
additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode,
private readonly webhookData: IWebhookData,
private readonly closeFunctions: CloseFunction[],
runExecutionData: IRunExecutionData | null,
) {
let connectionInputData: INodeExecutionData[] = [];
let executionData: IExecuteData | undefined;
if (runExecutionData?.executionData !== undefined) {
executionData = runExecutionData.executionData.nodeExecutionStack[0];
if (executionData !== undefined) {
connectionInputData = executionData.data.main[0]!;
}
}
super(
workflow,
node,
additionalData,
mode,
runExecutionData,
0,
connectionInputData,
executionData,
);
this.helpers = {
createDeferredPromise,
returnJsonArray,
...getRequestHelperFunctions(workflow, node, additionalData),
...getBinaryHelperFunctions(additionalData, workflow.id),
};
this.nodeHelpers = {
copyBinaryFile: async (filePath, fileName, mimeType) =>
await copyBinaryFile(
this.workflow.id,
this.additionalData.executionId!,
filePath,
fileName,
mimeType,
),
};
}
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
return await this._getCredentials<T>(type);
}
getBodyData() {
return this.assertHttpRequest().body as IDataObject;
}
getHeaderData() {
return this.assertHttpRequest().headers;
}
getParamsData(): object {
return this.assertHttpRequest().params;
}
getQueryData(): object {
return this.assertHttpRequest().query;
}
getRequestObject(): Request {
return this.assertHttpRequest();
}
getResponseObject(): Response {
if (this.additionalData.httpResponse === undefined) {
throw new ApplicationError('Response is missing');
}
return this.additionalData.httpResponse;
}
private assertHttpRequest() {
const { httpRequest } = this.additionalData;
if (httpRequest === undefined) {
throw new ApplicationError('Request is missing');
}
return httpRequest;
}
getNodeWebhookUrl(name: WebhookType): string | undefined {
return getNodeWebhookUrl(
name,
this.workflow,
this.node,
this.additionalData,
this.mode,
this.additionalKeys,
);
}
getWebhookName() {
return this.webhookData.webhookDescription.name;
}
async getInputConnectionData(
connectionType: AINodeConnectionType,
itemIndex: number,
): Promise<unknown> {
// To be able to use expressions like "$json.sessionId" set the
// body data the webhook received to what is normally used for
// incoming node data.
const connectionInputData: INodeExecutionData[] = [
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
{ json: this.additionalData.httpRequest?.body || {} },
];
const runExecutionData: IRunExecutionData = {
resultData: {
runData: {},
},
};
const executeData: IExecuteData = {
data: {
main: [connectionInputData],
},
node: this.node,
source: null,
};
return await getInputConnectionData.call(
this,
this.workflow,
runExecutionData,
this.runIndex,
connectionInputData,
{} as ITaskDataConnections,
this.additionalData,
executeData,
this.mode,
this.closeFunctions,
connectionType,
itemIndex,
);
}
}

View File

@@ -0,0 +1,36 @@
import type {
IGetNodeParameterOptions,
INode,
IWorkflowExecuteAdditionalData,
Workflow,
IWorkflowNodeContext,
} from 'n8n-workflow';
import { NodeExecutionContext } from './node-execution-context';
export class LoadWorkflowNodeContext extends NodeExecutionContext implements IWorkflowNodeContext {
// Note that this differs from and does not shadow the function with the
// same name in `NodeExecutionContext`, as it has the `itemIndex` parameter
readonly getNodeParameter: IWorkflowNodeContext['getNodeParameter'];
constructor(workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData) {
super(workflow, node, additionalData, 'internal');
{
// We need to cast due to the overloaded IWorkflowNodeContext::getNodeParameter function
// Which would require us to replicate all overload return types, as TypeScript offers
// no convenient solution to refer to a set of overloads.
this.getNodeParameter = ((
parameterName: string,
itemIndex: number,
fallbackValue?: unknown,
options?: IGetNodeParameterOptions,
) =>
this._getNodeParameter(
parameterName,
itemIndex,
fallbackValue,
options,
)) as IWorkflowNodeContext['getNodeParameter'];
}
}
}