Ado-2898-execute-workflow-node-add-workflow-inputs-parameter (#11887)

Co-authored-by: Charlie Kolb <charlie@n8n.io>
This commit is contained in:
Ivan Atanasov 2024-12-06 11:30:44 +01:00 committed by GitHub
parent 8e374b4394
commit c31bf0a4d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 872 additions and 470 deletions

View file

@ -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,

View file

@ -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<ResourceMapperFields>;
type ListSearchMethod = (
this: ILoadOptionsFunctions,
filter?: string,
paginationToken?: string,
) => Promise<INodeListSearchResult>;
type LoadOptionsMethod = (this: ILoadOptionsFunctions) => Promise<INodePropertyOptions[]>;
type ActionHandlerMethod = (
this: ILoadOptionsFunctions,
payload?: string,
) => Promise<NodeParameterValueType>;
type ResourceMappingMethod = (this: ILoadOptionsFunctions) => Promise<ResourceMapperFields>;
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<ResourceMapperFields> {
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<ResourceMapperFields>;
): ResourceMappingMethod;
private getMethod(
type: 'listSearch',
type: 'localResourceMapping',
methodName: string,
nodeType: INodeType,
): (
this: ILoadOptionsFunctions,
filter?: string | undefined,
paginationToken?: string | undefined,
) => Promise<INodeListSearchResult>;
): LocalResourceMappingMethod;
private getMethod(type: 'listSearch', methodName: string, nodeType: INodeType): ListSearchMethod;
private getMethod(
type: 'loadOptions',
methodName: string,
nodeType: INodeType,
): (this: ILoadOptionsFunctions) => Promise<INodePropertyOptions[]>;
): LoadOptionsMethod;
private getMethod(
type: 'actionHandler',
methodName: string,
nodeType: INodeType,
): (this: ILoadOptionsFunctions, payload?: string) => Promise<NodeParameterValueType>;
): 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,
);
}
}

View file

@ -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<IWorkflowBase> {
const workflow = await this.workflowRepository.findById(workflowId);
if (!workflow) {
throw new ApplicationError(`Failed to find workflow with ID "${workflowId}"`);
}
return workflow;
}
}

View file

@ -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';

View file

@ -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<IWorkflowNodeContext | null> {
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;
}
}

View file

@ -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'];
}
}
}

View file

@ -59,6 +59,18 @@ export async function getResourceMapperFields(
);
}
export async function getLocalResourceMapperFields(
context: IRestApiContext,
sendData: DynamicNodeParameters.ResourceMapperFieldsRequest,
): Promise<ResourceMapperFields> {
return await makeRestApiRequest(
context,
'POST',
'/dynamic-node-parameters/local-resource-mapper-fields',
sendData,
);
}
export async function getNodeParameterActionResult(
context: IRestApiContext,
sendData: DynamicNodeParameters.ActionResultRequest,

View file

@ -239,20 +239,14 @@ async function initFetching(inlineLoading = false): Promise<void> {
}
}
async function loadFieldsToMap(): Promise<void> {
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<void> {
methodName,
credentials: props.node.credentials,
};
const fetchedFields = await nodeTypesStore.getResourceMapperFields(requestParams);
return requestParams;
};
async function loadFieldsToMap(): Promise<void> {
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);

View file

@ -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,

View file

@ -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<INodeExecutionData[][]> {
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;

View file

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

View file

@ -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<ResourceMapperFields> {
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 };
}

View file

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

View file

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

View file

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

View file

@ -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<SchemaObject>(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.<br>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];
}
}
}

View file

@ -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",

View file

@ -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<FunctionsBase, 'getNode'>;
export interface ILocalLoadOptionsFunctions {
getWorkflowNodeContext(nodeType: string): Promise<IWorkflowNodeContext | null>;
}
export interface IWorkflowLoader {
get(workflowId: string): Promise<IWorkflowBase>;
}
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, ...T[]];
export type FilterTypeCombinator = 'and' | 'or';
@ -1637,6 +1667,9 @@ export interface INodeType {
resourceMapping?: {
[functionName: string]: (this: ILoadOptionsFunctions) => Promise<ResourceMapperFields>;
};
localResourceMapping?: {
[functionName: string]: (this: ILocalLoadOptionsFunctions) => Promise<ResourceMapperFields>;
};
actionHandler?: {
[functionName: string]: (
this: ILoadOptionsFunctions,