From 75e2b6fd9e8b4f76bb7b4cca02e32c3d3d84023f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Tue, 26 Nov 2024 12:10:42 +0100 Subject: [PATCH] refactor(core): Extract ExecuteContext out of NodeExecutionFunctions (no-changelog) (#11853) --- packages/cli/src/credentials-helper.ts | 4 +- packages/core/src/NodeExecuteFunctions.ts | 1078 +---------------- .../__tests__/execute-context.test.ts | 212 ++++ .../__tests__/execute-single-context.test.ts | 142 +-- ...test.ts => node-execution-context.test.ts} | 2 +- .../__tests__/shared-tests.ts | 181 +++ .../__tests__/supply-data-context.test.ts | 87 +- .../__tests__/utils.test.ts} | 119 +- .../base-execute-context.ts | 213 ++++ .../node-execution-context/execute-context.ts | 263 ++++ .../execute-single-context.ts | 152 +-- .../helpers/binary-helpers.ts | 0 .../node-execution-context/hook-context.ts | 38 +- .../core/src/node-execution-context/index.ts | 3 + .../load-options-context.ts | 39 +- .../node-execution-context.ts | 280 ++++- .../node-execution-context/poll-context.ts | 36 +- .../supply-data-context.ts | 225 +--- .../node-execution-context/trigger-context.ts | 36 +- .../core/src/node-execution-context/utils.ts | 423 +++++++ .../node-execution-context/webhook-context.ts | 104 +- .../core/test/NodeExecuteFunctions.test.ts | 116 -- 22 files changed, 1813 insertions(+), 1940 deletions(-) create mode 100644 packages/core/src/node-execution-context/__tests__/execute-context.test.ts rename packages/core/src/node-execution-context/__tests__/{base-context.test.ts => node-execution-context.test.ts} (99%) create mode 100644 packages/core/src/node-execution-context/__tests__/shared-tests.ts rename packages/core/{test/Validation.test.ts => src/node-execution-context/__tests__/utils.test.ts} (59%) create mode 100644 packages/core/src/node-execution-context/base-execute-context.ts create mode 100644 packages/core/src/node-execution-context/execute-context.ts create mode 100644 packages/core/src/node-execution-context/helpers/binary-helpers.ts create mode 100644 packages/core/src/node-execution-context/utils.ts diff --git a/packages/cli/src/credentials-helper.ts b/packages/cli/src/credentials-helper.ts index e57ad2c6d3265..967ee9efe4dac 100644 --- a/packages/cli/src/credentials-helper.ts +++ b/packages/cli/src/credentials-helper.ts @@ -4,7 +4,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-return */ // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; -import { Credentials, NodeExecuteFunctions } from 'n8n-core'; +import { Credentials, getAdditionalKeys } from 'n8n-core'; import type { ICredentialDataDecryptedObject, ICredentialsExpressionResolveValues, @@ -379,7 +379,7 @@ export class CredentialsHelper extends ICredentialsHelper { decryptedData.oauthTokenData = decryptedDataOriginal.oauthTokenData; } - const additionalKeys = NodeExecuteFunctions.getAdditionalKeys(additionalData, mode, null, { + const additionalKeys = getAdditionalKeys(additionalData, mode, null, { secretsEnabled: canUseSecrets, }); diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 807daab46bd25..750ff80dd95b8 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ - /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-return */ @@ -33,45 +32,28 @@ import get from 'lodash/get'; import isEmpty from 'lodash/isEmpty'; import merge from 'lodash/merge'; import pick from 'lodash/pick'; -import { DateTime } from 'luxon'; import { extension, lookup } from 'mime-types'; import type { BinaryHelperFunctions, CloseFunction, - ContextType, - ExecuteWorkflowData, - FieldType, FileSystemHelperFunctions, - FunctionsBase, GenericValue, IAdditionalCredentialOptions, IAllExecuteFunctions, IBinaryData, - IContextObject, ICredentialDataDecryptedObject, ICredentialTestFunctions, - ICredentialsExpressionResolveValues, IDataObject, IExecuteData, IExecuteFunctions, - IExecuteResponsePromiseData, IExecuteSingleFunctions, - IExecuteWorkflowInfo, - IGetNodeParameterOptions, IHookFunctions, IHttpRequestOptions, IN8nHttpFullResponse, IN8nHttpResponse, INode, - INodeCredentialDescription, - INodeCredentialsDetails, INodeExecutionData, INodeInputConfiguration, - INodeOutputConfiguration, - INodeProperties, - INodePropertyCollection, - INodePropertyOptions, - INodeType, IOAuth2Options, IPairedItemData, IPollFunctions, @@ -85,20 +67,15 @@ import type { IWebhookDescription, IWebhookFunctions, IWorkflowDataProxyAdditionalKeys, - IWorkflowDataProxyData, IWorkflowExecuteAdditionalData, NodeExecutionWithMetadata, NodeHelperFunctions, NodeParameterValueType, - NodeTypeAndVersion, PaginationOptions, RequestHelperFunctions, Workflow, WorkflowActivateMode, WorkflowExecuteMode, - CallbackManager, - INodeParameters, - EnsureTypeOptions, SSHTunnelFunctions, DeduplicationHelperFunctions, IDeduplicationOutput, @@ -107,27 +84,20 @@ import type { DeduplicationScope, DeduplicationItemTypes, ICheckProcessedContextData, - AiEvent, ISupplyDataFunctions, WebhookType, SchedulingFunctions, - RelatedExecution, } from 'n8n-workflow'; import { NodeConnectionType, - ExpressionError, LoggerProxy as Logger, NodeApiError, NodeHelpers, NodeOperationError, NodeSslError, - WorkflowDataProxy, - createDeferredPromise, deepCopy, fileTypeFromMimeType, isObjectEmpty, - isResourceMapperValue, - validateFieldType, ExecutionBaseError, jsonParse, ApplicationError, @@ -141,7 +111,6 @@ import { Readable } from 'stream'; import Container from 'typedi'; import url, { URL, URLSearchParams } from 'url'; -import { createAgentStartJob } from './Agent'; import { BinaryDataService } from './BinaryData/BinaryData.service'; import type { BinaryData } from './BinaryData/types'; import { binaryToBuffer } from './BinaryData/utils'; @@ -150,26 +119,17 @@ import { BLOCK_FILE_ACCESS_TO_N8N_FILES, CONFIG_FILES, CUSTOM_EXTENSION_ENV, - HTTP_REQUEST_NODE_TYPE, - HTTP_REQUEST_TOOL_NODE_TYPE, - PLACEHOLDER_EMPTY_EXECUTION_ID, RESTRICT_FILE_ACCESS_TO, UM_EMAIL_TEMPLATES_INVITE, UM_EMAIL_TEMPLATES_PWRESET, } from './Constants'; import { createNodeAsTool } from './CreateNodeAsTool'; import { DataDeduplicationService } from './data-deduplication-service'; -import { - getAllWorkflowExecutionMetadata, - getWorkflowExecutionMetadata, - setAllWorkflowExecutionMetadata, - setWorkflowExecutionMetadata, -} from './ExecutionMetadata'; -import { extractValue } from './ExtractValue'; import { InstanceSettings } from './InstanceSettings'; -import type { ExtendedValidationResult, IResponseError } from './Interfaces'; +import type { IResponseError } from './Interfaces'; // eslint-disable-next-line import/no-cycle import { + ExecuteContext, ExecuteSingleContext, HookContext, PollContext, @@ -178,7 +138,6 @@ import { WebhookContext, } from './node-execution-context'; import { ScheduledTaskManager } from './ScheduledTaskManager'; -import { getSecretsProxy } from './Secrets'; import { SSHClientsManager } from './SSHClientsManager'; axios.defaults.timeout = 300000; @@ -2023,630 +1982,8 @@ export async function requestWithAuthentication( } } -/** - * 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; - } - Logger.debug(e.message); - } - }, - setAll(obj: Record): void { - try { - setAllWorkflowExecutionMetadata(runExecutionData, obj); - } catch (e) { - if (mode === 'manual') { - throw e; - } - Logger.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, - }; -} - -/** - * Returns the requested decrypted credentials if the node has access to them. - * - * @param {Workflow} workflow Workflow which requests the data - * @param {INode} node Node which request the data - * @param {string} type The credential type to return - */ -export async function getCredentials( - workflow: Workflow, - node: INode, - type: string, - additionalData: IWorkflowExecuteAdditionalData, - mode: WorkflowExecuteMode, - executeData?: IExecuteData, - runExecutionData?: IRunExecutionData | null, - runIndex?: number, - connectionInputData?: INodeExecutionData[], - itemIndex?: number, -): Promise { - // Get the NodeType as it has the information if the credentials are required - const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); - if (nodeType === undefined) { - throw new NodeOperationError( - node, - `Node type "${node.type}" is not known so can not get credentials`, - ); - } - - // Hardcode for now for security reasons that only a single node can access - // all credentials - const fullAccess = [HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_TOOL_NODE_TYPE].includes(node.type); - - let nodeCredentialDescription: INodeCredentialDescription | undefined; - if (!fullAccess) { - if (nodeType.description.credentials === undefined) { - throw new NodeOperationError( - node, - `Node type "${node.type}" does not have any credentials defined`, - { level: 'warning' }, - ); - } - - nodeCredentialDescription = nodeType.description.credentials.find( - (credentialTypeDescription) => credentialTypeDescription.name === type, - ); - if (nodeCredentialDescription === undefined) { - throw new NodeOperationError( - node, - `Node type "${node.type}" does not have any credentials of type "${type}" defined`, - { level: 'warning' }, - ); - } - - if ( - !NodeHelpers.displayParameter( - additionalData.currentNodeParameters || node.parameters, - nodeCredentialDescription, - node, - node.parameters, - ) - ) { - // Credentials should not be displayed even if they would be defined - throw new NodeOperationError(node, 'Credentials not found'); - } - } - - // Check if node has any credentials defined - if (!fullAccess && !node.credentials?.[type]) { - // If none are defined check if the credentials are required or not - - if (nodeCredentialDescription?.required === true) { - // Credentials are required so error - if (!node.credentials) { - throw new NodeOperationError(node, 'Node does not have any credentials set', { - level: 'warning', - }); - } - if (!node.credentials[type]) { - throw new NodeOperationError(node, `Node does not have any credentials set for "${type}"`, { - level: 'warning', - }); - } - } else { - // Credentials are not required - throw new NodeOperationError(node, 'Node does not require credentials'); - } - } - - if (fullAccess && !node.credentials?.[type]) { - // Make sure that fullAccess nodes still behave like before that if they - // request access to credentials that are currently not set it returns undefined - throw new NodeOperationError(node, 'Credentials not found'); - } - - let expressionResolveValues: ICredentialsExpressionResolveValues | undefined; - if (connectionInputData && runExecutionData && runIndex !== undefined) { - expressionResolveValues = { - connectionInputData, - itemIndex: itemIndex || 0, - node, - runExecutionData, - runIndex, - workflow, - } as ICredentialsExpressionResolveValues; - } - - const nodeCredentials = node.credentials - ? node.credentials[type] - : ({} as INodeCredentialsDetails); - - // TODO: solve using credentials via expression - // if (name.charAt(0) === '=') { - // // If the credential name is an expression resolve it - // const additionalKeys = getAdditionalKeys(additionalData, mode); - // name = workflow.expression.getParameterValue( - // name, - // runExecutionData || null, - // runIndex || 0, - // itemIndex || 0, - // node.name, - // connectionInputData || [], - // mode, - // additionalKeys, - // ) as string; - // } - - const decryptedDataObject = await additionalData.credentialsHelper.getDecrypted( - additionalData, - nodeCredentials, - type, - mode, - executeData, - false, - expressionResolveValues, - ); - - return decryptedDataObject as T; -} - -/** - * 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]; - 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, - }; - } - - if (schemaEntry?.type) { - const validationResult = validateFieldType(key, resolvedValue, schemaEntry.type, { - 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, - }, - ); - } - 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, - }, - ); - } - return validationResult.newValue; -}; - -export function ensureType( - toType: EnsureTypeOptions, - parameterValue: any, - parameterName: string, - errorOptions?: { itemIndex?: number; runIndex?: number; nodeCause?: string }, -): string | number | boolean | object { - 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 { - const parsedValue = JSON.parse(returnData); - returnData = parsedValue; - } catch (error) { - throw new ExpressionError(`Parameter '${parameterName}' could not be parsed`, { - ...errorOptions, - 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, - 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, - description: error.message, - }); - } - - return returnData; -} - -/** - * Returns the requested resolved (all expressions replaced) node parameters. - * - * @param {(IRunExecutionData | null)} runExecutionData - */ -export function getNodeParameter( - workflow: Workflow, - runExecutionData: IRunExecutionData | null, - runIndex: number, - connectionInputData: INodeExecutionData[], - node: INode, - parameterName: string, - itemIndex: number, - mode: WorkflowExecuteMode, - additionalKeys: IWorkflowDataProxyAdditionalKeys, - executeData?: IExecuteData, - fallbackValue?: any, - options?: IGetNodeParameterOptions, -): NodeParameterValueType | object { - const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); - if (nodeType === undefined) { - throw new ApplicationError('Node type is unknown so cannot return parameter value', { - tags: { nodeType: node.type }, - }); - } - - const value = get(node.parameters, parameterName, fallbackValue); - - if (value === undefined) { - throw new ApplicationError('Could not get parameter', { extra: { parameterName } }); - } - - if (options?.rawExpressions) { - return value; - } - - let returnData; - - try { - returnData = workflow.expression.getParameterValue( - value, - runExecutionData, - runIndex, - itemIndex, - node.name, - connectionInputData, - mode, - additionalKeys, - executeData, - false, - {}, - options?.contextNode?.name, - ); - cleanupParameterData(returnData); - } catch (e) { - if (e instanceof ExpressionError && node.continueOnFail && node.type === 'n8n-nodes-base.set') { - // https://linear.app/n8n/issue/PAY-684 - returnData = [{ name: undefined, value: undefined }]; - } else { - if (e.context) e.context.parameter = parameterName; - e.cause = value; - throw e; - } - } - - // This is outside the try/catch because it throws errors with proper messages - if (options?.extractValue) { - returnData = extractValue(returnData, parameterName, node, nodeType, itemIndex); - } - - // Make sure parameter value is the type specified in the ensureType option, if needed convert it - if (options?.ensureType) { - returnData = ensureType(options.ensureType, returnData, parameterName, { - itemIndex, - runIndex, - nodeCause: node.name, - }); - } - - // Validate parameter value if it has a schema defined(RMC) or validateType defined - returnData = validateValueAgainstSchema( - node, - nodeType, - returnData, - parameterName, - runIndex, - itemIndex, - ); - - return returnData; -} - -/** - * Returns if execution should be continued even if there was an error. - * - */ -export function continueOnFail(node: INode): boolean { - const onError = get(node, 'onError', undefined); - - if (onError === undefined) { - return get(node, 'continueOnFail', false); - } - - return ['continueRegularOutput', 'continueErrorOutput'].includes(onError); -} - /** * Returns the webhook URL of the webhook with the given name - * */ export function getNodeWebhookUrl( name: WebhookType, @@ -2691,7 +2028,6 @@ export function getNodeWebhookUrl( /** * Returns the full webhook description of the webhook with the given name - * */ export function getWebhookDescription( name: WebhookType, @@ -2973,71 +2309,6 @@ export async function getInputConnectionData( : nodes.map((node) => node.response); } -const getCommonWorkflowFunctions = ( - workflow: Workflow, - node: INode, - additionalData: IWorkflowExecuteAdditionalData, -): Omit => ({ - logger: Logger, - getExecutionId: () => additionalData.executionId!, - getNode: () => deepCopy(node), - getWorkflow: () => ({ - id: workflow.id, - name: workflow.name, - active: workflow.active, - }), - getWorkflowStaticData: (type) => workflow.getStaticData(type, node), - getChildNodes: (nodeName: string) => { - const output: NodeTypeAndVersion[] = []; - const nodes = workflow.getChildNodes(nodeName); - - for (const nodeName of nodes) { - const node = workflow.nodes[nodeName]; - output.push({ - name: node.name, - type: node.type, - typeVersion: node.typeVersion, - }); - } - return output; - }, - getParentNodes: (nodeName: string) => { - const output: NodeTypeAndVersion[] = []; - const nodes = workflow.getParentNodes(nodeName); - - for (const nodeName of nodes) { - const node = workflow.nodes[nodeName]; - output.push({ - name: node.name, - type: node.type, - typeVersion: node.typeVersion, - }); - } - return output; - }, - getKnownNodeTypes: () => workflow.nodeTypes.getKnownTypes(), - getRestApiUrl: () => additionalData.restApiUrl, - getInstanceBaseUrl: () => additionalData.instanceBaseUrl, - getInstanceId: () => Container.get(InstanceSettings).instanceId, - getTimezone: () => workflow.timezone, - getCredentialsProperties: (type: string) => - additionalData.credentialsHelper.getCredentialsProperties(type), - prepareOutputData: async (outputData) => [outputData], -}); - -const executionCancellationFunctions = ( - abortSignal?: AbortSignal, -): Pick => ({ - getExecutionCancelSignal: () => abortSignal, - onExecutionCancellation: (handler) => { - const fn = () => { - abortSignal?.removeEventListener('abort', fn); - handler(); - }; - abortSignal?.addEventListener('abort', fn); - }, -}); - export const getRequestHelperFunctions = ( workflow: Workflow, node: INode, @@ -3607,338 +2878,19 @@ export function getExecuteFunctions( closeFunctions: CloseFunction[], abortSignal?: AbortSignal, ): IExecuteFunctions { - return ((workflow, runExecutionData, connectionInputData, inputData, node) => { - return { - ...getCommonWorkflowFunctions(workflow, node, additionalData), - ...executionCancellationFunctions(abortSignal), - getMode: () => mode, - getCredentials: async (type, itemIndex) => - await getCredentials( - workflow, - node, - type, - additionalData, - mode, - executeData, - runExecutionData, - runIndex, - connectionInputData, - itemIndex, - ), - getExecuteData: () => executeData, - setMetadata: (metadata: ITaskMetadata): void => { - executeData.metadata = { - ...(executeData.metadata ?? {}), - ...metadata, - }; - }, - continueOnFail: () => { - return continueOnFail(node); - }, - evaluateExpression(expression: string, itemIndex: number) { - return workflow.expression.resolveSimpleParameterValue( - `=${expression}`, - {}, - runExecutionData, - runIndex, - itemIndex, - node.name, - connectionInputData, - mode, - getAdditionalKeys(additionalData, mode, runExecutionData), - executeData, - ); - }, - async executeWorkflow( - workflowInfo: IExecuteWorkflowInfo, - inputData?: INodeExecutionData[], - parentCallbackManager?: CallbackManager, - options?: { - doNotWaitToFinish?: boolean; - parentExecution?: RelatedExecution; - }, - ): Promise { - return await additionalData - .executeWorkflow(workflowInfo, additionalData, { - ...options, - parentWorkflowId: workflow.id?.toString(), - inputData, - parentWorkflowSettings: workflow.settings, - node, - parentCallbackManager, - }) - .then(async (result) => { - const data = await Container.get(BinaryDataService).duplicateBinaryData( - workflow.id, - additionalData.executionId!, - result.data, - ); - return { ...result, data }; - }); - }, - getContext(type: ContextType): IContextObject { - return NodeHelpers.getContext(runExecutionData, type, node); - }, - - async getInputConnectionData( - inputName: NodeConnectionType, - itemIndex: number, - ): Promise { - return await getInputConnectionData.call( - this, - workflow, - runExecutionData, - runIndex, - connectionInputData, - inputData, - additionalData, - executeData, - mode, - closeFunctions, - inputName, - itemIndex, - abortSignal, - ); - }, - - getNodeInputs(): INodeInputConfiguration[] { - const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); - return NodeHelpers.getNodeInputs(workflow, node, nodeType.description).map((output) => { - if (typeof output === 'string') { - return { - type: output, - }; - } - return output; - }); - }, - getNodeOutputs(): INodeOutputConfiguration[] { - const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); - return NodeHelpers.getNodeOutputs(workflow, node, nodeType.description).map((output) => { - if (typeof output === 'string') { - return { - type: output, - }; - } - return output; - }); - }, - getInputData: (inputIndex = 0, inputName = 'main') => { - if (!inputData.hasOwnProperty(inputName)) { - // Return empty array because else it would throw error when nothing is connected to input - return []; - } - - // TODO: Check if nodeType has input with that index defined - if (inputData[inputName].length < inputIndex) { - throw new ApplicationError('Could not get input with given index', { - extra: { inputIndex, inputName }, - }); - } - - if (inputData[inputName][inputIndex] === null) { - throw new ApplicationError('Value of input was not set', { - extra: { inputIndex, inputName }, - }); - } - - return inputData[inputName][inputIndex]; - }, - getInputSourceData: (inputIndex = 0, inputName = 'main') => { - if (executeData?.source === null) { - // Should never happen as n8n sets it automatically - throw new ApplicationError('Source data is missing'); - } - return executeData.source[inputName][inputIndex]; - }, - getNodeParameter: ( - parameterName: string, - itemIndex: number, - fallbackValue?: any, - options?: IGetNodeParameterOptions, - ): NodeParameterValueType | object => { - return getNodeParameter( - workflow, - runExecutionData, - runIndex, - connectionInputData, - node, - parameterName, - itemIndex, - mode, - getAdditionalKeys(additionalData, mode, runExecutionData), - executeData, - fallbackValue, - options, - ); - }, - getWorkflowDataProxy: (itemIndex: number): IWorkflowDataProxyData => { - const dataProxy = new WorkflowDataProxy( - workflow, - runExecutionData, - runIndex, - itemIndex, - node.name, - connectionInputData, - {}, - mode, - getAdditionalKeys(additionalData, mode, runExecutionData), - executeData, - ); - return dataProxy.getDataProxy(); - }, - async putExecutionToWait(waitTill: Date): Promise { - runExecutionData.waitTill = waitTill; - if (additionalData.setExecutionStatus) { - additionalData.setExecutionStatus('waiting'); - } - }, - logNodeOutput(...args: unknown[]): void { - if (mode === 'manual') { - // @ts-expect-error `args` is spreadable - this.sendMessageToUI(...args); - return; - } - - if (process.env.CODE_ENABLE_STDOUT === 'true') { - console.log(`[Workflow "${this.getWorkflow().id}"][Node "${node.name}"]`, ...args); - } - }, - sendMessageToUI(...args: any[]): void { - if (mode !== 'manual') { - return; - } - try { - if (additionalData.sendDataToUI) { - args = args.map((arg) => { - // prevent invalid dates from being logged as null - if (arg.isLuxonDateTime && arg.invalidReason) return { ...arg }; - - // log valid dates in human readable format, as in browser - if (arg.isLuxonDateTime) return new Date(arg.ts).toString(); - if (arg instanceof Date) return arg.toString(); - - return arg; - }); - - additionalData.sendDataToUI('sendConsoleMessage', { - source: `[Node: "${node.name}"]`, - messages: args, - }); - } - } catch (error) { - Logger.warn(`There was a problem sending message to UI: ${error.message}`); - } - }, - async sendResponse(response: IExecuteResponsePromiseData): Promise { - await additionalData.hooks?.executeHookFunctions('sendResponse', [response]); - }, - - addInputData( - connectionType: NodeConnectionType, - data: INodeExecutionData[][] | ExecutionBaseError, - ): { index: number } { - const nodeName = this.getNode().name; - let currentNodeRunIndex = 0; - if (runExecutionData.resultData.runData.hasOwnProperty(nodeName)) { - currentNodeRunIndex = runExecutionData.resultData.runData[nodeName].length; - } - - addExecutionDataFunctions( - 'input', - this.getNode().name, - data, - runExecutionData, - connectionType, - additionalData, - node.name, - runIndex, - currentNodeRunIndex, - ).catch((error) => { - Logger.warn( - `There was a problem logging input data of node "${this.getNode().name}": ${ - error.message - }`, - ); - }); - - return { index: currentNodeRunIndex }; - }, - addOutputData( - connectionType: NodeConnectionType, - currentNodeRunIndex: number, - data: INodeExecutionData[][] | ExecutionBaseError, - metadata?: ITaskMetadata, - ): void { - addExecutionDataFunctions( - 'output', - this.getNode().name, - data, - runExecutionData, - connectionType, - additionalData, - node.name, - runIndex, - currentNodeRunIndex, - metadata, - ).catch((error) => { - Logger.warn( - `There was a problem logging output data of node "${this.getNode().name}": ${ - error.message - }`, - ); - }); - }, - helpers: { - createDeferredPromise, - copyInputItems, - ...getRequestHelperFunctions( - workflow, - node, - additionalData, - runExecutionData, - connectionInputData, - ), - ...getSSHTunnelFunctions(), - ...getFileSystemHelperFunctions(node), - ...getBinaryHelperFunctions(additionalData, workflow.id), - ...getCheckProcessedHelperFunctions(workflow, node), - assertBinaryData: (itemIndex, propertyName) => - assertBinaryData(inputData, node, itemIndex, propertyName, 0), - getBinaryDataBuffer: async (itemIndex, propertyName) => - await getBinaryDataBuffer(inputData, itemIndex, propertyName, 0), - - returnJsonArray, - normalizeItems, - constructExecutionMetaData, - }, - nodeHelpers: getNodeHelperFunctions(additionalData, workflow.id), - logAiEvent: (eventName: AiEvent, msg: string) => { - return additionalData.logAiEvent(eventName, { - executionId: additionalData.executionId ?? 'unsaved-execution', - nodeName: node.name, - workflowName: workflow.name ?? 'Unnamed workflow', - nodeType: node.type, - workflowId: workflow.id ?? 'unsaved-workflow', - msg, - }); - }, - getParentCallbackManager: () => additionalData.parentCallbackManager, - startJob: createAgentStartJob( - additionalData, - inputData, - node, - workflow, - runExecutionData, - runIndex, - node.name, - connectionInputData, - {}, - mode, - executeData, - ), - }; - })(workflow, runExecutionData, connectionInputData, inputData, node) as IExecuteFunctions; + return new ExecuteContext( + workflow, + node, + additionalData, + mode, + runExecutionData, + runIndex, + connectionInputData, + inputData, + executeData, + closeFunctions, + abortSignal, + ); } /** diff --git a/packages/core/src/node-execution-context/__tests__/execute-context.test.ts b/packages/core/src/node-execution-context/__tests__/execute-context.test.ts new file mode 100644 index 0000000000000..723bab24f3e47 --- /dev/null +++ b/packages/core/src/node-execution-context/__tests__/execute-context.test.ts @@ -0,0 +1,212 @@ +import { mock } from 'jest-mock-extended'; +import type { + INode, + IWorkflowExecuteAdditionalData, + IRunExecutionData, + INodeExecutionData, + ITaskDataConnections, + IExecuteData, + Workflow, + WorkflowExecuteMode, + ICredentialsHelper, + Expression, + INodeType, + INodeTypes, + ICredentialDataDecryptedObject, +} from 'n8n-workflow'; +import { ApplicationError, ExpressionError } from 'n8n-workflow'; + +import { describeCommonTests } from './shared-tests'; +import { ExecuteContext } from '../execute-context'; + +describe('ExecuteContext', () => { + const testCredentialType = 'testCredential'; + const nodeType = mock({ + description: { + credentials: [ + { + name: testCredentialType, + required: true, + }, + ], + properties: [ + { + name: 'testParameter', + required: true, + }, + ], + }, + }); + const nodeTypes = mock(); + const expression = mock(); + const workflow = mock({ expression, nodeTypes }); + const node = mock({ + name: 'Test Node', + credentials: { + [testCredentialType]: { + id: 'testCredentialId', + }, + }, + }); + node.parameters = { + testParameter: 'testValue', + nullParameter: null, + }; + const credentialsHelper = mock(); + const additionalData = mock({ credentialsHelper }); + const mode: WorkflowExecuteMode = 'manual'; + const runExecutionData = mock(); + const connectionInputData: INodeExecutionData[] = []; + const inputData: ITaskDataConnections = { main: [[{ json: { test: 'data' } }]] }; + const executeData = mock(); + const runIndex = 0; + const closeFn = jest.fn(); + const abortSignal = mock(); + + const executeContext = new ExecuteContext( + workflow, + node, + additionalData, + mode, + runExecutionData, + runIndex, + connectionInputData, + inputData, + executeData, + [closeFn], + abortSignal, + ); + + beforeEach(() => { + nodeTypes.getByNameAndVersion.mockReturnValue(nodeType); + expression.getParameterValue.mockImplementation((value) => value); + }); + + describeCommonTests(executeContext, { + abortSignal, + node, + workflow, + executeData, + runExecutionData, + }); + + describe('getInputData', () => { + const inputIndex = 0; + const inputName = 'main'; + + afterEach(() => { + inputData[inputName] = [[{ json: { test: 'data' } }]]; + }); + + it('should return the input data correctly', () => { + const expectedData = [{ json: { test: 'data' } }]; + + expect(executeContext.getInputData(inputIndex, inputName)).toEqual(expectedData); + }); + + it('should return an empty array if the input name does not exist', () => { + const inputName = 'nonExistent'; + expect(executeContext.getInputData(inputIndex, inputName)).toEqual([]); + }); + + it('should throw an error if the input index is out of range', () => { + const inputIndex = 2; + + expect(() => executeContext.getInputData(inputIndex, inputName)).toThrow(ApplicationError); + }); + + it('should throw an error if the input index was not set', () => { + inputData.main[inputIndex] = null; + + expect(() => executeContext.getInputData(inputIndex, inputName)).toThrow(ApplicationError); + }); + }); + + describe('getNodeParameter', () => { + beforeEach(() => { + nodeTypes.getByNameAndVersion.mockReturnValue(nodeType); + expression.getParameterValue.mockImplementation((value) => value); + }); + + it('should throw if parameter is not defined on the node.parameters', () => { + expect(() => executeContext.getNodeParameter('invalidParameter', 0)).toThrow( + 'Could not get parameter', + ); + }); + + it('should return null if the parameter exists but has a null value', () => { + const parameter = executeContext.getNodeParameter('nullParameter', 0); + + expect(parameter).toBeNull(); + }); + + it('should return parameter value when it exists', () => { + const parameter = executeContext.getNodeParameter('testParameter', 0); + + expect(parameter).toBe('testValue'); + }); + + it('should return the fallback value when the parameter does not exist', () => { + const parameter = executeContext.getNodeParameter('otherParameter', 0, 'fallback'); + + expect(parameter).toBe('fallback'); + }); + + it('should handle expression evaluation errors', () => { + const error = new ExpressionError('Invalid expression'); + expression.getParameterValue.mockImplementationOnce(() => { + throw error; + }); + + expect(() => executeContext.getNodeParameter('testParameter', 0)).toThrow(error); + expect(error.context.parameter).toEqual('testParameter'); + }); + + it('should handle expression errors on Set nodes (Ticket #PAY-684)', () => { + node.type = 'n8n-nodes-base.set'; + node.continueOnFail = true; + + expression.getParameterValue.mockImplementationOnce(() => { + throw new ExpressionError('Invalid expression'); + }); + + const parameter = executeContext.getNodeParameter('testParameter', 0); + expect(parameter).toEqual([{ name: undefined, value: undefined }]); + }); + }); + + describe('getCredentials', () => { + it('should get decrypted credentials', async () => { + nodeTypes.getByNameAndVersion.mockReturnValue(nodeType); + credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' }); + + const credentials = await executeContext.getCredentials( + testCredentialType, + 0, + ); + + expect(credentials).toEqual({ secret: 'token' }); + }); + }); + + describe('getExecuteData', () => { + it('should return the execute data correctly', () => { + expect(executeContext.getExecuteData()).toEqual(executeData); + }); + }); + + describe('getWorkflowDataProxy', () => { + it('should return the workflow data proxy correctly', () => { + const workflowDataProxy = executeContext.getWorkflowDataProxy(0); + expect(workflowDataProxy.isProxy).toBe(true); + expect(Object.keys(workflowDataProxy.$input)).toEqual([ + 'all', + 'context', + 'first', + 'item', + 'last', + 'params', + ]); + }); + }); +}); diff --git a/packages/core/src/node-execution-context/__tests__/execute-single-context.test.ts b/packages/core/src/node-execution-context/__tests__/execute-single-context.test.ts index bc868f6c07557..e62c2b0f4632d 100644 --- a/packages/core/src/node-execution-context/__tests__/execute-single-context.test.ts +++ b/packages/core/src/node-execution-context/__tests__/execute-single-context.test.ts @@ -12,15 +12,11 @@ import type { Expression, INodeType, INodeTypes, - OnError, - ContextType, - IContextObject, ICredentialDataDecryptedObject, - ISourceData, - ITaskMetadata, } from 'n8n-workflow'; -import { ApplicationError, NodeHelpers } from 'n8n-workflow'; +import { ApplicationError } from 'n8n-workflow'; +import { describeCommonTests } from './shared-tests'; import { ExecuteSingleContext } from '../execute-single-context'; describe('ExecuteSingleContext', () => { @@ -45,6 +41,7 @@ describe('ExecuteSingleContext', () => { const expression = mock(); const workflow = mock({ expression, nodeTypes }); const node = mock({ + name: 'Test Node', credentials: { [testCredentialType]: { id: 'testCredentialId', @@ -58,7 +55,7 @@ describe('ExecuteSingleContext', () => { const additionalData = mock({ credentialsHelper }); const mode: WorkflowExecuteMode = 'manual'; const runExecutionData = mock(); - const connectionInputData = mock(); + const connectionInputData: INodeExecutionData[] = []; const inputData: ITaskDataConnections = { main: [[{ json: { test: 'data' } }]] }; const executeData = mock(); const runIndex = 0; @@ -84,81 +81,12 @@ describe('ExecuteSingleContext', () => { expression.getParameterValue.mockImplementation((value) => value); }); - describe('getExecutionCancelSignal', () => { - it('should return the abort signal', () => { - expect(executeSingleContext.getExecutionCancelSignal()).toBe(abortSignal); - }); - }); - - describe('continueOnFail', () => { - afterEach(() => { - node.onError = undefined; - node.continueOnFail = false; - }); - - it('should return false for nodes by default', () => { - expect(executeSingleContext.continueOnFail()).toEqual(false); - }); - - it('should return true if node has continueOnFail set to true', () => { - node.continueOnFail = true; - expect(executeSingleContext.continueOnFail()).toEqual(true); - }); - - test.each([ - ['continueRegularOutput', true], - ['continueErrorOutput', true], - ['stopWorkflow', false], - ])('if node has onError set to %s, it should return %s', (onError, expected) => { - node.onError = onError as OnError; - expect(executeSingleContext.continueOnFail()).toEqual(expected); - }); - }); - - describe('evaluateExpression', () => { - it('should evaluate the expression correctly', () => { - const expression = '$json.test'; - const expectedResult = 'data'; - const resolveSimpleParameterValueSpy = jest.spyOn( - workflow.expression, - 'resolveSimpleParameterValue', - ); - resolveSimpleParameterValueSpy.mockReturnValue(expectedResult); - - expect(executeSingleContext.evaluateExpression(expression, itemIndex)).toEqual( - expectedResult, - ); - - expect(resolveSimpleParameterValueSpy).toHaveBeenCalledWith( - `=${expression}`, - {}, - runExecutionData, - runIndex, - itemIndex, - node.name, - connectionInputData, - mode, - expect.objectContaining({}), - executeData, - ); - - resolveSimpleParameterValueSpy.mockRestore(); - }); - }); - - describe('getContext', () => { - it('should return the context object', () => { - const contextType: ContextType = 'node'; - const expectedContext = mock(); - const getContextSpy = jest.spyOn(NodeHelpers, 'getContext'); - getContextSpy.mockReturnValue(expectedContext); - - expect(executeSingleContext.getContext(contextType)).toEqual(expectedContext); - - expect(getContextSpy).toHaveBeenCalledWith(runExecutionData, contextType, node); - - getContextSpy.mockRestore(); - }); + describeCommonTests(executeSingleContext, { + abortSignal, + node, + workflow, + executeData, + runExecutionData, }); describe('getInputData', () => { @@ -266,54 +194,4 @@ describe('ExecuteSingleContext', () => { ]); }); }); - - describe('getInputSourceData', () => { - it('should return the input source data correctly', () => { - const inputSourceData = mock(); - executeData.source = { main: [inputSourceData] }; - - expect(executeSingleContext.getInputSourceData()).toEqual(inputSourceData); - }); - - it('should throw an error if the source data is missing', () => { - executeData.source = null; - - expect(() => executeSingleContext.getInputSourceData()).toThrow(ApplicationError); - }); - }); - - describe('logAiEvent', () => { - it('should log the AI event correctly', () => { - const eventName = 'ai-tool-called'; - const msg = 'test message'; - - executeSingleContext.logAiEvent(eventName, msg); - - expect(additionalData.logAiEvent).toHaveBeenCalledWith(eventName, { - executionId: additionalData.executionId, - nodeName: node.name, - workflowName: workflow.name, - nodeType: node.type, - workflowId: workflow.id, - msg, - }); - }); - }); - - describe('setMetadata', () => { - it('sets metadata on execution data', () => { - const metadata: ITaskMetadata = { - subExecution: { - workflowId: '123', - executionId: 'xyz', - }, - }; - - expect(executeSingleContext.getExecuteData().metadata?.subExecution).toEqual(undefined); - executeSingleContext.setMetadata(metadata); - expect(executeSingleContext.getExecuteData().metadata?.subExecution).toEqual( - metadata.subExecution, - ); - }); - }); }); diff --git a/packages/core/src/node-execution-context/__tests__/base-context.test.ts b/packages/core/src/node-execution-context/__tests__/node-execution-context.test.ts similarity index 99% rename from packages/core/src/node-execution-context/__tests__/base-context.test.ts rename to packages/core/src/node-execution-context/__tests__/node-execution-context.test.ts index aadc630a38a79..95ed62337e559 100644 --- a/packages/core/src/node-execution-context/__tests__/base-context.test.ts +++ b/packages/core/src/node-execution-context/__tests__/node-execution-context.test.ts @@ -14,7 +14,7 @@ import { NodeExecutionContext } from '../node-execution-context'; class TestContext extends NodeExecutionContext {} -describe('BaseContext', () => { +describe('NodeExecutionContext', () => { const instanceSettings = mock({ instanceId: 'abc123' }); Container.set(InstanceSettings, instanceSettings); diff --git a/packages/core/src/node-execution-context/__tests__/shared-tests.ts b/packages/core/src/node-execution-context/__tests__/shared-tests.ts new file mode 100644 index 0000000000000..c7262554d0c97 --- /dev/null +++ b/packages/core/src/node-execution-context/__tests__/shared-tests.ts @@ -0,0 +1,181 @@ +import { captor, mock } from 'jest-mock-extended'; +import type { + IRunExecutionData, + ContextType, + IContextObject, + INode, + OnError, + Workflow, + ITaskMetadata, + ISourceData, + IExecuteData, +} from 'n8n-workflow'; +import { ApplicationError, NodeHelpers } from 'n8n-workflow'; + +import type { BaseExecuteContext } from '../base-execute-context'; + +export const describeCommonTests = ( + context: BaseExecuteContext, + { + abortSignal, + node, + workflow, + runExecutionData, + executeData, + }: { + abortSignal: AbortSignal; + node: INode; + workflow: Workflow; + runExecutionData: IRunExecutionData; + executeData: IExecuteData; + }, +) => { + // @ts-expect-error `additionalData` is private + const { additionalData } = context; + + describe('getExecutionCancelSignal', () => { + it('should return the abort signal', () => { + expect(context.getExecutionCancelSignal()).toBe(abortSignal); + }); + }); + + describe('onExecutionCancellation', () => { + const handler = jest.fn(); + context.onExecutionCancellation(handler); + + const fnCaptor = captor<() => void>(); + expect(abortSignal.addEventListener).toHaveBeenCalledWith('abort', fnCaptor); + expect(handler).not.toHaveBeenCalled(); + + fnCaptor.value(); + expect(abortSignal.removeEventListener).toHaveBeenCalledWith('abort', fnCaptor); + expect(handler).toHaveBeenCalled(); + }); + + describe('continueOnFail', () => { + afterEach(() => { + node.onError = undefined; + node.continueOnFail = false; + }); + + it('should return false for nodes by default', () => { + expect(context.continueOnFail()).toEqual(false); + }); + + it('should return true if node has continueOnFail set to true', () => { + node.continueOnFail = true; + expect(context.continueOnFail()).toEqual(true); + }); + + test.each([ + ['continueRegularOutput', true], + ['continueErrorOutput', true], + ['stopWorkflow', false], + ])('if node has onError set to %s, it should return %s', (onError, expected) => { + node.onError = onError as OnError; + expect(context.continueOnFail()).toEqual(expected); + }); + }); + + describe('getContext', () => { + it('should return the context object', () => { + const contextType: ContextType = 'node'; + const expectedContext = mock(); + const getContextSpy = jest.spyOn(NodeHelpers, 'getContext'); + getContextSpy.mockReturnValue(expectedContext); + + expect(context.getContext(contextType)).toEqual(expectedContext); + + expect(getContextSpy).toHaveBeenCalledWith(runExecutionData, contextType, node); + + getContextSpy.mockRestore(); + }); + }); + + describe('sendMessageToUI', () => { + it('should send console messages to the frontend', () => { + context.sendMessageToUI('Testing', 1, 2, {}); + expect(additionalData.sendDataToUI).toHaveBeenCalledWith('sendConsoleMessage', { + source: '[Node: "Test Node"]', + messages: ['Testing', 1, 2, {}], + }); + }); + }); + + describe('logAiEvent', () => { + it('should log the AI event correctly', () => { + const eventName = 'ai-tool-called'; + const msg = 'test message'; + + context.logAiEvent(eventName, msg); + + expect(additionalData.logAiEvent).toHaveBeenCalledWith(eventName, { + executionId: additionalData.executionId, + nodeName: node.name, + workflowName: workflow.name, + nodeType: node.type, + workflowId: workflow.id, + msg, + }); + }); + }); + + describe('getInputSourceData', () => { + it('should return the input source data correctly', () => { + const inputSourceData = mock(); + executeData.source = { main: [inputSourceData] }; + + expect(context.getInputSourceData()).toEqual(inputSourceData); + }); + + it('should throw an error if the source data is missing', () => { + executeData.source = null; + + expect(() => context.getInputSourceData()).toThrow(ApplicationError); + }); + }); + + describe('setMetadata', () => { + it('sets metadata on execution data', () => { + const metadata: ITaskMetadata = { + subExecution: { + workflowId: '123', + executionId: 'xyz', + }, + }; + + expect(context.getExecuteData().metadata?.subExecution).toEqual(undefined); + context.setMetadata(metadata); + expect(context.getExecuteData().metadata?.subExecution).toEqual(metadata.subExecution); + }); + }); + + describe('evaluateExpression', () => { + it('should evaluate the expression correctly', () => { + const expression = '$json.test'; + const expectedResult = 'data'; + const resolveSimpleParameterValueSpy = jest.spyOn( + workflow.expression, + 'resolveSimpleParameterValue', + ); + resolveSimpleParameterValueSpy.mockReturnValue(expectedResult); + + expect(context.evaluateExpression(expression, 0)).toEqual(expectedResult); + + expect(resolveSimpleParameterValueSpy).toHaveBeenCalledWith( + `=${expression}`, + {}, + runExecutionData, + 0, + 0, + node.name, + [], + 'manual', + expect.objectContaining({}), + executeData, + ); + + resolveSimpleParameterValueSpy.mockRestore(); + }); + }); +}; diff --git a/packages/core/src/node-execution-context/__tests__/supply-data-context.test.ts b/packages/core/src/node-execution-context/__tests__/supply-data-context.test.ts index a60517e5ddcb3..6c5a3849dda22 100644 --- a/packages/core/src/node-execution-context/__tests__/supply-data-context.test.ts +++ b/packages/core/src/node-execution-context/__tests__/supply-data-context.test.ts @@ -12,11 +12,11 @@ import type { Expression, INodeType, INodeTypes, - OnError, ICredentialDataDecryptedObject, } from 'n8n-workflow'; import { ApplicationError } from 'n8n-workflow'; +import { describeCommonTests } from './shared-tests'; import { SupplyDataContext } from '../supply-data-context'; describe('SupplyDataContext', () => { @@ -41,6 +41,7 @@ describe('SupplyDataContext', () => { const expression = mock(); const workflow = mock({ expression, nodeTypes }); const node = mock({ + name: 'Test Node', credentials: { [testCredentialType]: { id: 'testCredentialId', @@ -54,7 +55,7 @@ describe('SupplyDataContext', () => { const additionalData = mock({ credentialsHelper }); const mode: WorkflowExecuteMode = 'manual'; const runExecutionData = mock(); - const connectionInputData = mock(); + const connectionInputData: INodeExecutionData[] = []; const inputData: ITaskDataConnections = { main: [[{ json: { test: 'data' } }]] }; const executeData = mock(); const runIndex = 0; @@ -80,64 +81,12 @@ describe('SupplyDataContext', () => { expression.getParameterValue.mockImplementation((value) => value); }); - describe('getExecutionCancelSignal', () => { - it('should return the abort signal', () => { - expect(supplyDataContext.getExecutionCancelSignal()).toBe(abortSignal); - }); - }); - - describe('continueOnFail', () => { - afterEach(() => { - node.onError = undefined; - node.continueOnFail = false; - }); - - it('should return false for nodes by default', () => { - expect(supplyDataContext.continueOnFail()).toEqual(false); - }); - - it('should return true if node has continueOnFail set to true', () => { - node.continueOnFail = true; - expect(supplyDataContext.continueOnFail()).toEqual(true); - }); - - test.each([ - ['continueRegularOutput', true], - ['continueErrorOutput', true], - ['stopWorkflow', false], - ])('if node has onError set to %s, it should return %s', (onError, expected) => { - node.onError = onError as OnError; - expect(supplyDataContext.continueOnFail()).toEqual(expected); - }); - }); - - describe('evaluateExpression', () => { - it('should evaluate the expression correctly', () => { - const expression = '$json.test'; - const expectedResult = 'data'; - const resolveSimpleParameterValueSpy = jest.spyOn( - workflow.expression, - 'resolveSimpleParameterValue', - ); - resolveSimpleParameterValueSpy.mockReturnValue(expectedResult); - - expect(supplyDataContext.evaluateExpression(expression, 0)).toEqual(expectedResult); - - expect(resolveSimpleParameterValueSpy).toHaveBeenCalledWith( - `=${expression}`, - {}, - runExecutionData, - runIndex, - 0, - node.name, - connectionInputData, - mode, - expect.objectContaining({}), - executeData, - ); - - resolveSimpleParameterValueSpy.mockRestore(); - }); + describeCommonTests(supplyDataContext, { + abortSignal, + node, + workflow, + executeData, + runExecutionData, }); describe('getInputData', () => { @@ -219,22 +168,4 @@ describe('SupplyDataContext', () => { ]); }); }); - - describe('logAiEvent', () => { - it('should log the AI event correctly', () => { - const eventName = 'ai-tool-called'; - const msg = 'test message'; - - supplyDataContext.logAiEvent(eventName, msg); - - expect(additionalData.logAiEvent).toHaveBeenCalledWith(eventName, { - executionId: additionalData.executionId, - nodeName: node.name, - workflowName: workflow.name, - nodeType: node.type, - workflowId: workflow.id, - msg, - }); - }); - }); }); diff --git a/packages/core/test/Validation.test.ts b/packages/core/src/node-execution-context/__tests__/utils.test.ts similarity index 59% rename from packages/core/test/Validation.test.ts rename to packages/core/src/node-execution-context/__tests__/utils.test.ts index 04ad3c134e1b2..1871af4c0db66 100644 --- a/packages/core/test/Validation.test.ts +++ b/packages/core/src/node-execution-context/__tests__/utils.test.ts @@ -1,8 +1,121 @@ -import type { IDataObject, INode, INodeType } from 'n8n-workflow'; +import toPlainObject from 'lodash/toPlainObject'; +import { DateTime } from 'luxon'; +import type { IDataObject, INode, INodeType, NodeParameterValue } from 'n8n-workflow'; +import { ExpressionError } from 'n8n-workflow'; -import { validateValueAgainstSchema } from '@/NodeExecuteFunctions'; +import { cleanupParameterData, ensureType, validateValueAgainstSchema } from '../utils'; -describe('Validation', () => { +describe('cleanupParameterData', () => { + it('should stringify Luxon dates in-place', () => { + const input = { x: 1, y: DateTime.now() as unknown as NodeParameterValue }; + expect(typeof input.y).toBe('object'); + cleanupParameterData(input); + expect(typeof input.y).toBe('string'); + }); + + it('should stringify plain Luxon dates in-place', () => { + const input = { + x: 1, + y: toPlainObject(DateTime.now()), + }; + expect(typeof input.y).toBe('object'); + cleanupParameterData(input); + expect(typeof input.y).toBe('string'); + }); + + it('should handle objects with nameless constructors', () => { + const input = { x: 1, y: { constructor: {} } as NodeParameterValue }; + expect(typeof input.y).toBe('object'); + cleanupParameterData(input); + expect(typeof input.y).toBe('object'); + }); + + it('should handle objects without a constructor', () => { + const input = { x: 1, y: { constructor: undefined } as unknown as NodeParameterValue }; + expect(typeof input.y).toBe('object'); + cleanupParameterData(input); + expect(typeof input.y).toBe('object'); + }); +}); + +describe('ensureType', () => { + it('throws error for null value', () => { + expect(() => ensureType('string', null, 'myParam')).toThrowError( + new ExpressionError("Parameter 'myParam' must not be null"), + ); + }); + + it('throws error for undefined value', () => { + expect(() => ensureType('string', undefined, 'myParam')).toThrowError( + new ExpressionError("Parameter 'myParam' could not be 'undefined'"), + ); + }); + + it('returns string value without modification', () => { + const value = 'hello'; + const expectedValue = value; + const result = ensureType('string', value, 'myParam'); + expect(result).toBe(expectedValue); + }); + + it('returns number value without modification', () => { + const value = 42; + const expectedValue = value; + const result = ensureType('number', value, 'myParam'); + expect(result).toBe(expectedValue); + }); + + it('returns boolean value without modification', () => { + const value = true; + const expectedValue = value; + const result = ensureType('boolean', value, 'myParam'); + expect(result).toBe(expectedValue); + }); + + it('converts object to string if toType is string', () => { + const value = { name: 'John' }; + const expectedValue = JSON.stringify(value); + const result = ensureType('string', value, 'myParam'); + expect(result).toBe(expectedValue); + }); + + it('converts string to number if toType is number', () => { + const value = '10'; + const expectedValue = 10; + const result = ensureType('number', value, 'myParam'); + expect(result).toBe(expectedValue); + }); + + it('throws error for invalid conversion to number', () => { + const value = 'invalid'; + expect(() => ensureType('number', value, 'myParam')).toThrowError( + new ExpressionError("Parameter 'myParam' must be a number, but we got 'invalid'"), + ); + }); + + it('parses valid JSON string to object if toType is object', () => { + const value = '{"name": "Alice"}'; + const expectedValue = JSON.parse(value); + const result = ensureType('object', value, 'myParam'); + expect(result).toEqual(expectedValue); + }); + + it('throws error for invalid JSON string to object conversion', () => { + const value = 'invalid_json'; + expect(() => ensureType('object', value, 'myParam')).toThrowError( + new ExpressionError("Parameter 'myParam' could not be parsed"), + ); + }); + + it('throws error for non-array value if toType is array', () => { + const value = { name: 'Alice' }; + expect(() => ensureType('array', value, 'myParam')).toThrowError( + new ExpressionError("Parameter 'myParam' must be an array, but we got object"), + ); + }); +}); + +describe('validateValueAgainstSchema', () => { test('should validate fixedCollection values parameter', () => { const nodeType = { description: { diff --git a/packages/core/src/node-execution-context/base-execute-context.ts b/packages/core/src/node-execution-context/base-execute-context.ts new file mode 100644 index 0000000000000..c13ba66dc2f92 --- /dev/null +++ b/packages/core/src/node-execution-context/base-execute-context.ts @@ -0,0 +1,213 @@ +import { get } from 'lodash'; +import type { + Workflow, + INode, + IWorkflowExecuteAdditionalData, + WorkflowExecuteMode, + IRunExecutionData, + INodeExecutionData, + ITaskDataConnections, + IExecuteData, + ICredentialDataDecryptedObject, + CallbackManager, + IExecuteWorkflowInfo, + RelatedExecution, + ExecuteWorkflowData, + ITaskMetadata, + ContextType, + IContextObject, + INodeInputConfiguration, + INodeOutputConfiguration, + IWorkflowDataProxyData, + ISourceData, + AiEvent, +} from 'n8n-workflow'; +import { ApplicationError, NodeHelpers, WorkflowDataProxy } from 'n8n-workflow'; +import { Container } from 'typedi'; + +import { BinaryDataService } from '@/BinaryData/BinaryData.service'; + +import { NodeExecutionContext } from './node-execution-context'; + +export class BaseExecuteContext extends NodeExecutionContext { + protected readonly binaryDataService = Container.get(BinaryDataService); + + constructor( + workflow: Workflow, + node: INode, + additionalData: IWorkflowExecuteAdditionalData, + mode: WorkflowExecuteMode, + protected readonly runExecutionData: IRunExecutionData, + runIndex: number, + protected readonly connectionInputData: INodeExecutionData[], + protected readonly inputData: ITaskDataConnections, + protected readonly executeData: IExecuteData, + protected readonly abortSignal?: AbortSignal, + ) { + super(workflow, node, additionalData, mode, runExecutionData, runIndex); + } + + getExecutionCancelSignal() { + return this.abortSignal; + } + + onExecutionCancellation(handler: () => unknown) { + const fn = () => { + this.abortSignal?.removeEventListener('abort', fn); + handler(); + }; + this.abortSignal?.addEventListener('abort', fn); + } + + getExecuteData() { + return this.executeData; + } + + setMetadata(metadata: ITaskMetadata): void { + this.executeData.metadata = { + ...(this.executeData.metadata ?? {}), + ...metadata, + }; + } + + getContext(type: ContextType): IContextObject { + return NodeHelpers.getContext(this.runExecutionData, type, this.node); + } + + /** Returns if execution should be continued even if there was an error */ + continueOnFail(): boolean { + const onError = get(this.node, 'onError', undefined); + + if (onError === undefined) { + return get(this.node, 'continueOnFail', false); + } + + return ['continueRegularOutput', 'continueErrorOutput'].includes(onError); + } + + async getCredentials( + type: string, + itemIndex: number, + ) { + return await this._getCredentials( + type, + this.executeData, + this.connectionInputData, + itemIndex, + ); + } + + async executeWorkflow( + workflowInfo: IExecuteWorkflowInfo, + inputData?: INodeExecutionData[], + parentCallbackManager?: CallbackManager, + options?: { + doNotWaitToFinish?: boolean; + parentExecution?: RelatedExecution; + }, + ): Promise { + return await this.additionalData + .executeWorkflow(workflowInfo, this.additionalData, { + ...options, + parentWorkflowId: this.workflow.id?.toString(), + inputData, + parentWorkflowSettings: this.workflow.settings, + node: this.node, + parentCallbackManager, + }) + .then(async (result) => { + const data = await this.binaryDataService.duplicateBinaryData( + this.workflow.id, + this.additionalData.executionId!, + result.data, + ); + return { ...result, data }; + }); + } + + 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, inputName = 'main'): ISourceData { + if (this.executeData?.source === null) { + // Should never happen as n8n sets it automatically + throw new ApplicationError('Source data is missing'); + } + return this.executeData.source[inputName][inputIndex]!; + } + + getWorkflowDataProxy(itemIndex: number): IWorkflowDataProxyData { + return new WorkflowDataProxy( + this.workflow, + this.runExecutionData, + this.runIndex, + itemIndex, + this.node.name, + this.connectionInputData, + {}, + this.mode, + this.additionalKeys, + this.executeData, + ).getDataProxy(); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sendMessageToUI(...args: any[]): void { + if (this.mode !== 'manual') { + return; + } + try { + if (this.additionalData.sendDataToUI) { + args = args.map((arg) => { + // prevent invalid dates from being logged as null + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return + if (arg.isLuxonDateTime && arg.invalidReason) return { ...arg }; + + // log valid dates in human readable format, as in browser + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument + if (arg.isLuxonDateTime) return new Date(arg.ts).toString(); + if (arg instanceof Date) return arg.toString(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return arg; + }); + + this.additionalData.sendDataToUI('sendConsoleMessage', { + source: `[Node: "${this.node.name}"]`, + messages: args, + }); + } + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + this.logger.warn(`There was a problem sending message to UI: ${error.message}`); + } + } + + logAiEvent(eventName: AiEvent, msg: string) { + return this.additionalData.logAiEvent(eventName, { + executionId: this.additionalData.executionId ?? 'unsaved-execution', + nodeName: this.node.name, + workflowName: this.workflow.name ?? 'Unnamed workflow', + nodeType: this.node.type, + workflowId: this.workflow.id ?? 'unsaved-workflow', + msg, + }); + } +} diff --git a/packages/core/src/node-execution-context/execute-context.ts b/packages/core/src/node-execution-context/execute-context.ts new file mode 100644 index 0000000000000..514c9cf27f7e5 --- /dev/null +++ b/packages/core/src/node-execution-context/execute-context.ts @@ -0,0 +1,263 @@ +import type { + CallbackManager, + CloseFunction, + ExecutionBaseError, + IExecuteData, + IExecuteFunctions, + IExecuteResponsePromiseData, + IGetNodeParameterOptions, + INode, + INodeExecutionData, + IRunExecutionData, + ITaskDataConnections, + ITaskMetadata, + IWorkflowExecuteAdditionalData, + NodeConnectionType, + Workflow, + WorkflowExecuteMode, +} from 'n8n-workflow'; +import { ApplicationError, createDeferredPromise } from 'n8n-workflow'; + +import { createAgentStartJob } from '@/Agent'; +// eslint-disable-next-line import/no-cycle +import { + returnJsonArray, + copyInputItems, + normalizeItems, + constructExecutionMetaData, + getInputConnectionData, + addExecutionDataFunctions, + assertBinaryData, + getBinaryDataBuffer, + copyBinaryFile, + getRequestHelperFunctions, + getBinaryHelperFunctions, + getSSHTunnelFunctions, + getFileSystemHelperFunctions, + getCheckProcessedHelperFunctions, +} from '@/NodeExecuteFunctions'; + +import { BaseExecuteContext } from './base-execute-context'; + +export class ExecuteContext extends BaseExecuteContext implements IExecuteFunctions { + readonly helpers: IExecuteFunctions['helpers']; + + readonly nodeHelpers: IExecuteFunctions['nodeHelpers']; + + readonly getNodeParameter: IExecuteFunctions['getNodeParameter']; + + readonly startJob: IExecuteFunctions['startJob']; + + constructor( + workflow: Workflow, + node: INode, + additionalData: IWorkflowExecuteAdditionalData, + mode: WorkflowExecuteMode, + runExecutionData: IRunExecutionData, + runIndex: number, + connectionInputData: INodeExecutionData[], + inputData: ITaskDataConnections, + executeData: IExecuteData, + private readonly closeFunctions: CloseFunction[], + abortSignal?: AbortSignal, + ) { + super( + workflow, + node, + additionalData, + mode, + runExecutionData, + runIndex, + connectionInputData, + inputData, + executeData, + abortSignal, + ); + + this.helpers = { + createDeferredPromise, + returnJsonArray, + copyInputItems, + normalizeItems, + constructExecutionMetaData, + ...getRequestHelperFunctions( + workflow, + node, + additionalData, + runExecutionData, + connectionInputData, + ), + ...getBinaryHelperFunctions(additionalData, workflow.id), + ...getSSHTunnelFunctions(), + ...getFileSystemHelperFunctions(node), + ...getCheckProcessedHelperFunctions(workflow, node), + + assertBinaryData: (itemIndex, propertyName) => + assertBinaryData(inputData, node, itemIndex, propertyName, 0), + getBinaryDataBuffer: async (itemIndex, propertyName) => + await getBinaryDataBuffer(inputData, itemIndex, propertyName, 0), + }; + + this.nodeHelpers = { + copyBinaryFile: async (filePath, fileName, mimeType) => + await copyBinaryFile( + this.workflow.id, + this.additionalData.executionId!, + filePath, + fileName, + mimeType, + ), + }; + + this.getNodeParameter = (( + parameterName: string, + itemIndex: number, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fallbackValue?: any, + options?: IGetNodeParameterOptions, + ) => + this._getNodeParameter( + parameterName, + itemIndex, + fallbackValue, + options, + )) as IExecuteFunctions['getNodeParameter']; + + this.startJob = createAgentStartJob( + this.additionalData, + this.inputData, + this.node, + this.workflow, + this.runExecutionData, + this.runIndex, + this.node.name, + this.connectionInputData, + {}, + this.mode, + this.executeData, + ); + } + + async getInputConnectionData(inputName: NodeConnectionType, itemIndex: number): Promise { + return await getInputConnectionData.call( + this, + this.workflow, + this.runExecutionData, + this.runIndex, + this.connectionInputData, + this.inputData, + this.additionalData, + this.executeData, + this.mode, + this.closeFunctions, + inputName, + itemIndex, + this.abortSignal, + ); + } + + getInputData(inputIndex = 0, inputName = 'main') { + if (!this.inputData.hasOwnProperty(inputName)) { + // Return empty array because else it would throw error when nothing is connected to input + return []; + } + + const inputData = this.inputData[inputName]; + // TODO: Check if nodeType has input with that index defined + if (inputData.length < inputIndex) { + throw new ApplicationError('Could not get input with given index', { + extra: { inputIndex, inputName }, + }); + } + + if (inputData[inputIndex] === null) { + throw new ApplicationError('Value of input was not set', { + extra: { inputIndex, inputName }, + }); + } + + return inputData[inputIndex]; + } + + async putExecutionToWait(waitTill: Date): Promise { + this.runExecutionData.waitTill = waitTill; + if (this.additionalData.setExecutionStatus) { + this.additionalData.setExecutionStatus('waiting'); + } + } + + logNodeOutput(...args: unknown[]): void { + if (this.mode === 'manual') { + this.sendMessageToUI(...args); + return; + } + + if (process.env.CODE_ENABLE_STDOUT === 'true') { + console.log(`[Workflow "${this.getWorkflow().id}"][Node "${this.node.name}"]`, ...args); + } + } + + async sendResponse(response: IExecuteResponsePromiseData): Promise { + await this.additionalData.hooks?.executeHookFunctions('sendResponse', [response]); + } + + addInputData( + connectionType: NodeConnectionType, + data: INodeExecutionData[][] | ExecutionBaseError, + ): { index: number } { + const nodeName = this.node.name; + let currentNodeRunIndex = 0; + if (this.runExecutionData.resultData.runData.hasOwnProperty(nodeName)) { + currentNodeRunIndex = this.runExecutionData.resultData.runData[nodeName].length; + } + + void addExecutionDataFunctions( + 'input', + nodeName, + data, + this.runExecutionData, + connectionType, + this.additionalData, + nodeName, + this.runIndex, + currentNodeRunIndex, + ).catch((error) => { + this.logger.warn( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + `There was a problem logging input data of node "${nodeName}": ${error.message}`, + ); + }); + + return { index: currentNodeRunIndex }; + } + + addOutputData( + connectionType: NodeConnectionType, + currentNodeRunIndex: number, + data: INodeExecutionData[][] | ExecutionBaseError, + metadata?: ITaskMetadata, + ): void { + const nodeName = this.node.name; + addExecutionDataFunctions( + 'output', + nodeName, + data, + this.runExecutionData, + connectionType, + this.additionalData, + nodeName, + this.runIndex, + currentNodeRunIndex, + metadata, + ).catch((error) => { + this.logger.warn( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + `There was a problem logging output data of node "${nodeName}": ${error.message}`, + ); + }); + } + + getParentCallbackManager(): CallbackManager | undefined { + return this.additionalData.parentCallbackManager; + } +} diff --git a/packages/core/src/node-execution-context/execute-single-context.ts b/packages/core/src/node-execution-context/execute-single-context.ts index dd08aa0fc66b1..91c7fcf683030 100644 --- a/packages/core/src/node-execution-context/execute-single-context.ts +++ b/packages/core/src/node-execution-context/execute-single-context.ts @@ -10,34 +10,21 @@ import type { WorkflowExecuteMode, ITaskDataConnections, IExecuteData, - ContextType, - AiEvent, - ISourceData, - ITaskMetadata, -} from 'n8n-workflow'; -import { - ApplicationError, - createDeferredPromise, - NodeHelpers, - WorkflowDataProxy, } from 'n8n-workflow'; +import { ApplicationError, createDeferredPromise } from 'n8n-workflow'; // eslint-disable-next-line import/no-cycle import { assertBinaryData, - continueOnFail, - getAdditionalKeys, getBinaryDataBuffer, getBinaryHelperFunctions, - getCredentials, - getNodeParameter, getRequestHelperFunctions, returnJsonArray, } from '@/NodeExecuteFunctions'; -import { NodeExecutionContext } from './node-execution-context'; +import { BaseExecuteContext } from './base-execute-context'; -export class ExecuteSingleContext extends NodeExecutionContext implements IExecuteSingleFunctions { +export class ExecuteSingleContext extends BaseExecuteContext implements IExecuteSingleFunctions { readonly helpers: IExecuteSingleFunctions['helpers']; constructor( @@ -45,15 +32,26 @@ export class ExecuteSingleContext extends NodeExecutionContext implements IExecu node: INode, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode, - private readonly runExecutionData: IRunExecutionData, - private readonly runIndex: number, - private readonly connectionInputData: INodeExecutionData[], - private readonly inputData: ITaskDataConnections, + runExecutionData: IRunExecutionData, + runIndex: number, + connectionInputData: INodeExecutionData[], + inputData: ITaskDataConnections, private readonly itemIndex: number, - private readonly executeData: IExecuteData, - private readonly abortSignal?: AbortSignal, + executeData: IExecuteData, + abortSignal?: AbortSignal, ) { - super(workflow, node, additionalData, mode); + super( + workflow, + node, + additionalData, + mode, + runExecutionData, + runIndex, + connectionInputData, + inputData, + executeData, + abortSignal, + ); this.helpers = { createDeferredPromise, @@ -74,47 +72,8 @@ export class ExecuteSingleContext extends NodeExecutionContext implements IExecu }; } - getExecutionCancelSignal() { - return this.abortSignal; - } - - onExecutionCancellation(handler: () => unknown) { - const fn = () => { - this.abortSignal?.removeEventListener('abort', fn); - handler(); - }; - this.abortSignal?.addEventListener('abort', fn); - } - - setMetadata(metadata: ITaskMetadata): void { - this.executeData.metadata = { - ...(this.executeData.metadata ?? {}), - ...metadata, - }; - } - - continueOnFail() { - return continueOnFail(this.node); - } - - evaluateExpression(expression: string, evaluateItemIndex: number | undefined) { - evaluateItemIndex = evaluateItemIndex ?? this.itemIndex; - return this.workflow.expression.resolveSimpleParameterValue( - `=${expression}`, - {}, - this.runExecutionData, - this.runIndex, - evaluateItemIndex, - this.node.name, - this.connectionInputData, - this.mode, - getAdditionalKeys(this.additionalData, this.mode, this.runExecutionData), - this.executeData, - ); - } - - getContext(type: ContextType) { - return NodeHelpers.getContext(this.runExecutionData, type, this.node); + evaluateExpression(expression: string, itemIndex: number = this.itemIndex) { + return super.evaluateExpression(expression, itemIndex); } getInputData(inputIndex = 0, inputName = 'main') { @@ -154,73 +113,14 @@ export class ExecuteSingleContext extends NodeExecutionContext implements IExecu // eslint-disable-next-line @typescript-eslint/no-explicit-any getNodeParameter(parameterName: string, fallbackValue?: any, options?: IGetNodeParameterOptions) { - return getNodeParameter( - this.workflow, - this.runExecutionData, - this.runIndex, - this.connectionInputData, - this.node, - parameterName, - this.itemIndex, - this.mode, - getAdditionalKeys(this.additionalData, this.mode, this.runExecutionData), - this.executeData, - fallbackValue, - options, - ); + return this._getNodeParameter(parameterName, this.itemIndex, fallbackValue, options); } - // TODO: extract out in a BaseExecutionContext async getCredentials(type: string) { - return await getCredentials( - this.workflow, - this.node, - type, - this.additionalData, - this.mode, - this.executeData, - this.runExecutionData, - this.runIndex, - this.connectionInputData, - this.itemIndex, - ); - } - - getExecuteData() { - return this.executeData; + return await super.getCredentials(type, this.itemIndex); } getWorkflowDataProxy() { - return new WorkflowDataProxy( - this.workflow, - this.runExecutionData, - this.runIndex, - this.itemIndex, - this.node.name, - this.connectionInputData, - {}, - this.mode, - getAdditionalKeys(this.additionalData, this.mode, this.runExecutionData), - this.executeData, - ).getDataProxy(); - } - - getInputSourceData(inputIndex = 0, inputName = 'main'): ISourceData { - if (this.executeData?.source === null) { - // Should never happen as n8n sets it automatically - throw new ApplicationError('Source data is missing'); - } - return this.executeData.source[inputName][inputIndex] as ISourceData; - } - - logAiEvent(eventName: AiEvent, msg: string) { - return this.additionalData.logAiEvent(eventName, { - executionId: this.additionalData.executionId ?? 'unsaved-execution', - nodeName: this.node.name, - workflowName: this.workflow.name ?? 'Unnamed workflow', - nodeType: this.node.type, - workflowId: this.workflow.id ?? 'unsaved-workflow', - msg, - }); + return super.getWorkflowDataProxy(this.itemIndex); } } diff --git a/packages/core/src/node-execution-context/helpers/binary-helpers.ts b/packages/core/src/node-execution-context/helpers/binary-helpers.ts new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/core/src/node-execution-context/hook-context.ts b/packages/core/src/node-execution-context/hook-context.ts index 5585d6b8f3644..102be563a1afa 100644 --- a/packages/core/src/node-execution-context/hook-context.ts +++ b/packages/core/src/node-execution-context/hook-context.ts @@ -1,12 +1,8 @@ import type { ICredentialDataDecryptedObject, - IGetNodeParameterOptions, INode, - INodeExecutionData, IHookFunctions, - IRunExecutionData, IWorkflowExecuteAdditionalData, - NodeParameterValueType, Workflow, WorkflowActivateMode, WorkflowExecuteMode, @@ -17,9 +13,6 @@ import { ApplicationError } from 'n8n-workflow'; // eslint-disable-next-line import/no-cycle import { - getAdditionalKeys, - getCredentials, - getNodeParameter, getNodeWebhookUrl, getRequestHelperFunctions, getWebhookDescription, @@ -48,34 +41,7 @@ export class HookContext extends NodeExecutionContext implements IHookFunctions } async getCredentials(type: string) { - return await getCredentials(this.workflow, this.node, type, this.additionalData, this.mode); - } - - getNodeParameter( - parameterName: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - fallbackValue?: any, - options?: IGetNodeParameterOptions, - ): NodeParameterValueType | object { - const runExecutionData: IRunExecutionData | null = null; - const itemIndex = 0; - const runIndex = 0; - const connectionInputData: INodeExecutionData[] = []; - - return getNodeParameter( - this.workflow, - runExecutionData, - runIndex, - connectionInputData, - this.node, - parameterName, - itemIndex, - this.mode, - getAdditionalKeys(this.additionalData, this.mode, runExecutionData), - undefined, - fallbackValue, - options, - ); + return await this._getCredentials(type); } getNodeWebhookUrl(name: WebhookType): string | undefined { @@ -85,7 +51,7 @@ export class HookContext extends NodeExecutionContext implements IHookFunctions this.node, this.additionalData, this.mode, - getAdditionalKeys(this.additionalData, this.mode, null), + this.additionalKeys, this.webhookData?.isTest, ); } diff --git a/packages/core/src/node-execution-context/index.ts b/packages/core/src/node-execution-context/index.ts index 8d281f1ed37bd..00c90266db98a 100644 --- a/packages/core/src/node-execution-context/index.ts +++ b/packages/core/src/node-execution-context/index.ts @@ -1,4 +1,5 @@ // eslint-disable-next-line import/no-cycle +export { ExecuteContext } from './execute-context'; export { ExecuteSingleContext } from './execute-single-context'; export { HookContext } from './hook-context'; export { LoadOptionsContext } from './load-options-context'; @@ -6,3 +7,5 @@ export { PollContext } from './poll-context'; export { SupplyDataContext } from './supply-data-context'; export { TriggerContext } from './trigger-context'; export { WebhookContext } from './webhook-context'; + +export { getAdditionalKeys } from './utils'; diff --git a/packages/core/src/node-execution-context/load-options-context.ts b/packages/core/src/node-execution-context/load-options-context.ts index bb43d9c2e2e1a..c961b56c06ca3 100644 --- a/packages/core/src/node-execution-context/load-options-context.ts +++ b/packages/core/src/node-execution-context/load-options-context.ts @@ -3,9 +3,7 @@ import type { ICredentialDataDecryptedObject, IGetNodeParameterOptions, INode, - INodeExecutionData, ILoadOptionsFunctions, - IRunExecutionData, IWorkflowExecuteAdditionalData, NodeParameterValueType, Workflow, @@ -13,13 +11,7 @@ import type { import { extractValue } from '@/ExtractValue'; // eslint-disable-next-line import/no-cycle -import { - getAdditionalKeys, - getCredentials, - getNodeParameter, - getRequestHelperFunctions, - getSSHTunnelFunctions, -} from '@/NodeExecuteFunctions'; +import { getRequestHelperFunctions, getSSHTunnelFunctions } from '@/NodeExecuteFunctions'; import { NodeExecutionContext } from './node-execution-context'; @@ -41,7 +33,7 @@ export class LoadOptionsContext extends NodeExecutionContext implements ILoadOpt } async getCredentials(type: string) { - return await getCredentials(this.workflow, this.node, type, this.additionalData, this.mode); + return await this._getCredentials(type); } getCurrentNodeParameter( @@ -76,31 +68,4 @@ export class LoadOptionsContext extends NodeExecutionContext implements ILoadOpt getCurrentNodeParameters() { return this.additionalData.currentNodeParameters; } - - getNodeParameter( - parameterName: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - fallbackValue?: any, - options?: IGetNodeParameterOptions, - ): NodeParameterValueType | object { - const runExecutionData: IRunExecutionData | null = null; - const itemIndex = 0; - const runIndex = 0; - const connectionInputData: INodeExecutionData[] = []; - - return getNodeParameter( - this.workflow, - runExecutionData, - runIndex, - connectionInputData, - this.node, - parameterName, - itemIndex, - this.mode, - getAdditionalKeys(this.additionalData, this.mode, runExecutionData), - undefined, - fallbackValue, - options, - ); - } } 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 09c21b63a4f6a..c4bb0739fbc4a 100644 --- a/packages/core/src/node-execution-context/node-execution-context.ts +++ b/packages/core/src/node-execution-context/node-execution-context.ts @@ -1,17 +1,42 @@ +import { get } from 'lodash'; import type { FunctionsBase, + ICredentialDataDecryptedObject, + ICredentialsExpressionResolveValues, + IExecuteData, + IGetNodeParameterOptions, INode, + INodeCredentialDescription, + INodeCredentialsDetails, INodeExecutionData, + IRunExecutionData, IWorkflowExecuteAdditionalData, + NodeParameterValueType, NodeTypeAndVersion, Workflow, WorkflowExecuteMode, } from 'n8n-workflow'; -import { deepCopy, LoggerProxy } from 'n8n-workflow'; +import { + ApplicationError, + deepCopy, + ExpressionError, + LoggerProxy, + NodeHelpers, + NodeOperationError, +} from 'n8n-workflow'; import { Container } from 'typedi'; +import { HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_TOOL_NODE_TYPE } from '@/Constants'; +import { extractValue } from '@/ExtractValue'; import { InstanceSettings } from '@/InstanceSettings'; +import { + cleanupParameterData, + ensureType, + getAdditionalKeys, + validateValueAgainstSchema, +} from './utils'; + export abstract class NodeExecutionContext implements Omit { protected readonly instanceSettings = Container.get(InstanceSettings); @@ -20,6 +45,10 @@ export abstract class NodeExecutionContext implements Omit( + type: string, + executeData?: IExecuteData, + connectionInputData?: INodeExecutionData[], + itemIndex?: number, + ): Promise { + const { workflow, node, additionalData, mode, runExecutionData, runIndex } = this; + // Get the NodeType as it has the information if the credentials are required + const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); + + // Hardcode for now for security reasons that only a single node can access + // all credentials + const fullAccess = [HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_TOOL_NODE_TYPE].includes(node.type); + + let nodeCredentialDescription: INodeCredentialDescription | undefined; + if (!fullAccess) { + if (nodeType.description.credentials === undefined) { + throw new NodeOperationError( + node, + `Node type "${node.type}" does not have any credentials defined`, + { level: 'warning' }, + ); + } + + nodeCredentialDescription = nodeType.description.credentials.find( + (credentialTypeDescription) => credentialTypeDescription.name === type, + ); + if (nodeCredentialDescription === undefined) { + throw new NodeOperationError( + node, + `Node type "${node.type}" does not have any credentials of type "${type}" defined`, + { level: 'warning' }, + ); + } + + if ( + !NodeHelpers.displayParameter( + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + additionalData.currentNodeParameters || node.parameters, + nodeCredentialDescription, + node, + node.parameters, + ) + ) { + // Credentials should not be displayed even if they would be defined + throw new NodeOperationError(node, 'Credentials not found'); + } + } + + // Check if node has any credentials defined + if (!fullAccess && !node.credentials?.[type]) { + // If none are defined check if the credentials are required or not + + if (nodeCredentialDescription?.required === true) { + // Credentials are required so error + if (!node.credentials) { + throw new NodeOperationError(node, 'Node does not have any credentials set', { + level: 'warning', + }); + } + if (!node.credentials[type]) { + throw new NodeOperationError( + node, + `Node does not have any credentials set for "${type}"`, + { + level: 'warning', + }, + ); + } + } else { + // Credentials are not required + throw new NodeOperationError(node, 'Node does not require credentials'); + } + } + + if (fullAccess && !node.credentials?.[type]) { + // Make sure that fullAccess nodes still behave like before that if they + // request access to credentials that are currently not set it returns undefined + throw new NodeOperationError(node, 'Credentials not found'); + } + + let expressionResolveValues: ICredentialsExpressionResolveValues | undefined; + if (connectionInputData && runExecutionData && runIndex !== undefined) { + expressionResolveValues = { + connectionInputData, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + itemIndex: itemIndex || 0, + node, + runExecutionData, + runIndex, + workflow, + } as ICredentialsExpressionResolveValues; + } + + const nodeCredentials = node.credentials + ? node.credentials[type] + : ({} as INodeCredentialsDetails); + + // TODO: solve using credentials via expression + // if (name.charAt(0) === '=') { + // // If the credential name is an expression resolve it + // const additionalKeys = getAdditionalKeys(additionalData, mode); + // name = workflow.expression.getParameterValue( + // name, + // runExecutionData || null, + // runIndex || 0, + // itemIndex || 0, + // node.name, + // connectionInputData || [], + // mode, + // additionalKeys, + // ) as string; + // } + + const decryptedDataObject = await additionalData.credentialsHelper.getDecrypted( + additionalData, + nodeCredentials, + type, + mode, + executeData, + false, + expressionResolveValues, + ); + + return decryptedDataObject as T; + } + + protected get additionalKeys() { + return getAdditionalKeys(this.additionalData, this.mode, this.runExecutionData); + } + + /** Returns the requested resolved (all expressions replaced) node parameters. */ + getNodeParameter( + parameterName: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fallbackValue?: any, + options?: IGetNodeParameterOptions, + ): NodeParameterValueType | object { + const itemIndex = 0; + return this._getNodeParameter(parameterName, itemIndex, fallbackValue, options); + } + + protected _getNodeParameter( + parameterName: string, + itemIndex: number, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fallbackValue?: any, + options?: IGetNodeParameterOptions, + ): NodeParameterValueType | object { + const { workflow, node, mode, runExecutionData, runIndex, connectionInputData, executeData } = + this; + + const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const value = get(node.parameters, parameterName, fallbackValue); + + if (value === undefined) { + throw new ApplicationError('Could not get parameter', { extra: { parameterName } }); + } + + if (options?.rawExpressions) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return value; + } + + const { additionalKeys } = this; + + let returnData; + + try { + returnData = workflow.expression.getParameterValue( + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + value, + runExecutionData, + runIndex, + itemIndex, + node.name, + connectionInputData, + mode, + additionalKeys, + executeData, + false, + {}, + options?.contextNode?.name, + ); + cleanupParameterData(returnData); + } catch (e) { + if ( + e instanceof ExpressionError && + node.continueOnFail && + node.type === 'n8n-nodes-base.set' + ) { + // https://linear.app/n8n/issue/PAY-684 + returnData = [{ name: undefined, value: undefined }]; + } else { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (e.context) e.context.parameter = parameterName; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + e.cause = value; + throw e; + } + } + + // This is outside the try/catch because it throws errors with proper messages + if (options?.extractValue) { + returnData = extractValue(returnData, parameterName, node, nodeType, itemIndex); + } + + // Make sure parameter value is the type specified in the ensureType option, if needed convert it + if (options?.ensureType) { + returnData = ensureType(options.ensureType, returnData, parameterName, { + itemIndex, + runIndex, + nodeCause: node.name, + }); + } + + // Validate parameter value if it has a schema defined(RMC) or validateType defined + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + returnData = validateValueAgainstSchema( + node, + nodeType, + returnData, + parameterName, + runIndex, + itemIndex, + ); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return returnData; + } + + evaluateExpression(expression: string, itemIndex: number = 0) { + return this.workflow.expression.resolveSimpleParameterValue( + `=${expression}`, + {}, + this.runExecutionData, + this.runIndex, + itemIndex, + this.node.name, + this.connectionInputData, + this.mode, + this.additionalKeys, + this.executeData, + ); + } + async prepareOutputData(outputData: INodeExecutionData[]) { return [outputData]; } diff --git a/packages/core/src/node-execution-context/poll-context.ts b/packages/core/src/node-execution-context/poll-context.ts index e3c0dd0cc8a81..ca90a243f46a5 100644 --- a/packages/core/src/node-execution-context/poll-context.ts +++ b/packages/core/src/node-execution-context/poll-context.ts @@ -1,12 +1,8 @@ import type { ICredentialDataDecryptedObject, - IGetNodeParameterOptions, INode, - INodeExecutionData, IPollFunctions, - IRunExecutionData, IWorkflowExecuteAdditionalData, - NodeParameterValueType, Workflow, WorkflowActivateMode, WorkflowExecuteMode, @@ -15,10 +11,7 @@ import { ApplicationError, createDeferredPromise } from 'n8n-workflow'; // eslint-disable-next-line import/no-cycle import { - getAdditionalKeys, getBinaryHelperFunctions, - getCredentials, - getNodeParameter, getRequestHelperFunctions, getSchedulingFunctions, returnJsonArray, @@ -62,33 +55,6 @@ export class PollContext extends NodeExecutionContext implements IPollFunctions } async getCredentials(type: string) { - return await getCredentials(this.workflow, this.node, type, this.additionalData, this.mode); - } - - getNodeParameter( - parameterName: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - fallbackValue?: any, - options?: IGetNodeParameterOptions, - ): NodeParameterValueType | object { - const runExecutionData: IRunExecutionData | null = null; - const itemIndex = 0; - const runIndex = 0; - const connectionInputData: INodeExecutionData[] = []; - - return getNodeParameter( - this.workflow, - runExecutionData, - runIndex, - connectionInputData, - this.node, - parameterName, - itemIndex, - this.mode, - getAdditionalKeys(this.additionalData, this.mode, runExecutionData), - undefined, - fallbackValue, - options, - ); + return await this._getCredentials(type); } } diff --git a/packages/core/src/node-execution-context/supply-data-context.ts b/packages/core/src/node-execution-context/supply-data-context.ts index de59a1c6d46d2..bab9d111089e9 100644 --- a/packages/core/src/node-execution-context/supply-data-context.ts +++ b/packages/core/src/node-execution-context/supply-data-context.ts @@ -1,11 +1,6 @@ import type { - AiEvent, - CallbackManager, CloseFunction, - ExecuteWorkflowData, - ICredentialDataDecryptedObject, IExecuteData, - IExecuteWorkflowInfo, IGetNodeParameterOptions, INode, INodeExecutionData, @@ -15,32 +10,20 @@ import type { ITaskMetadata, IWorkflowExecuteAdditionalData, NodeConnectionType, - RelatedExecution, Workflow, WorkflowExecuteMode, } from 'n8n-workflow'; -import { - ApplicationError, - createDeferredPromise, - NodeHelpers, - WorkflowDataProxy, -} from 'n8n-workflow'; -import Container from 'typedi'; +import { ApplicationError, createDeferredPromise } from 'n8n-workflow'; -import { BinaryDataService } from '@/BinaryData/BinaryData.service'; // eslint-disable-next-line import/no-cycle import { assertBinaryData, - continueOnFail, constructExecutionMetaData, copyInputItems, - getAdditionalKeys, getBinaryDataBuffer, getBinaryHelperFunctions, getCheckProcessedHelperFunctions, - getCredentials, getFileSystemHelperFunctions, - getNodeParameter, getRequestHelperFunctions, getSSHTunnelFunctions, normalizeItems, @@ -49,9 +32,9 @@ import { addExecutionDataFunctions, } from '@/NodeExecuteFunctions'; -import { NodeExecutionContext } from './node-execution-context'; +import { BaseExecuteContext } from './base-execute-context'; -export class SupplyDataContext extends NodeExecutionContext implements ISupplyDataFunctions { +export class SupplyDataContext extends BaseExecuteContext implements ISupplyDataFunctions { readonly helpers: ISupplyDataFunctions['helpers']; readonly getNodeParameter: ISupplyDataFunctions['getNodeParameter']; @@ -61,15 +44,26 @@ export class SupplyDataContext extends NodeExecutionContext implements ISupplyDa node: INode, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode, - private readonly runExecutionData: IRunExecutionData, - private readonly runIndex: number, - private readonly connectionInputData: INodeExecutionData[], - private readonly inputData: ITaskDataConnections, - private readonly executeData: IExecuteData, + runExecutionData: IRunExecutionData, + runIndex: number, + connectionInputData: INodeExecutionData[], + inputData: ITaskDataConnections, + executeData: IExecuteData, private readonly closeFunctions: CloseFunction[], - private readonly abortSignal?: AbortSignal, + abortSignal?: AbortSignal, ) { - super(workflow, node, additionalData, mode); + super( + workflow, + node, + additionalData, + mode, + runExecutionData, + runIndex, + connectionInputData, + inputData, + executeData, + abortSignal, + ); this.helpers = { createDeferredPromise, @@ -102,116 +96,14 @@ export class SupplyDataContext extends NodeExecutionContext implements ISupplyDa fallbackValue?: any, options?: IGetNodeParameterOptions, ) => - getNodeParameter( - this.workflow, - this.runExecutionData, - this.runIndex, - this.connectionInputData, - this.node, + this._getNodeParameter( parameterName, itemIndex, - this.mode, - getAdditionalKeys(this.additionalData, this.mode, this.runExecutionData), - this.executeData, fallbackValue, options, )) as ISupplyDataFunctions['getNodeParameter']; } - getExecutionCancelSignal() { - return this.abortSignal; - } - - onExecutionCancellation(handler: () => unknown) { - const fn = () => { - this.abortSignal?.removeEventListener('abort', fn); - handler(); - }; - this.abortSignal?.addEventListener('abort', fn); - } - - async getCredentials( - type: string, - itemIndex: number, - ) { - return await getCredentials( - this.workflow, - this.node, - type, - this.additionalData, - this.mode, - this.executeData, - this.runExecutionData, - this.runIndex, - this.connectionInputData, - itemIndex, - ); - } - - continueOnFail() { - return continueOnFail(this.node); - } - - evaluateExpression(expression: string, itemIndex: number) { - return this.workflow.expression.resolveSimpleParameterValue( - `=${expression}`, - {}, - this.runExecutionData, - this.runIndex, - itemIndex, - this.node.name, - this.connectionInputData, - this.mode, - getAdditionalKeys(this.additionalData, this.mode, this.runExecutionData), - this.executeData, - ); - } - - async executeWorkflow( - workflowInfo: IExecuteWorkflowInfo, - inputData?: INodeExecutionData[], - parentCallbackManager?: CallbackManager, - options?: { - doNotWaitToFinish?: boolean; - parentExecution?: RelatedExecution; - }, - ): Promise { - return await this.additionalData - .executeWorkflow(workflowInfo, this.additionalData, { - parentWorkflowId: this.workflow.id?.toString(), - inputData, - parentWorkflowSettings: this.workflow.settings, - node: this.node, - parentCallbackManager, - ...options, - }) - .then(async (result) => { - const data = await Container.get(BinaryDataService).duplicateBinaryData( - this.workflow.id, - this.additionalData.executionId!, - result.data, - ); - return { ...result, data }; - }); - } - - getNodeOutputs() { - const nodeType = this.workflow.nodeTypes.getByNameAndVersion( - this.node.type, - this.node.typeVersion, - ); - return NodeHelpers.getNodeOutputs(this.workflow, this.node, nodeType.description).map( - (output) => { - if (typeof output === 'string') { - return { - type: output, - }; - } - return output; - }, - ); - } - async getInputConnectionData(inputName: NodeConnectionType, itemIndex: number): Promise { return await getInputConnectionData.call( this, @@ -252,69 +144,11 @@ export class SupplyDataContext extends NodeExecutionContext implements ISupplyDa return this.inputData[inputName][inputIndex]; } - getWorkflowDataProxy(itemIndex: number) { - return new WorkflowDataProxy( - this.workflow, - this.runExecutionData, - this.runIndex, - itemIndex, - this.node.name, - this.connectionInputData, - {}, - this.mode, - getAdditionalKeys(this.additionalData, this.mode, this.runExecutionData), - this.executeData, - ).getDataProxy(); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sendMessageToUI(...args: any[]): void { - if (this.mode !== 'manual') { - return; - } - try { - if (this.additionalData.sendDataToUI) { - args = args.map((arg) => { - // prevent invalid dates from being logged as null - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return - if (arg.isLuxonDateTime && arg.invalidReason) return { ...arg }; - - // log valid dates in human readable format, as in browser - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument - if (arg.isLuxonDateTime) return new Date(arg.ts).toString(); - if (arg instanceof Date) return arg.toString(); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return arg; - }); - - this.additionalData.sendDataToUI('sendConsoleMessage', { - source: `[Node: "${this.node.name}"]`, - messages: args, - }); - } - } catch (error) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - this.logger.warn(`There was a problem sending message to UI: ${error.message}`); - } - } - - logAiEvent(eventName: AiEvent, msg: string) { - return this.additionalData.logAiEvent(eventName, { - executionId: this.additionalData.executionId ?? 'unsaved-execution', - nodeName: this.node.name, - workflowName: this.workflow.name ?? 'Unnamed workflow', - nodeType: this.node.type, - workflowId: this.workflow.id ?? 'unsaved-workflow', - msg, - }); - } - addInputData( connectionType: NodeConnectionType, data: INodeExecutionData[][], ): { index: number } { - const nodeName = this.getNode().name; + const nodeName = this.node.name; let currentNodeRunIndex = 0; if (this.runExecutionData.resultData.runData.hasOwnProperty(nodeName)) { currentNodeRunIndex = this.runExecutionData.resultData.runData[nodeName].length; @@ -322,17 +156,17 @@ export class SupplyDataContext extends NodeExecutionContext implements ISupplyDa addExecutionDataFunctions( 'input', - this.node.name, + nodeName, data, this.runExecutionData, connectionType, this.additionalData, - this.node.name, + nodeName, this.runIndex, currentNodeRunIndex, ).catch((error) => { this.logger.warn( - `There was a problem logging input data of node "${this.node.name}": ${ + `There was a problem logging input data of node "${nodeName}": ${ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access error.message }`, @@ -348,20 +182,21 @@ export class SupplyDataContext extends NodeExecutionContext implements ISupplyDa data: INodeExecutionData[][], metadata?: ITaskMetadata, ): void { + const nodeName = this.node.name; addExecutionDataFunctions( 'output', - this.node.name, + nodeName, data, this.runExecutionData, connectionType, this.additionalData, - this.node.name, + nodeName, this.runIndex, currentNodeRunIndex, metadata, ).catch((error) => { this.logger.warn( - `There was a problem logging output data of node "${this.node.name}": ${ + `There was a problem logging output data of node "${nodeName}": ${ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access error.message }`, diff --git a/packages/core/src/node-execution-context/trigger-context.ts b/packages/core/src/node-execution-context/trigger-context.ts index 5ae6ce47df19f..88c8d91432c0c 100644 --- a/packages/core/src/node-execution-context/trigger-context.ts +++ b/packages/core/src/node-execution-context/trigger-context.ts @@ -1,12 +1,8 @@ import type { ICredentialDataDecryptedObject, - IGetNodeParameterOptions, INode, - INodeExecutionData, - IRunExecutionData, ITriggerFunctions, IWorkflowExecuteAdditionalData, - NodeParameterValueType, Workflow, WorkflowActivateMode, WorkflowExecuteMode, @@ -15,10 +11,7 @@ import { ApplicationError, createDeferredPromise } from 'n8n-workflow'; // eslint-disable-next-line import/no-cycle import { - getAdditionalKeys, getBinaryHelperFunctions, - getCredentials, - getNodeParameter, getRequestHelperFunctions, getSchedulingFunctions, getSSHTunnelFunctions, @@ -64,33 +57,6 @@ export class TriggerContext extends NodeExecutionContext implements ITriggerFunc } async getCredentials(type: string) { - return await getCredentials(this.workflow, this.node, type, this.additionalData, this.mode); - } - - getNodeParameter( - parameterName: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - fallbackValue?: any, - options?: IGetNodeParameterOptions, - ): NodeParameterValueType | object { - const runExecutionData: IRunExecutionData | null = null; - const itemIndex = 0; - const runIndex = 0; - const connectionInputData: INodeExecutionData[] = []; - - return getNodeParameter( - this.workflow, - runExecutionData, - runIndex, - connectionInputData, - this.node, - parameterName, - itemIndex, - this.mode, - getAdditionalKeys(this.additionalData, this.mode, runExecutionData), - undefined, - fallbackValue, - options, - ); + return await this._getCredentials(type); } } diff --git a/packages/core/src/node-execution-context/utils.ts b/packages/core/src/node-execution-context/utils.ts new file mode 100644 index 0000000000000..a09147d543b23 --- /dev/null +++ b/packages/core/src/node-execution-context/utils.ts @@ -0,0 +1,423 @@ +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): 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/webhook-context.ts b/packages/core/src/node-execution-context/webhook-context.ts index 4d3eef53e2fed..e1dae9c1debfe 100644 --- a/packages/core/src/node-execution-context/webhook-context.ts +++ b/packages/core/src/node-execution-context/webhook-context.ts @@ -4,7 +4,6 @@ import type { ICredentialDataDecryptedObject, IDataObject, IExecuteData, - IGetNodeParameterOptions, INode, INodeExecutionData, IRunExecutionData, @@ -13,7 +12,6 @@ import type { IWebhookFunctions, IWorkflowExecuteAdditionalData, NodeConnectionType, - NodeParameterValueType, WebhookType, Workflow, WorkflowExecuteMode, @@ -23,11 +21,8 @@ import { ApplicationError, createDeferredPromise } from 'n8n-workflow'; // eslint-disable-next-line import/no-cycle import { copyBinaryFile, - getAdditionalKeys, getBinaryHelperFunctions, - getCredentials, getInputConnectionData, - getNodeParameter, getNodeWebhookUrl, getRequestHelperFunctions, returnJsonArray, @@ -47,9 +42,28 @@ export class WebhookContext extends NodeExecutionContext implements IWebhookFunc mode: WorkflowExecuteMode, private readonly webhookData: IWebhookData, private readonly closeFunctions: CloseFunction[], - private readonly runExecutionData: IRunExecutionData | null, + runExecutionData: IRunExecutionData | null, ) { - super(workflow, node, additionalData, mode); + let connectionInputData: INodeExecutionData[] = []; + let executionData: IExecuteData | undefined; + + if (runExecutionData?.executionData !== undefined) { + executionData = runExecutionData.executionData.nodeExecutionStack[0]; + if (executionData !== undefined) { + connectionInputData = executionData.data.main[0]!; + } + } + + super( + workflow, + node, + additionalData, + mode, + runExecutionData, + 0, + connectionInputData, + executionData, + ); this.helpers = { createDeferredPromise, @@ -71,7 +85,7 @@ export class WebhookContext extends NodeExecutionContext implements IWebhookFunc } async getCredentials(type: string) { - return await getCredentials(this.workflow, this.node, type, this.additionalData, this.mode); + return await this._getCredentials(type); } getBodyData() { @@ -116,7 +130,7 @@ export class WebhookContext extends NodeExecutionContext implements IWebhookFunc this.node, this.additionalData, this.mode, - getAdditionalKeys(this.additionalData, this.mode, null), + this.additionalKeys, ); } @@ -144,13 +158,12 @@ export class WebhookContext extends NodeExecutionContext implements IWebhookFunc node: this.node, source: null, }; - const runIndex = 0; return await getInputConnectionData.call( this, this.workflow, runExecutionData, - runIndex, + this.runIndex, connectionInputData, {} as ITaskDataConnections, this.additionalData, @@ -161,73 +174,4 @@ export class WebhookContext extends NodeExecutionContext implements IWebhookFunc itemIndex, ); } - - evaluateExpression(expression: string, evaluateItemIndex?: number) { - const itemIndex = evaluateItemIndex ?? 0; - const runIndex = 0; - - let connectionInputData: INodeExecutionData[] = []; - let executionData: IExecuteData | undefined; - - if (this.runExecutionData?.executionData !== undefined) { - executionData = this.runExecutionData.executionData.nodeExecutionStack[0]; - - if (executionData !== undefined) { - connectionInputData = executionData.data.main[0]!; - } - } - - const additionalKeys = getAdditionalKeys(this.additionalData, this.mode, this.runExecutionData); - - return this.workflow.expression.resolveSimpleParameterValue( - `=${expression}`, - {}, - this.runExecutionData, - runIndex, - itemIndex, - this.node.name, - connectionInputData, - this.mode, - additionalKeys, - executionData, - ); - } - - getNodeParameter( - parameterName: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - fallbackValue?: any, - options?: IGetNodeParameterOptions, - ): NodeParameterValueType | object { - const itemIndex = 0; - const runIndex = 0; - - let connectionInputData: INodeExecutionData[] = []; - let executionData: IExecuteData | undefined; - - if (this.runExecutionData?.executionData !== undefined) { - executionData = this.runExecutionData.executionData.nodeExecutionStack[0]; - - if (executionData !== undefined) { - connectionInputData = executionData.data.main[0]!; - } - } - - const additionalKeys = getAdditionalKeys(this.additionalData, this.mode, this.runExecutionData); - - return getNodeParameter( - this.workflow, - this.runExecutionData, - runIndex, - connectionInputData, - this.node, - parameterName, - itemIndex, - this.mode, - additionalKeys, - executionData, - fallbackValue, - options, - ); - } } diff --git a/packages/core/test/NodeExecuteFunctions.test.ts b/packages/core/test/NodeExecuteFunctions.test.ts index 421b7cd247da6..a796314b57c78 100644 --- a/packages/core/test/NodeExecuteFunctions.test.ts +++ b/packages/core/test/NodeExecuteFunctions.test.ts @@ -2,8 +2,6 @@ import { mkdtempSync, readFileSync } from 'fs'; import type { IncomingMessage } from 'http'; import type { Agent } from 'https'; import { mock } from 'jest-mock-extended'; -import toPlainObject from 'lodash/toPlainObject'; -import { DateTime } from 'luxon'; import type { IBinaryData, IHttpRequestMethods, @@ -12,11 +10,9 @@ import type { IRequestOptions, ITaskDataConnections, IWorkflowExecuteAdditionalData, - NodeParameterValue, Workflow, WorkflowHooks, } from 'n8n-workflow'; -import { ExpressionError } from 'n8n-workflow'; import nock from 'nock'; import { tmpdir } from 'os'; import { join } from 'path'; @@ -26,9 +22,7 @@ import Container from 'typedi'; import { BinaryDataService } from '@/BinaryData/BinaryData.service'; import { InstanceSettings } from '@/InstanceSettings'; import { - cleanupParameterData, copyInputItems, - ensureType, getBinaryDataBuffer, isFilePathBlocked, parseIncomingMessage, @@ -470,39 +464,6 @@ describe('NodeExecuteFunctions', () => { }); }); - 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('copyInputItems', () => { it('should pick only selected properties', () => { const output = copyInputItems( @@ -588,83 +549,6 @@ describe('NodeExecuteFunctions', () => { }, ); }); - - describe('ensureType', () => { - it('throws error for null value', () => { - expect(() => ensureType('string', null, 'myParam')).toThrowError( - new ExpressionError("Parameter 'myParam' must not be null"), - ); - }); - - it('throws error for undefined value', () => { - expect(() => ensureType('string', undefined, 'myParam')).toThrowError( - new ExpressionError("Parameter 'myParam' could not be 'undefined'"), - ); - }); - - it('returns string value without modification', () => { - const value = 'hello'; - const expectedValue = value; - const result = ensureType('string', value, 'myParam'); - expect(result).toBe(expectedValue); - }); - - it('returns number value without modification', () => { - const value = 42; - const expectedValue = value; - const result = ensureType('number', value, 'myParam'); - expect(result).toBe(expectedValue); - }); - - it('returns boolean value without modification', () => { - const value = true; - const expectedValue = value; - const result = ensureType('boolean', value, 'myParam'); - expect(result).toBe(expectedValue); - }); - - it('converts object to string if toType is string', () => { - const value = { name: 'John' }; - const expectedValue = JSON.stringify(value); - const result = ensureType('string', value, 'myParam'); - expect(result).toBe(expectedValue); - }); - - it('converts string to number if toType is number', () => { - const value = '10'; - const expectedValue = 10; - const result = ensureType('number', value, 'myParam'); - expect(result).toBe(expectedValue); - }); - - it('throws error for invalid conversion to number', () => { - const value = 'invalid'; - expect(() => ensureType('number', value, 'myParam')).toThrowError( - new ExpressionError("Parameter 'myParam' must be a number, but we got 'invalid'"), - ); - }); - - it('parses valid JSON string to object if toType is object', () => { - const value = '{"name": "Alice"}'; - const expectedValue = JSON.parse(value); - const result = ensureType('object', value, 'myParam'); - expect(result).toEqual(expectedValue); - }); - - it('throws error for invalid JSON string to object conversion', () => { - const value = 'invalid_json'; - expect(() => ensureType('object', value, 'myParam')).toThrowError( - new ExpressionError("Parameter 'myParam' could not be parsed"), - ); - }); - - it('throws error for non-array value if toType is array', () => { - const value = { name: 'Alice' }; - expect(() => ensureType('array', value, 'myParam')).toThrowError( - new ExpressionError("Parameter 'myParam' must be an array, but we got object"), - ); - }); - }); }); describe('isFilePathBlocked', () => {