From c31bf0a4d15ba151ab25a8c4892ad5eec5f729f3 Mon Sep 17 00:00:00 2001 From: Ivan Atanasov Date: Fri, 6 Dec 2024 11:30:44 +0100 Subject: [PATCH] Ado-2898-execute-workflow-node-add-workflow-inputs-parameter (#11887) Co-authored-by: Charlie Kolb --- .../dynamic-node-parameters.controller.ts | 16 + .../dynamic-node-parameters.service.ts | 85 +++- .../src/services/workflow-loader.service.ts | 19 + .../core/src/node-execution-context/index.ts | 1 + .../local-load-options-context.ts | 68 ++++ .../workflow-node-context.ts | 32 ++ packages/editor-ui/src/api/nodeTypes.ts | 12 + .../ResourceMapper/ResourceMapper.vue | 38 +- .../editor-ui/src/stores/nodeTypes.store.ts | 11 + .../ExecuteWorkflow.node.json | 0 .../ExecuteWorkflow.node.ts | 65 ++- .../ExecuteWorkflow/GenericFunctions.ts | 72 ++++ .../methods/resourceMapping.ts | 37 ++ .../ExecuteWorkflowTrigger.node.json | 0 .../ExecuteWorkflowTrigger.node.ts | 206 ++++++++++ .../test/ExecuteWorkflowTrigger.node.test.ts | 0 .../nodes/ExecuteWorkflow/GenericFunctions.ts | 220 +++++++--- .../nodes/ExecuteWorkflow/constants.ts | 36 ++ .../ExecuteWorkflowTrigger.node.ts | 381 ------------------ packages/nodes-base/package.json | 4 +- packages/workflow/src/Interfaces.ts | 39 +- 21 files changed, 872 insertions(+), 470 deletions(-) create mode 100644 packages/cli/src/services/workflow-loader.service.ts create mode 100644 packages/core/src/node-execution-context/local-load-options-context.ts create mode 100644 packages/core/src/node-execution-context/workflow-node-context.ts rename packages/nodes-base/nodes/ExecuteWorkflow/{ => ExecuteWorkflow}/ExecuteWorkflow.node.json (100%) rename packages/nodes-base/nodes/ExecuteWorkflow/{ => ExecuteWorkflow}/ExecuteWorkflow.node.ts (85%) create mode 100644 packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/methods/resourceMapping.ts rename packages/nodes-base/nodes/{ => ExecuteWorkflow}/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.json (100%) create mode 100644 packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.ts rename packages/nodes-base/nodes/{ => ExecuteWorkflow}/ExecuteWorkflowTrigger/test/ExecuteWorkflowTrigger.node.test.ts (100%) create mode 100644 packages/nodes-base/nodes/ExecuteWorkflow/constants.ts delete mode 100644 packages/nodes-base/nodes/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.ts diff --git a/packages/cli/src/controllers/dynamic-node-parameters.controller.ts b/packages/cli/src/controllers/dynamic-node-parameters.controller.ts index f3df53d95b..2858fd99ca 100644 --- a/packages/cli/src/controllers/dynamic-node-parameters.controller.ts +++ b/packages/cli/src/controllers/dynamic-node-parameters.controller.ts @@ -93,6 +93,22 @@ export class DynamicNodeParametersController { ); } + @Post('/local-resource-mapper-fields') + async getLocalResourceMappingFields(req: DynamicNodeParametersRequest.ResourceMapperFields) { + const { path, methodName, currentNodeParameters, nodeTypeAndVersion } = req.body; + + if (!methodName) throw new BadRequestError('Missing `methodName` in request body'); + + const additionalData = await getBase(req.user.id, currentNodeParameters); + + return await this.service.getLocalResourceMappingFields( + methodName, + path, + additionalData, + nodeTypeAndVersion, + ); + } + @Post('/action-result') async getActionResult( req: DynamicNodeParametersRequest.ActionResult, diff --git a/packages/cli/src/services/dynamic-node-parameters.service.ts b/packages/cli/src/services/dynamic-node-parameters.service.ts index eb6ecc5f67..8a5092866e 100644 --- a/packages/cli/src/services/dynamic-node-parameters.service.ts +++ b/packages/cli/src/services/dynamic-node-parameters.service.ts @@ -1,4 +1,4 @@ -import { LoadOptionsContext, NodeExecuteFunctions } from 'n8n-core'; +import { LoadOptionsContext, NodeExecuteFunctions, LocalLoadOptionsContext } from 'n8n-core'; import type { ILoadOptions, ILoadOptionsFunctions, @@ -17,15 +17,43 @@ import type { INodeTypeNameVersion, NodeParameterValueType, IDataObject, + ILocalLoadOptionsFunctions, } from 'n8n-workflow'; import { Workflow, RoutingNode, ApplicationError } from 'n8n-workflow'; import { Service } from 'typedi'; import { NodeTypes } from '@/node-types'; +import { WorkflowLoaderService } from './workflow-loader.service'; + +type LocalResourceMappingMethod = ( + this: ILocalLoadOptionsFunctions, +) => Promise; +type ListSearchMethod = ( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +) => Promise; +type LoadOptionsMethod = (this: ILoadOptionsFunctions) => Promise; +type ActionHandlerMethod = ( + this: ILoadOptionsFunctions, + payload?: string, +) => Promise; +type ResourceMappingMethod = (this: ILoadOptionsFunctions) => Promise; + +type NodeMethod = + | LocalResourceMappingMethod + | ListSearchMethod + | LoadOptionsMethod + | ActionHandlerMethod + | ResourceMappingMethod; + @Service() export class DynamicNodeParametersService { - constructor(private nodeTypes: NodeTypes) {} + constructor( + private nodeTypes: NodeTypes, + private workflowLoaderService: WorkflowLoaderService, + ) {} /** Returns the available options via a predefined method */ async getOptionsViaMethodName( @@ -159,6 +187,20 @@ export class DynamicNodeParametersService { return method.call(thisArgs); } + /** Returns the available workflow input mapping fields for the ResourceMapper component */ + async getLocalResourceMappingFields( + methodName: string, + path: string, + additionalData: IWorkflowExecuteAdditionalData, + nodeTypeAndVersion: INodeTypeNameVersion, + ): Promise { + const nodeType = this.getNodeType(nodeTypeAndVersion); + const method = this.getMethod('localResourceMapping', methodName, nodeType); + const thisArgs = this.getLocalLoadOptionsContext(path, additionalData); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return method.call(thisArgs); + } + /** Returns the result of the action handler */ async getActionResult( handler: string, @@ -181,33 +223,34 @@ export class DynamicNodeParametersService { type: 'resourceMapping', methodName: string, nodeType: INodeType, - ): (this: ILoadOptionsFunctions) => Promise; + ): ResourceMappingMethod; private getMethod( - type: 'listSearch', + type: 'localResourceMapping', methodName: string, nodeType: INodeType, - ): ( - this: ILoadOptionsFunctions, - filter?: string | undefined, - paginationToken?: string | undefined, - ) => Promise; + ): LocalResourceMappingMethod; + private getMethod(type: 'listSearch', methodName: string, nodeType: INodeType): ListSearchMethod; private getMethod( type: 'loadOptions', methodName: string, nodeType: INodeType, - ): (this: ILoadOptionsFunctions) => Promise; + ): LoadOptionsMethod; private getMethod( type: 'actionHandler', methodName: string, nodeType: INodeType, - ): (this: ILoadOptionsFunctions, payload?: string) => Promise; - + ): ActionHandlerMethod; private getMethod( - type: 'resourceMapping' | 'listSearch' | 'loadOptions' | 'actionHandler', + type: + | 'resourceMapping' + | 'localResourceMapping' + | 'listSearch' + | 'loadOptions' + | 'actionHandler', methodName: string, nodeType: INodeType, - ) { - const method = nodeType.methods?.[type]?.[methodName]; + ): NodeMethod { + const method = nodeType.methods?.[type]?.[methodName] as NodeMethod; if (typeof method !== 'function') { throw new ApplicationError('Node type does not have method defined', { tags: { nodeType: nodeType.description.name }, @@ -255,4 +298,16 @@ export class DynamicNodeParametersService { const node = workflow.nodes['Temp-Node']; return new LoadOptionsContext(workflow, node, additionalData, path); } + + private getLocalLoadOptionsContext( + path: string, + additionalData: IWorkflowExecuteAdditionalData, + ): ILocalLoadOptionsFunctions { + return new LocalLoadOptionsContext( + this.nodeTypes, + additionalData, + path, + this.workflowLoaderService, + ); + } } diff --git a/packages/cli/src/services/workflow-loader.service.ts b/packages/cli/src/services/workflow-loader.service.ts new file mode 100644 index 0000000000..ca1a9ff48c --- /dev/null +++ b/packages/cli/src/services/workflow-loader.service.ts @@ -0,0 +1,19 @@ +import { ApplicationError, type IWorkflowBase, type IWorkflowLoader } from 'n8n-workflow'; +import { Service } from 'typedi'; + +import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; + +@Service() +export class WorkflowLoaderService implements IWorkflowLoader { + constructor(private readonly workflowRepository: WorkflowRepository) {} + + async get(workflowId: string): Promise { + const workflow = await this.workflowRepository.findById(workflowId); + + if (!workflow) { + throw new ApplicationError(`Failed to find workflow with ID "${workflowId}"`); + } + + return workflow; + } +} diff --git a/packages/core/src/node-execution-context/index.ts b/packages/core/src/node-execution-context/index.ts index 00c90266db..fd5157485d 100644 --- a/packages/core/src/node-execution-context/index.ts +++ b/packages/core/src/node-execution-context/index.ts @@ -3,6 +3,7 @@ export { ExecuteContext } from './execute-context'; export { ExecuteSingleContext } from './execute-single-context'; export { HookContext } from './hook-context'; export { LoadOptionsContext } from './load-options-context'; +export { LocalLoadOptionsContext } from './local-load-options-context'; export { PollContext } from './poll-context'; export { SupplyDataContext } from './supply-data-context'; export { TriggerContext } from './trigger-context'; diff --git a/packages/core/src/node-execution-context/local-load-options-context.ts b/packages/core/src/node-execution-context/local-load-options-context.ts new file mode 100644 index 0000000000..6f6e76215c --- /dev/null +++ b/packages/core/src/node-execution-context/local-load-options-context.ts @@ -0,0 +1,68 @@ +import { get } from 'lodash'; +import { ApplicationError, Workflow } from 'n8n-workflow'; +import type { + INodeParameterResourceLocator, + IWorkflowExecuteAdditionalData, + NodeParameterValueType, + ILocalLoadOptionsFunctions, + IWorkflowLoader, + IWorkflowNodeContext, + INode, + INodeTypes, +} from 'n8n-workflow'; + +import { LoadWorkflowNodeContext } from './workflow-node-context'; + +export class LocalLoadOptionsContext implements ILocalLoadOptionsFunctions { + constructor( + private nodeTypes: INodeTypes, + private additionalData: IWorkflowExecuteAdditionalData, + private path: string, + private workflowLoader: IWorkflowLoader, + ) {} + + async getWorkflowNodeContext(nodeType: string): Promise { + const { value } = this.getCurrentNodeParameter('workflowId') as INodeParameterResourceLocator; + + const workflowId = value as string; + if (!workflowId) { + throw new ApplicationError(`No workflowId parameter defined on node of type "${nodeType}"!`); + } + + const dbWorkflow = await this.workflowLoader.get(workflowId); + + const selectedWorkflowNode = dbWorkflow.nodes.find((node) => node.type === nodeType) as INode; + + if (selectedWorkflowNode) { + const selectedSingleNodeWorkflow = new Workflow({ + nodes: [selectedWorkflowNode], + connections: {}, + active: false, + nodeTypes: this.nodeTypes, + }); + + const workflowAdditionalData = { ...this.additionalData }; + workflowAdditionalData.currentNodeParameters = selectedWorkflowNode.parameters; + + return new LoadWorkflowNodeContext( + selectedSingleNodeWorkflow, + selectedWorkflowNode, + workflowAdditionalData, + ); + } + + return null; + } + + getCurrentNodeParameter(parameterPath: string): NodeParameterValueType | object | undefined { + const nodeParameters = this.additionalData.currentNodeParameters; + + if (parameterPath.startsWith('&')) { + parameterPath = `${this.path.split('.').slice(1, -1).join('.')}.${parameterPath.slice(1)}`; + } + + const returnData = get(nodeParameters, parameterPath); + + return returnData; + } +} diff --git a/packages/core/src/node-execution-context/workflow-node-context.ts b/packages/core/src/node-execution-context/workflow-node-context.ts new file mode 100644 index 0000000000..7a8c730ae6 --- /dev/null +++ b/packages/core/src/node-execution-context/workflow-node-context.ts @@ -0,0 +1,32 @@ +import type { + IGetNodeParameterOptions, + INode, + IWorkflowExecuteAdditionalData, + Workflow, + IWorkflowNodeContext, +} from 'n8n-workflow'; + +import { NodeExecutionContext } from './node-execution-context'; + +export class LoadWorkflowNodeContext extends NodeExecutionContext implements IWorkflowNodeContext { + readonly getNodeParameter: IWorkflowNodeContext['getNodeParameter']; + + constructor(workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData) { + super(workflow, node, additionalData, 'internal'); + { + 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 IWorkflowNodeContext['getNodeParameter']; + } + } +} diff --git a/packages/editor-ui/src/api/nodeTypes.ts b/packages/editor-ui/src/api/nodeTypes.ts index f4d516aaef..ec3e2bdba5 100644 --- a/packages/editor-ui/src/api/nodeTypes.ts +++ b/packages/editor-ui/src/api/nodeTypes.ts @@ -59,6 +59,18 @@ export async function getResourceMapperFields( ); } +export async function getLocalResourceMapperFields( + context: IRestApiContext, + sendData: DynamicNodeParameters.ResourceMapperFieldsRequest, +): Promise { + return await makeRestApiRequest( + context, + 'POST', + '/dynamic-node-parameters/local-resource-mapper-fields', + sendData, + ); +} + export async function getNodeParameterActionResult( context: IRestApiContext, sendData: DynamicNodeParameters.ActionResultRequest, diff --git a/packages/editor-ui/src/components/ResourceMapper/ResourceMapper.vue b/packages/editor-ui/src/components/ResourceMapper/ResourceMapper.vue index b09354ed00..acff282cca 100644 --- a/packages/editor-ui/src/components/ResourceMapper/ResourceMapper.vue +++ b/packages/editor-ui/src/components/ResourceMapper/ResourceMapper.vue @@ -239,20 +239,14 @@ async function initFetching(inlineLoading = false): Promise { } } -async function loadFieldsToMap(): Promise { +const createRequestParams = (methodName: string) => { if (!props.node) { return; } - - const methodName = props.parameter.typeOptions?.resourceMapper?.resourceMapperMethod; - if (typeof methodName !== 'string') { - return; - } - const requestParams: DynamicNodeParameters.ResourceMapperFieldsRequest = { nodeTypeAndVersion: { name: props.node?.type, - version: props.node.typeVersion, + version: props.node?.typeVersion, }, currentNodeParameters: resolveRequiredParameters( props.parameter, @@ -262,7 +256,33 @@ async function loadFieldsToMap(): Promise { methodName, credentials: props.node.credentials, }; - const fetchedFields = await nodeTypesStore.getResourceMapperFields(requestParams); + + return requestParams; +}; + +async function loadFieldsToMap(): Promise { + if (!props.node) { + return; + } + + const { resourceMapperMethod, localResourceMapperMethod } = + props.parameter.typeOptions?.resourceMapper ?? {}; + + let fetchedFields = null; + + if (typeof resourceMapperMethod === 'string') { + const requestParams = createRequestParams( + resourceMapperMethod, + ) as DynamicNodeParameters.ResourceMapperFieldsRequest; + fetchedFields = await nodeTypesStore.getResourceMapperFields(requestParams); + } else if (typeof localResourceMapperMethod === 'string') { + const requestParams = createRequestParams( + localResourceMapperMethod, + ) as DynamicNodeParameters.ResourceMapperFieldsRequest; + + fetchedFields = await nodeTypesStore.getLocalResourceMapperFields(requestParams); + } + if (fetchedFields !== null) { const newSchema = fetchedFields.fields.map((field) => { const existingField = state.paramValue.schema.find((f) => f.id === field.id); diff --git a/packages/editor-ui/src/stores/nodeTypes.store.ts b/packages/editor-ui/src/stores/nodeTypes.store.ts index 7a8e66aab0..06917edfaa 100644 --- a/packages/editor-ui/src/stores/nodeTypes.store.ts +++ b/packages/editor-ui/src/stores/nodeTypes.store.ts @@ -302,6 +302,16 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => { } }; + const getLocalResourceMapperFields = async ( + sendData: DynamicNodeParameters.ResourceMapperFieldsRequest, + ) => { + try { + return await nodeTypesApi.getLocalResourceMapperFields(rootStore.restApiContext, sendData); + } catch (error) { + return null; + } + }; + const getNodeParameterActionResult = async ( sendData: DynamicNodeParameters.ActionResultRequest, ) => { @@ -326,6 +336,7 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => { visibleNodeTypesByInputConnectionTypeNames, isConfigurableNode, getResourceMapperFields, + getLocalResourceMapperFields, getNodeParameterActionResult, getResourceLocatorResults, getNodeParameterOptions, diff --git a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow.node.json b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.json similarity index 100% rename from packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow.node.json rename to packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.json diff --git a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow.node.ts b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.ts similarity index 85% rename from packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow.node.ts rename to packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.ts index 1c0938a21f..60037305b0 100644 --- a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow.node.ts +++ b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.ts @@ -1,14 +1,34 @@ import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; import type { ExecuteWorkflowData, + FieldValueOption, IExecuteFunctions, INodeExecutionData, INodeType, INodeTypeDescription, + ResourceMapperField, } from 'n8n-workflow'; import { getWorkflowInfo } from './GenericFunctions'; -import { generatePairedItemData } from '../../utils/utilities'; +import { loadWorkflowInputMappings } from './methods/resourceMapping'; +import { generatePairedItemData } from '../../../utils/utilities'; +import { getWorkflowInputData } from '../GenericFunctions'; + +function getCurrentWorkflowInputData(this: IExecuteFunctions) { + const inputData = this.getInputData(); + + if (this.getNode().typeVersion < 1.2) { + return inputData; + } else { + const schema = this.getNodeParameter('workflowInputs.schema', 0, []) as ResourceMapperField[]; + const newParams = schema + .filter((x) => !x.removed) + .map((x) => ({ name: x.displayName, type: x.type ?? 'any' })) as FieldValueOption[]; + + // TODO: map every row to field values so we can set the static value or expression later on + return getWorkflowInputData.call(this, inputData, newParams); + } +} export class ExecuteWorkflow implements INodeType { description: INodeTypeDescription = { @@ -187,6 +207,41 @@ export class ExecuteWorkflow implements INodeType { default: '', displayOptions: { show: { '@version': [{ _cnd: { lte: 1.1 } }] } }, }, + { + displayName: 'Workflow Inputs', + name: 'workflowInputs', + type: 'resourceMapper', + noDataExpression: true, + default: { + mappingMode: 'defineBelow', + value: null, + }, + required: true, + typeOptions: { + loadOptionsDependsOn: ['workflowId.value'], + resourceMapper: { + localResourceMapperMethod: 'loadWorkflowInputMappings', + valuesLabel: 'Workflow Inputs', + mode: 'add', + fieldWords: { + singular: 'workflow input', + plural: 'workflow inputs', + }, + addAllFields: true, + multiKeyMatch: false, + supportAutoMap: false, + }, + }, + displayOptions: { + show: { + source: ['database'], + '@version': [{ _cnd: { gte: 1.2 } }], + }, + hide: { + workflowId: [''], + }, + }, + }, { displayName: 'Mode', name: 'mode', @@ -228,10 +283,16 @@ export class ExecuteWorkflow implements INodeType { ], }; + methods = { + localResourceMapping: { + loadWorkflowInputMappings, + }, + }; + async execute(this: IExecuteFunctions): Promise { const source = this.getNodeParameter('source', 0) as string; const mode = this.getNodeParameter('mode', 0, false) as string; - const items = this.getInputData(); + const items = getCurrentWorkflowInputData.call(this); const workflowProxy = this.getWorkflowDataProxy(0); const currentWorkflowId = workflowProxy.$workflow.id as string; diff --git a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/GenericFunctions.ts b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/GenericFunctions.ts new file mode 100644 index 0000000000..450a268dfa --- /dev/null +++ b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/GenericFunctions.ts @@ -0,0 +1,72 @@ +import { readFile as fsReadFile } from 'fs/promises'; +import { NodeOperationError, jsonParse } from 'n8n-workflow'; +import type { + IExecuteFunctions, + IExecuteWorkflowInfo, + ILoadOptionsFunctions, + INodeParameterResourceLocator, + IRequestOptions, +} from 'n8n-workflow'; + +export async function getWorkflowInfo( + this: ILoadOptionsFunctions | IExecuteFunctions, + source: string, + itemIndex = 0, +) { + const workflowInfo: IExecuteWorkflowInfo = {}; + const nodeVersion = this.getNode().typeVersion; + if (source === 'database') { + // Read workflow from database + if (nodeVersion === 1) { + workflowInfo.id = this.getNodeParameter('workflowId', itemIndex) as string; + } else { + const { value } = this.getNodeParameter( + 'workflowId', + itemIndex, + {}, + ) as INodeParameterResourceLocator; + workflowInfo.id = value as string; + } + } else if (source === 'localFile') { + // Read workflow from filesystem + const workflowPath = this.getNodeParameter('workflowPath', itemIndex) as string; + + let workflowJson; + try { + workflowJson = await fsReadFile(workflowPath, { encoding: 'utf8' }); + } catch (error) { + if (error.code === 'ENOENT') { + throw new NodeOperationError( + this.getNode(), + `The file "${workflowPath}" could not be found, [item ${itemIndex}]`, + ); + } + + throw error; + } + + workflowInfo.code = jsonParse(workflowJson); + } else if (source === 'parameter') { + // Read workflow from parameter + const workflowJson = this.getNodeParameter('workflowJson', itemIndex) as string; + workflowInfo.code = jsonParse(workflowJson); + } else if (source === 'url') { + // Read workflow from url + const workflowUrl = this.getNodeParameter('workflowUrl', itemIndex) as string; + + const requestOptions = { + headers: { + accept: 'application/json,text/*;q=0.99', + }, + method: 'GET', + uri: workflowUrl, + json: true, + gzip: true, + } satisfies IRequestOptions; + + const response = await this.helpers.request(requestOptions); + workflowInfo.code = response; + } + + return workflowInfo; +} diff --git a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/methods/resourceMapping.ts b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/methods/resourceMapping.ts new file mode 100644 index 0000000000..cb1c01d365 --- /dev/null +++ b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflow/methods/resourceMapping.ts @@ -0,0 +1,37 @@ +import { + EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, + type ILocalLoadOptionsFunctions, + type ResourceMapperField, + type ResourceMapperFields, +} from 'n8n-workflow'; + +import { getFieldEntries } from '../../GenericFunctions'; + +export async function loadWorkflowInputMappings( + this: ILocalLoadOptionsFunctions, +): Promise { + const nodeLoadContext = await this.getWorkflowNodeContext(EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE); + let fields: ResourceMapperField[] = []; + + if (nodeLoadContext) { + const fieldValues = getFieldEntries(nodeLoadContext); + + fields = fieldValues.map((currentWorkflowInput) => { + const field: ResourceMapperField = { + id: currentWorkflowInput.name, + displayName: currentWorkflowInput.name, + required: false, + defaultMatch: true, + display: true, + canBeUsedToMatch: true, + }; + + if (currentWorkflowInput.type !== 'any') { + field.type = currentWorkflowInput.type; + } + + return field; + }); + } + return { fields }; +} diff --git a/packages/nodes-base/nodes/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.json b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.json similarity index 100% rename from packages/nodes-base/nodes/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.json rename to packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.json diff --git a/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.ts b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.ts new file mode 100644 index 0000000000..437c56e9f4 --- /dev/null +++ b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.ts @@ -0,0 +1,206 @@ +import { + NodeConnectionType, + type IExecuteFunctions, + type INodeType, + type INodeTypeDescription, +} from 'n8n-workflow'; + +import { + INPUT_SOURCE, + WORKFLOW_INPUTS, + JSON_EXAMPLE, + VALUES, + INPUT_OPTIONS, + TYPE_OPTIONS, +} from '../constants'; +import { getFieldEntries, getWorkflowInputData } from '../GenericFunctions'; + +export class ExecuteWorkflowTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Execute Workflow Trigger', + name: 'executeWorkflowTrigger', + icon: 'fa:sign-out-alt', + group: ['trigger'], + version: [1, 1.1], + description: + 'Helpers for calling other n8n workflows. Used for designing modular, microservice-like workflows.', + eventTriggerDescription: '', + maxNodes: 1, + defaults: { + name: 'Workflow Input Trigger', + color: '#ff6d5a', + }, + inputs: [], + outputs: [NodeConnectionType.Main], + hints: [ + { + message: + 'You need to define your input fields explicitly. Otherwise the parent cannot provide data and you will not receive input data.', + // This condition checks if we have no input fields, which gets a bit awkward: + // For WORKFLOW_INPUTS: keys() only contains `VALUES` if at least one value is provided + // For JSON_EXAMPLE: We remove all whitespace and check if we're left with an empty object. Note that we already error if the example is not valid JSON + displayCondition: + `={{$parameter['${INPUT_SOURCE}'] === '${WORKFLOW_INPUTS}' && !$parameter['${WORKFLOW_INPUTS}'].keys().length ` + + `|| $parameter['${INPUT_SOURCE}'] === '${JSON_EXAMPLE}' && $parameter['${JSON_EXAMPLE}'].toString().replaceAll(' ', '').replaceAll('\\n', '') === '{}' }}`, + whenToDisplay: 'always', + location: 'ndv', + }, + ], + properties: [ + { + displayName: 'Events', + name: 'events', + type: 'hidden', + noDataExpression: true, + options: [ + { + name: 'Workflow Call', + value: 'worklfow_call', + description: 'When called by another workflow using Execute Workflow Trigger', + action: 'When Called by Another Workflow', + }, + ], + default: 'worklfow_call', + }, + { + displayName: 'Input Source', + name: INPUT_SOURCE, + type: 'options', + options: [ + { + name: 'Using Fields Below', + value: WORKFLOW_INPUTS, + description: 'Provide via UI', + }, + { + name: 'Using JSON Example', + value: JSON_EXAMPLE, + description: 'Infer JSON schema via JSON example output', + }, + ], + default: WORKFLOW_INPUTS, + noDataExpression: true, + displayOptions: { + show: { '@version': [{ _cnd: { gte: 1.1 } }] }, + }, + }, + { + displayName: + 'Provide an example object to infer fields and their types.
To allow any type for a given field, set the value to null.', + name: `${JSON_EXAMPLE}_notice`, + type: 'notice', + default: '', + displayOptions: { + show: { '@version': [{ _cnd: { gte: 1.1 } }], inputSource: [JSON_EXAMPLE] }, + }, + }, + { + displayName: 'JSON Example', + name: JSON_EXAMPLE, + type: 'json', + default: JSON.stringify( + { + aField: 'a string', + aNumber: 123, + thisFieldAcceptsAnyType: null, + anArray: [], + }, + null, + 2, + ), + noDataExpression: true, + displayOptions: { + show: { '@version': [{ _cnd: { gte: 1.1 } }], inputSource: [JSON_EXAMPLE] }, + }, + }, + { + displayName: 'Workflow Inputs', + name: WORKFLOW_INPUTS, + placeholder: 'Add Field', + type: 'fixedCollection', + description: + 'Define expected input fields. If no inputs are provided, all data from the calling workflow will be passed through.', + typeOptions: { + multipleValues: true, + sortable: true, + }, + displayOptions: { + show: { '@version': [{ _cnd: { gte: 1.1 } }], inputSource: [WORKFLOW_INPUTS] }, + }, + default: {}, + options: [ + { + name: VALUES, + displayName: 'Values', + values: [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + placeholder: 'e.g. fieldName', + description: 'Name of the field', + noDataExpression: true, + }, + { + displayName: 'Type', + name: 'type', + type: 'options', + description: 'The field value type', + // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items + options: TYPE_OPTIONS, + default: 'string', + noDataExpression: true, + }, + ], + }, + ], + }, + { + displayName: 'Input Options', + name: INPUT_OPTIONS, + placeholder: 'Options', + type: 'collection', + description: 'Options controlling how input data is handled, converted and rejected', + displayOptions: { + show: { '@version': [{ _cnd: { gte: 1.1 } }] }, + }, + default: {}, + // Note that, while the defaults are true, the user has to add these in the first place + // We default to false if absent in the execute function below + options: [ + { + displayName: 'Attempt to Convert Types', + name: 'attemptToConvertTypes', + type: 'boolean', + default: true, + description: + 'Whether to attempt conversion on type mismatch, rather than directly returning an Error', + noDataExpression: true, + }, + { + displayName: 'Ignore Type Mismatch Errors', + name: 'ignoreTypeErrors', + type: 'boolean', + default: true, + description: + 'Whether type mismatches should be ignored, rather than returning an Error', + noDataExpression: true, + }, + ], + }, + ], + }; + + async execute(this: IExecuteFunctions) { + const inputData = this.getInputData(); + + if (this.getNode().typeVersion < 1.1) { + return [inputData]; + } else { + const newParams = getFieldEntries(this); + + return [getWorkflowInputData.call(this, inputData, newParams)]; + } + } +} diff --git a/packages/nodes-base/nodes/ExecuteWorkflowTrigger/test/ExecuteWorkflowTrigger.node.test.ts b/packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/test/ExecuteWorkflowTrigger.node.test.ts similarity index 100% rename from packages/nodes-base/nodes/ExecuteWorkflowTrigger/test/ExecuteWorkflowTrigger.node.test.ts rename to packages/nodes-base/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/test/ExecuteWorkflowTrigger.node.test.ts diff --git a/packages/nodes-base/nodes/ExecuteWorkflow/GenericFunctions.ts b/packages/nodes-base/nodes/ExecuteWorkflow/GenericFunctions.ts index 7588040bf8..ad140e6c77 100644 --- a/packages/nodes-base/nodes/ExecuteWorkflow/GenericFunctions.ts +++ b/packages/nodes-base/nodes/ExecuteWorkflow/GenericFunctions.ts @@ -1,67 +1,171 @@ -import { readFile as fsReadFile } from 'fs/promises'; -import { NodeOperationError, jsonParse } from 'n8n-workflow'; +import { json as generateSchemaFromExample, type SchemaObject } from 'generate-schema'; +import type { JSONSchema7 } from 'json-schema'; import type { + FieldValueOption, + FieldType, + IWorkflowNodeContext, + INodeExecutionData, IExecuteFunctions, - IExecuteWorkflowInfo, - INodeParameterResourceLocator, - IRequestOptions, } from 'n8n-workflow'; +import { jsonParse, NodeOperationError, validateFieldType } from 'n8n-workflow'; -export async function getWorkflowInfo(this: IExecuteFunctions, source: string, itemIndex = 0) { - const workflowInfo: IExecuteWorkflowInfo = {}; - const nodeVersion = this.getNode().typeVersion; - if (source === 'database') { - // Read workflow from database - if (nodeVersion === 1) { - workflowInfo.id = this.getNodeParameter('workflowId', itemIndex) as string; - } else { - const { value } = this.getNodeParameter( - 'workflowId', - itemIndex, - {}, - ) as INodeParameterResourceLocator; - workflowInfo.id = value as string; - } - } else if (source === 'localFile') { - // Read workflow from filesystem - const workflowPath = this.getNodeParameter('workflowPath', itemIndex) as string; +import { + JSON_EXAMPLE, + INPUT_SOURCE, + WORKFLOW_INPUTS, + VALUES, + TYPE_OPTIONS, + INPUT_OPTIONS, + FALLBACK_DEFAULT_VALUE, +} from './constants'; - let workflowJson; - try { - workflowJson = await fsReadFile(workflowPath, { encoding: 'utf8' }); - } catch (error) { - if (error.code === 'ENOENT') { - throw new NodeOperationError( - this.getNode(), - `The file "${workflowPath}" could not be found, [item ${itemIndex}]`, - ); - } +const SUPPORTED_TYPES = TYPE_OPTIONS.map((x) => x.value); - throw error; - } - - workflowInfo.code = jsonParse(workflowJson); - } else if (source === 'parameter') { - // Read workflow from parameter - const workflowJson = this.getNodeParameter('workflowJson', itemIndex) as string; - workflowInfo.code = jsonParse(workflowJson); - } else if (source === 'url') { - // Read workflow from url - const workflowUrl = this.getNodeParameter('workflowUrl', itemIndex) as string; - - const requestOptions = { - headers: { - accept: 'application/json,text/*;q=0.99', - }, - method: 'GET', - uri: workflowUrl, - json: true, - gzip: true, - } satisfies IRequestOptions; - - const response = await this.helpers.request(requestOptions); - workflowInfo.code = response; +function parseJsonSchema(schema: JSONSchema7): FieldValueOption[] | string { + if (!schema?.properties) { + return 'Invalid JSON schema. Missing key `properties` in schema'; } - return workflowInfo; + if (typeof schema.properties !== 'object') { + return 'Invalid JSON schema. Key `properties` is not an object'; + } + + const result: FieldValueOption[] = []; + for (const [name, v] of Object.entries(schema.properties)) { + if (typeof v !== 'object') { + return `Invalid JSON schema. Value for property '${name}' is not an object`; + } + + const type = v?.type; + + if (type === 'null') { + result.push({ name, type: 'any' }); + } else if (Array.isArray(type)) { + // Schema allows an array of types, but we don't + return `Invalid JSON schema. Array of types for property '${name}' is not supported by n8n. Either provide a single type or use type 'any' to allow any type`; + } else if (typeof type !== 'string') { + return `Invalid JSON schema. Unexpected non-string type ${type} for property '${name}'`; + } else if (!SUPPORTED_TYPES.includes(type as never)) { + return `Invalid JSON schema. Unsupported type ${type} for property '${name}'. Supported types are ${JSON.stringify(SUPPORTED_TYPES, null, 1)}`; + } else { + result.push({ name, type: type as FieldType }); + } + } + return result; +} + +function parseJsonExample(context: IWorkflowNodeContext): JSONSchema7 { + const jsonString = context.getNodeParameter(JSON_EXAMPLE, 0, '') as string; + const json = jsonParse(jsonString); + + return generateSchemaFromExample(json) as JSONSchema7; +} + +export function getFieldEntries(context: IWorkflowNodeContext): FieldValueOption[] { + const inputSource = context.getNodeParameter(INPUT_SOURCE, 0); + let result: FieldValueOption[] | string = 'Internal Error: Invalid input source'; + try { + if (inputSource === WORKFLOW_INPUTS) { + result = context.getNodeParameter( + `${WORKFLOW_INPUTS}.${VALUES}`, + 0, + [], + ) as FieldValueOption[]; + } else if (inputSource === JSON_EXAMPLE) { + const schema = parseJsonExample(context); + result = parseJsonSchema(schema); + } + } catch (e: unknown) { + result = + e && typeof e === 'object' && 'message' in e && typeof e.message === 'string' + ? e.message + : `Unknown error occurred: ${JSON.stringify(e)}`; + } + + if (Array.isArray(result)) { + return result; + } + throw new NodeOperationError(context.getNode(), result); +} + +export function getWorkflowInputData( + this: IExecuteFunctions, + inputData: INodeExecutionData[], + newParams: FieldValueOption[], +): INodeExecutionData[] { + const items: INodeExecutionData[] = []; + + for (const [itemIndex, item] of inputData.entries()) { + const attemptToConvertTypes = this.getNodeParameter( + `${INPUT_OPTIONS}.attemptToConvertTypes`, + itemIndex, + false, + ); + const ignoreTypeErrors = this.getNodeParameter( + `${INPUT_OPTIONS}.ignoreTypeErrors`, + itemIndex, + false, + ); + + // Fields listed here will explicitly overwrite original fields + const newItem: INodeExecutionData = { + json: {}, + index: itemIndex, + // TODO: Ensure we handle sub-execution jumps correctly. + // metadata: { + // subExecution: { + // executionId: 'uhh', + // workflowId: 'maybe?', + // }, + // }, + pairedItem: { item: itemIndex }, + }; + try { + for (const { name, type } of newParams) { + if (!item.json.hasOwnProperty(name)) { + newItem.json[name] = FALLBACK_DEFAULT_VALUE; + continue; + } + + const result = + type === 'any' + ? ({ valid: true, newValue: item.json[name] } as const) + : validateFieldType(name, item.json[name], type, { + strict: !attemptToConvertTypes, + parseStrings: true, // Default behavior is to accept anything as a string, this is a good opportunity for a stricter boundary + }); + + if (!result.valid) { + if (ignoreTypeErrors) { + newItem.json[name] = item.json[name]; + continue; + } + + throw new NodeOperationError(this.getNode(), result.errorMessage, { + itemIndex, + }); + } else { + // If the value is `null` or `undefined`, then `newValue` is not in the returned object + if (result.hasOwnProperty('newValue')) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + newItem.json[name] = result.newValue; + } else { + newItem.json[name] = item.json[name]; + } + } + } + + items.push(newItem); + } catch (error) { + if (this.continueOnFail()) { + /** todo error case? */ + } else { + throw new NodeOperationError(this.getNode(), error, { + itemIndex, + }); + } + } + } + + return items; } diff --git a/packages/nodes-base/nodes/ExecuteWorkflow/constants.ts b/packages/nodes-base/nodes/ExecuteWorkflow/constants.ts new file mode 100644 index 0000000000..7f54f28809 --- /dev/null +++ b/packages/nodes-base/nodes/ExecuteWorkflow/constants.ts @@ -0,0 +1,36 @@ +import type { FieldType } from 'n8n-workflow'; + +export const INPUT_SOURCE = 'inputSource'; +export const WORKFLOW_INPUTS = 'workflowInputs'; +export const INPUT_OPTIONS = 'inputOptions'; +export const VALUES = 'values'; +export const JSON_EXAMPLE = 'jsonExample'; +export const TYPE_OPTIONS: Array<{ name: string; value: FieldType | 'any' }> = [ + { + name: 'Allow Any Type', + value: 'any', + }, + { + name: 'String', + value: 'string', + }, + { + name: 'Number', + value: 'number', + }, + { + name: 'Boolean', + value: 'boolean', + }, + { + name: 'Array', + value: 'array', + }, + { + name: 'Object', + value: 'object', + }, + // Intentional omission of `dateTime`, `time`, `string-alphanumeric`, `form-fields`, `jwt` and `url` +]; + +export const FALLBACK_DEFAULT_VALUE = null; diff --git a/packages/nodes-base/nodes/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.ts b/packages/nodes-base/nodes/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.ts deleted file mode 100644 index 726ab0b948..0000000000 --- a/packages/nodes-base/nodes/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { json as generateSchemaFromExample, type SchemaObject } from 'generate-schema'; -import type { JSONSchema7 } from 'json-schema'; -import { - type INodeExecutionData, - NodeConnectionType, - NodeOperationError, - type IExecuteFunctions, - type INodeType, - type INodeTypeDescription, - validateFieldType, - type FieldType, - jsonParse, -} from 'n8n-workflow'; - -const INPUT_SOURCE = 'inputSource'; -const WORKFLOW_INPUTS = 'workflowInputs'; -const INPUT_OPTIONS = 'inputOptions'; -const VALUES = 'values'; -const JSON_EXAMPLE = 'jsonExample'; -const TYPE_OPTIONS: Array<{ name: string; value: FieldType | 'any' }> = [ - { - name: 'Allow Any Type', - value: 'any', - }, - { - name: 'String', - value: 'string', - }, - { - name: 'Number', - value: 'number', - }, - { - name: 'Boolean', - value: 'boolean', - }, - { - name: 'Array', - value: 'array', - }, - { - name: 'Object', - value: 'object', - }, - // Intentional omission of `dateTime`, `time`, `string-alphanumeric`, `form-fields`, `jwt` and `url` -]; -const SUPPORTED_TYPES = TYPE_OPTIONS.map((x) => x.value); - -const DEFAULT_PLACEHOLDER = null; - -type ValueOptions = { name: string; type: FieldType | 'any' }; - -function parseJsonSchema(schema: JSONSchema7): ValueOptions[] | string { - if (!schema?.properties) { - return 'Invalid JSON schema. Missing key `properties` in schema'; - } - - if (typeof schema.properties !== 'object') { - return 'Invalid JSON schema. Key `properties` is not an object'; - } - - const result: ValueOptions[] = []; - for (const [name, v] of Object.entries(schema.properties)) { - if (typeof v !== 'object') { - return `Invalid JSON schema. Value for property '${name}' is not an object`; - } - - const type = v?.type; - - if (type === 'null') { - result.push({ name, type: 'any' }); - } else if (Array.isArray(type)) { - // Schema allows an array of types, but we don't - return `Invalid JSON schema. Array of types for property '${name}' is not supported by n8n. Either provide a single type or use type 'any' to allow any type`; - } else if (typeof type !== 'string') { - return `Invalid JSON schema. Unexpected non-string type ${type} for property '${name}'`; - } else if (!SUPPORTED_TYPES.includes(type as never)) { - return `Invalid JSON schema. Unsupported type ${type} for property '${name}'. Supported types are ${JSON.stringify(SUPPORTED_TYPES, null, 1)}`; - } else { - result.push({ name, type: type as FieldType }); - } - } - return result; -} - -function parseJsonExample(context: IExecuteFunctions): JSONSchema7 { - const jsonString = context.getNodeParameter(JSON_EXAMPLE, 0, '') as string; - const json = jsonParse(jsonString); - - return generateSchemaFromExample(json) as JSONSchema7; -} - -function getFieldEntries(context: IExecuteFunctions): ValueOptions[] { - const inputSource = context.getNodeParameter(INPUT_SOURCE, 0) as string; - let result: ValueOptions[] | string = 'Internal Error: Invalid input source'; - try { - if (inputSource === WORKFLOW_INPUTS) { - result = context.getNodeParameter(`${WORKFLOW_INPUTS}.${VALUES}`, 0, []) as Array<{ - name: string; - type: FieldType; - }>; - } else if (inputSource === JSON_EXAMPLE) { - const schema = parseJsonExample(context); - result = parseJsonSchema(schema); - } - } catch (e: unknown) { - result = - e && typeof e === 'object' && 'message' in e && typeof e.message === 'string' - ? e.message - : `Unknown error occurred: ${JSON.stringify(e)}`; - } - - if (Array.isArray(result)) { - return result; - } - throw new NodeOperationError(context.getNode(), result); -} - -export class ExecuteWorkflowTrigger implements INodeType { - description: INodeTypeDescription = { - displayName: 'Execute Workflow Trigger', - name: 'executeWorkflowTrigger', - icon: 'fa:sign-out-alt', - group: ['trigger'], - version: [1, 1.1], - description: - 'Helpers for calling other n8n workflows. Used for designing modular, microservice-like workflows.', - eventTriggerDescription: '', - maxNodes: 1, - defaults: { - name: 'Workflow Input Trigger', - color: '#ff6d5a', - }, - inputs: [], - outputs: [NodeConnectionType.Main], - hints: [ - { - message: - 'You need to define your input fields explicitly. Otherwise the parent cannot provide data and you will not receive input data.', - // This condition checks if we have no input fields, which gets a bit awkward: - // For WORKFLOW_INPUTS: keys() only contains `VALUES` if at least one value is provided - // For JSON_EXAMPLE: We remove all whitespace and check if we're left with an empty object. Note that we already error if the example is not valid JSON - displayCondition: - `={{$parameter['${INPUT_SOURCE}'] === '${WORKFLOW_INPUTS}' && !$parameter['${WORKFLOW_INPUTS}'].keys().length ` + - `|| $parameter['${INPUT_SOURCE}'] === '${JSON_EXAMPLE}' && $parameter['${JSON_EXAMPLE}'].toString().replaceAll(' ', '').replaceAll('\\n', '') === '{}' }}`, - whenToDisplay: 'always', - location: 'ndv', - }, - ], - properties: [ - { - displayName: 'Events', - name: 'events', - type: 'hidden', - noDataExpression: true, - options: [ - { - name: 'Workflow Call', - value: 'worklfow_call', - description: 'When called by another workflow using Execute Workflow Trigger', - action: 'When Called by Another Workflow', - }, - ], - default: 'worklfow_call', - }, - { - displayName: 'Input Source', - name: INPUT_SOURCE, - type: 'options', - options: [ - { - name: 'Using Fields Below', - value: WORKFLOW_INPUTS, - description: 'Provide via UI', - }, - { - name: 'Using JSON Example', - value: JSON_EXAMPLE, - description: 'Infer JSON schema via JSON example output', - }, - ], - default: WORKFLOW_INPUTS, - noDataExpression: true, - displayOptions: { - show: { '@version': [{ _cnd: { gte: 1.1 } }] }, - }, - }, - { - displayName: - 'Provide an example object to infer fields and their types.
To allow any type for a given field, set the value to null.', - name: `${JSON_EXAMPLE}_notice`, - type: 'notice', - default: '', - displayOptions: { - show: { '@version': [{ _cnd: { gte: 1.1 } }], inputSource: [JSON_EXAMPLE] }, - }, - }, - { - displayName: 'JSON Example', - name: JSON_EXAMPLE, - type: 'json', - default: JSON.stringify( - { - aField: 'a string', - aNumber: 123, - thisFieldAcceptsAnyType: null, - anArray: [], - }, - null, - 2, - ), - noDataExpression: true, - displayOptions: { - show: { '@version': [{ _cnd: { gte: 1.1 } }], inputSource: [JSON_EXAMPLE] }, - }, - }, - { - displayName: 'Workflow Inputs', - name: WORKFLOW_INPUTS, - placeholder: 'Add Field', - type: 'fixedCollection', - description: - 'Define expected input fields. If no inputs are provided, all data from the calling workflow will be passed through.', - typeOptions: { - multipleValues: true, - sortable: true, - }, - displayOptions: { - show: { '@version': [{ _cnd: { gte: 1.1 } }], inputSource: [WORKFLOW_INPUTS] }, - }, - default: {}, - options: [ - { - name: VALUES, - displayName: 'Values', - values: [ - { - displayName: 'Name', - name: 'name', - type: 'string', - default: '', - placeholder: 'e.g. fieldName', - description: 'Name of the field', - noDataExpression: true, - }, - { - displayName: 'Type', - name: 'type', - type: 'options', - description: 'The field value type', - // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items - options: TYPE_OPTIONS, - default: 'string', - noDataExpression: true, - }, - ], - }, - ], - }, - { - displayName: 'Input Options', - name: INPUT_OPTIONS, - placeholder: 'Options', - type: 'collection', - description: 'Options controlling how input data is handled, converted and rejected', - displayOptions: { - show: { '@version': [{ _cnd: { gte: 1.1 } }] }, - }, - default: {}, - // Note that, while the defaults are true, the user has to add these in the first place - // We default to false if absent in the execute function below - options: [ - { - displayName: 'Attempt to Convert Types', - name: 'attemptToConvertTypes', - type: 'boolean', - default: true, - description: - 'Whether to attempt conversion on type mismatch, rather than directly returning an Error', - noDataExpression: true, - }, - { - displayName: 'Ignore Type Mismatch Errors', - name: 'ignoreTypeErrors', - type: 'boolean', - default: true, - description: - 'Whether type mismatches should be ignored, rather than returning an Error', - noDataExpression: true, - }, - ], - }, - ], - }; - - async execute(this: IExecuteFunctions) { - const inputData = this.getInputData(); - - if (this.getNode().typeVersion < 1.1) { - return [inputData]; - } else { - const items: INodeExecutionData[] = []; - - for (const [itemIndex, item] of inputData.entries()) { - const attemptToConvertTypes = this.getNodeParameter( - `${INPUT_OPTIONS}.attemptToConvertTypes`, - itemIndex, - false, - ); - const ignoreTypeErrors = this.getNodeParameter( - `${INPUT_OPTIONS}.ignoreTypeErrors`, - itemIndex, - false, - ); - - // Fields listed here will explicitly overwrite original fields - const newItem: INodeExecutionData = { - json: {}, - index: itemIndex, - // TODO: Ensure we handle sub-execution jumps correctly. - // metadata: { - // subExecution: { - // executionId: 'uhh', - // workflowId: 'maybe?', - // }, - // }, - pairedItem: { item: itemIndex }, - }; - try { - const newParams = getFieldEntries(this); - - for (const { name, type } of newParams) { - if (!item.json.hasOwnProperty(name)) { - newItem.json[name] = DEFAULT_PLACEHOLDER; - continue; - } - - const result = - type === 'any' - ? ({ valid: true, newValue: item.json[name] } as const) - : validateFieldType(name, item.json[name], type, { - strict: !attemptToConvertTypes, - parseStrings: true, // Default behavior is to accept anything as a string, this is a good opportunity for a stricter boundary - }); - - if (!result.valid) { - if (ignoreTypeErrors) { - newItem.json[name] = item.json[name]; - continue; - } - - throw new NodeOperationError(this.getNode(), result.errorMessage, { - itemIndex, - }); - } else { - // If the value is `null` or `undefined`, then `newValue` is not in the returned object - if (result.hasOwnProperty('newValue')) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - newItem.json[name] = result.newValue; - } else { - newItem.json[name] = item.json[name]; - } - } - } - - items.push(newItem); - } catch (error) { - if (this.continueOnFail()) { - /** todo error case? */ - } else { - throw new NodeOperationError(this.getNode(), error, { - itemIndex, - }); - } - } - } - - return [items]; - } - } -} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 24ea28f1ad..565f6d0900 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -492,8 +492,8 @@ "dist/nodes/ErrorTrigger/ErrorTrigger.node.js", "dist/nodes/Eventbrite/EventbriteTrigger.node.js", "dist/nodes/ExecuteCommand/ExecuteCommand.node.js", - "dist/nodes/ExecuteWorkflow/ExecuteWorkflow.node.js", - "dist/nodes/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.js", + "dist/nodes/ExecuteWorkflow/ExecuteWorkflow/ExecuteWorkflow.node.js", + "dist/nodes/ExecuteWorkflow/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.js", "dist/nodes/ExecutionData/ExecutionData.node.js", "dist/nodes/Facebook/FacebookGraphApi.node.js", "dist/nodes/Facebook/FacebookTrigger.node.js", diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 431730b5db..7caa20ad68 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1068,9 +1068,23 @@ export interface ILoadOptionsFunctions extends FunctionsBase { options?: IGetNodeParameterOptions, ): NodeParameterValueType | object | undefined; getCurrentNodeParameters(): INodeParameters | undefined; + helpers: RequestHelperFunctions & SSHTunnelFunctions; } +export type FieldValueOption = { name: string; type: FieldType | 'any' }; + +export type IWorkflowNodeContext = ExecuteFunctions.GetNodeParameterFn & + Pick; + +export interface ILocalLoadOptionsFunctions { + getWorkflowNodeContext(nodeType: string): Promise; +} + +export interface IWorkflowLoader { + get(workflowId: string): Promise; +} + export interface IPollFunctions extends FunctionsBaseWithRequiredKeys<'getMode' | 'getActivationMode'> { __emit( @@ -1351,11 +1365,13 @@ export interface INodePropertyTypeOptions { [key: string]: any; } -export interface ResourceMapperTypeOptions { - resourceMapperMethod: string; +export interface ResourceMapperTypeOptionsBase { mode: 'add' | 'update' | 'upsert'; valuesLabel?: string; - fieldWords?: { singular: string; plural: string }; + fieldWords?: { + singular: string; + plural: string; + }; addAllFields?: boolean; noFieldsError?: string; multiKeyMatch?: boolean; @@ -1367,6 +1383,20 @@ export interface ResourceMapperTypeOptions { }; } +// Enforce at least one of resourceMapperMethod or localResourceMapperMethod +export type ResourceMapperTypeOptionsLocal = { + resourceMapperMethod: string; + localResourceMapperMethod?: never; // Explicitly disallows this property +}; + +export type ResourceMapperTypeOptionsExternal = { + localResourceMapperMethod: string; + resourceMapperMethod?: never; // Explicitly disallows this property +}; + +export type ResourceMapperTypeOptions = ResourceMapperTypeOptionsBase & + (ResourceMapperTypeOptionsLocal | ResourceMapperTypeOptionsExternal); + type NonEmptyArray = [T, ...T[]]; export type FilterTypeCombinator = 'and' | 'or'; @@ -1637,6 +1667,9 @@ export interface INodeType { resourceMapping?: { [functionName: string]: (this: ILoadOptionsFunctions) => Promise; }; + localResourceMapping?: { + [functionName: string]: (this: ILocalLoadOptionsFunctions) => Promise; + }; actionHandler?: { [functionName: string]: ( this: ILoadOptionsFunctions,