diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 38679513de..20d5ae0833 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -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 { - 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, diff --git a/packages/core/src/node-execution-context/__tests__/node-execution-context.test.ts b/packages/core/src/node-execution-context/__tests__/node-execution-context.test.ts index 0231873984..443460bb33 100644 --- a/packages/core/src/node-execution-context/__tests__/node-execution-context.test.ts +++ b/packages/core/src/node-execution-context/__tests__/node-execution-context.test.ts @@ -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({ instanceId: 'abc123' }); Container.set(InstanceSettings, instanceSettings); + const node = mock(); + const nodeType = mock({ description: mock() }); + const nodeTypes = mock(); + const expression = mock(); const workflow = mock({ id: '123', name: 'Test Workflow', active: true, - nodeTypes: mock(), + nodeTypes, timezone: 'UTC', + expression, }); - const node = mock(); let additionalData = mock({ 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({ 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({ name: 'Node 1', type: 'test', disabled: false }); + const node2 = mock({ 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({ name: 'Node 1', type: 'test', disabled: false }); + const node2 = mock({ 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({ 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]); + }); + }); }); diff --git a/packages/core/src/node-execution-context/base-execute-context.ts b/packages/core/src/node-execution-context/base-execute-context.ts index 8ecc658579..24b9e89301 100644 --- a/packages/core/src/node-execution-context/base-execute-context.ts +++ b/packages/core/src/node-execution-context/base-execute-context.ts @@ -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 diff --git a/packages/core/src/node-execution-context/execute-context.ts b/packages/core/src/node-execution-context/execute-context.ts index d563881bea..f3e32608f3 100644 --- a/packages/core/src/node-execution-context/execute-context.ts +++ b/packages/core/src/node-execution-context/execute-context.ts @@ -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']; diff --git a/packages/core/src/node-execution-context/index.ts b/packages/core/src/node-execution-context/index.ts index 00c90266db..64088af72e 100644 --- a/packages/core/src/node-execution-context/index.ts +++ b/packages/core/src/node-execution-context/index.ts @@ -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'; diff --git a/packages/core/src/node-execution-context/node-execution-context.ts b/packages/core/src/node-execution-context/node-execution-context.ts index 158b06d02e..8477fe7856 100644 --- a/packages/core/src/node-execution-context/node-execution-context.ts +++ b/packages/core/src/node-execution-context/node-execution-context.ts @@ -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 { protected readonly instanceSettings = Container.get(InstanceSettings); @@ -108,6 +110,42 @@ export abstract class NodeExecutionContext implements Omit (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 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): 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 { - return getAllWorkflowExecutionMetadata(runExecutionData); - }, - } - : undefined, - }, - $vars: additionalData.variables, - $secrets: options?.secretsEnabled ? getSecretsProxy(additionalData) : undefined, - - // deprecated - $executionId: executionId, - $resumeWebhookUrl: resumeUrl, - }; -} diff --git a/packages/core/src/node-execution-context/utils/__tests__/cleanupParameterData.test.ts b/packages/core/src/node-execution-context/utils/__tests__/cleanupParameterData.test.ts new file mode 100644 index 0000000000..47913669b6 --- /dev/null +++ b/packages/core/src/node-execution-context/utils/__tests__/cleanupParameterData.test.ts @@ -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'); + }); +}); diff --git a/packages/core/src/node-execution-context/utils/__tests__/ensureType.test.ts b/packages/core/src/node-execution-context/utils/__tests__/ensureType.test.ts new file mode 100644 index 0000000000..1637d988c9 --- /dev/null +++ b/packages/core/src/node-execution-context/utils/__tests__/ensureType.test.ts @@ -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"), + ); + }); +}); diff --git a/packages/core/src/node-execution-context/utils/__tests__/getAdditionalKeys.test.ts b/packages/core/src/node-execution-context/utils/__tests__/getAdditionalKeys.test.ts new file mode 100644 index 0000000000..6ac1fbdc07 --- /dev/null +++ b/packages/core/src/node-execution-context/utils/__tests__/getAdditionalKeys.test.ts @@ -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(); + const additionalData = mock({ + executionId: '123', + webhookWaitingBaseUrl: 'https://webhook.test', + formWaitingBaseUrl: 'https://form.test', + variables: { testVar: 'value' }, + secretsHelpers, + }); + + const runExecutionData = mock({ + 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); + }); +}); diff --git a/packages/core/src/node-execution-context/utils/__tests__/getInputConnectionData.test.ts b/packages/core/src/node-execution-context/utils/__tests__/getInputConnectionData.test.ts new file mode 100644 index 0000000000..4e634a196e --- /dev/null +++ b/packages/core/src/node-execution-context/utils/__tests__/getInputConnectionData.test.ts @@ -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({ + name: 'Test Agent', + type: 'test.agent', + parameters: {}, + }); + const agentNodeType = mock({ + description: { + inputs: [], + }, + }); + const nodeTypes = mock(); + const workflow = mock({ + id: 'test-workflow', + active: false, + nodeTypes, + }); + const runExecutionData = mock({ + resultData: { runData: {} }, + }); + const connectionInputData = [] as INodeExecutionData[]; + const inputData = {} as ITaskDataConnections; + const executeData = {} as IExecuteData; + + const hooks = mock>(); + const additionalData = mock({ 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({ + name: 'First Node', + type: 'test.type', + disabled: false, + }); + const secondNode = mock({ name: 'Second Node', disabled: false }); + const supplyData = jest.fn().mockResolvedValue({ response }); + const nodeType = mock({ 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({ + 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(); + const toolNode = mock({ + name: 'Test Tool', + type: 'test.tool', + disabled: false, + }); + const supplyData = jest.fn().mockResolvedValue({ response: mockTool }); + const toolNodeType = mock({ supplyData }); + + const secondToolNode = mock({ name: 'test.secondTool', disabled: false }); + const secondMockTool = mock(); + const secondToolNodeType = mock({ + 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({ + 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(); + }); + }); +}); diff --git a/packages/core/src/node-execution-context/__tests__/utils.test.ts b/packages/core/src/node-execution-context/utils/__tests__/validateValueAgainstSchema.test.ts similarity index 59% rename from packages/core/src/node-execution-context/__tests__/utils.test.ts rename to packages/core/src/node-execution-context/utils/__tests__/validateValueAgainstSchema.test.ts index 1871af4c0d..e09299a457 100644 --- a/packages/core/src/node-execution-context/__tests__/utils.test.ts +++ b/packages/core/src/node-execution-context/utils/__tests__/validateValueAgainstSchema.test.ts @@ -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', () => { diff --git a/packages/core/src/node-execution-context/utils/cleanupParameterData.ts b/packages/core/src/node-execution-context/utils/cleanupParameterData.ts new file mode 100644 index 0000000000..59f27874ff --- /dev/null +++ b/packages/core/src/node-execution-context/utils/cleanupParameterData.ts @@ -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); + } + } + }); + } +} diff --git a/packages/core/src/node-execution-context/utils/ensureType.ts b/packages/core/src/node-execution-context/utils/ensureType.ts new file mode 100644 index 0000000000..4869c73ba4 --- /dev/null +++ b/packages/core/src/node-execution-context/utils/ensureType.ts @@ -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; +} diff --git a/packages/core/src/node-execution-context/utils/getAdditionalKeys.ts b/packages/core/src/node-execution-context/utils/getAdditionalKeys.ts new file mode 100644 index 0000000000..28bf3b89f6 --- /dev/null +++ b/packages/core/src/node-execution-context/utils/getAdditionalKeys.ts @@ -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): 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 { + return getAllWorkflowExecutionMetadata(runExecutionData); + }, + } + : undefined, + }, + $vars: additionalData.variables, + $secrets: options?.secretsEnabled ? getSecretsProxy(additionalData) : undefined, + + // deprecated + $executionId: executionId, + $resumeWebhookUrl: resumeUrl, + }; +} diff --git a/packages/core/src/node-execution-context/utils/getInputConnectionData.ts b/packages/core/src/node-execution-context/utils/getInputConnectionData.ts new file mode 100644 index 0000000000..231f672d65 --- /dev/null +++ b/packages/core/src/node-execution-context/utils/getInputConnectionData.ts @@ -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 { + 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); +} diff --git a/packages/core/src/node-execution-context/utils/validateValueAgainstSchema.ts b/packages/core/src/node-execution-context/utils/validateValueAgainstSchema.ts new file mode 100644 index 0000000000..2792199807 --- /dev/null +++ b/packages/core/src/node-execution-context/utils/validateValueAgainstSchema.ts @@ -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; +}; diff --git a/packages/core/src/node-execution-context/webhook-context.ts b/packages/core/src/node-execution-context/webhook-context.ts index 04d1df5e40..9d131a4103 100644 --- a/packages/core/src/node-execution-context/webhook-context.ts +++ b/packages/core/src/node-execution-context/webhook-context.ts @@ -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']; diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index c5fa0a938a..c5df420e29 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -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): void; + get(key: string): string; + getAll(): Record; + }; + }; + $vars?: IDataObject; + $secrets?: IDataObject; + $pageCount?: number; + + /** @deprecated */ + $executionId?: string; + /** @deprecated */ + $resumeWebhookUrl?: string; +}; export interface IWorkflowMetadata { id?: string;