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