diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index da4535ba4c..8cb6f97f84 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -31,6 +31,7 @@ import clientOAuth1 from 'oauth-1.0a'; import { BinaryDataManager, Credentials, + LoadMappingOptions, LoadNodeParameterOptions, LoadNodeListSearch, UserSettings, @@ -49,6 +50,7 @@ import type { ICredentialTypes, ExecutionStatus, IExecutionsSummary, + ResourceMapperFields, IN8nUISettings, } from 'n8n-workflow'; import { LoggerProxy, jsonParse } from 'n8n-workflow'; @@ -79,6 +81,7 @@ import type { NodeListSearchRequest, NodeParameterOptionsRequest, OAuthRequest, + ResourceMapperRequest, WorkflowRequest, } from '@/requests'; import { registerController } from '@/decorators'; @@ -756,6 +759,58 @@ export class Server extends AbstractServer { ), ); + this.app.get( + `/${this.restEndpoint}/get-mapping-fields`, + ResponseHelper.send( + async ( + req: ResourceMapperRequest, + res: express.Response, + ): Promise => { + const nodeTypeAndVersion = jsonParse( + req.query.nodeTypeAndVersion, + ) as INodeTypeNameVersion; + + const { path, methodName } = req.query; + + if (!req.query.currentNodeParameters) { + throw new ResponseHelper.BadRequestError( + 'Parameter currentNodeParameters is required.', + ); + } + + const currentNodeParameters = jsonParse( + req.query.currentNodeParameters, + ) as INodeParameters; + + let credentials: INodeCredentials | undefined; + + if (req.query.credentials) { + credentials = jsonParse(req.query.credentials); + } + + const loadMappingOptionsInstance = new LoadMappingOptions( + nodeTypeAndVersion, + this.nodeTypes, + path, + currentNodeParameters, + credentials, + ); + + const additionalData = await WorkflowExecuteAdditionalData.getBase( + req.user.id, + currentNodeParameters, + ); + + const fields = await loadMappingOptionsInstance.getOptionsViaMethodName( + methodName, + additionalData, + ); + + return fields; + }, + ), + ); + // ---------------------------------------- // Active Workflows // ---------------------------------------- diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index a18e8b0f26..498d56c392 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -348,6 +348,23 @@ export type NodeListSearchRequest = AuthenticatedRequest< } >; +// ---------------------------------- +// /get-mapping-fields +// ---------------------------------- + +export type ResourceMapperRequest = AuthenticatedRequest< + {}, + {}, + {}, + { + nodeTypeAndVersion: string; + methodName: string; + path: string; + currentNodeParameters: string; + credentials: string; + } +>; + // ---------------------------------- // /tags // ---------------------------------- diff --git a/packages/core/src/Interfaces.ts b/packages/core/src/Interfaces.ts index f9e2aec74c..0ec0017820 100644 --- a/packages/core/src/Interfaces.ts +++ b/packages/core/src/Interfaces.ts @@ -11,6 +11,7 @@ import type { ITriggerFunctions as ITriggerFunctionsBase, IWebhookFunctions as IWebhookFunctionsBase, BinaryMetadata, + ValidationResult, } from 'n8n-workflow'; // TODO: remove these after removing `n8n-core` dependency from `nodes-bases` @@ -89,3 +90,5 @@ export namespace n8n { }; } } + +export type ExtendedValidationResult = Partial & { fieldName?: string }; diff --git a/packages/core/src/LoadMappingOptions.ts b/packages/core/src/LoadMappingOptions.ts new file mode 100644 index 0000000000..614fe75efc --- /dev/null +++ b/packages/core/src/LoadMappingOptions.ts @@ -0,0 +1,34 @@ +import type { IWorkflowExecuteAdditionalData, ResourceMapperFields } from 'n8n-workflow'; + +import * as NodeExecuteFunctions from './NodeExecuteFunctions'; +import { LoadNodeDetails } from './LoadNodeDetails'; + +export class LoadMappingOptions extends LoadNodeDetails { + /** + * Returns the available mapping fields for the ResourceMapper component + */ + async getOptionsViaMethodName( + methodName: string, + additionalData: IWorkflowExecuteAdditionalData, + ): Promise { + const node = this.getTempNode(); + + const nodeType = this.workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); + const method = nodeType?.methods?.resourceMapping?.[methodName]; + + if (typeof method !== 'function') { + throw new Error( + `The node-type "${node.type}" does not have the method "${methodName}" defined!`, + ); + } + + const thisArgs = NodeExecuteFunctions.getLoadOptionsFunctions( + this.workflow, + node, + this.path, + additionalData, + ); + + return method.call(thisArgs); + } +} diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 6bd3f5433b..996389c9f0 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -61,10 +61,12 @@ import type { IWebhookFunctions, BinaryMetadata, FileSystemHelperFunctions, + INodeType, } from 'n8n-workflow'; import { createDeferredPromise, isObjectEmpty, + isResourceMapperValue, NodeApiError, NodeHelpers, NodeOperationError, @@ -74,6 +76,7 @@ import { deepCopy, fileTypeFromMimeType, ExpressionError, + validateFieldType, } from 'n8n-workflow'; import pick from 'lodash.pick'; @@ -114,7 +117,7 @@ import { access as fsAccess } from 'fs/promises'; import { createReadStream } from 'fs'; import { BinaryDataManager } from './BinaryDataManager'; -import type { IResponseError, IWorkflowSettings } from './Interfaces'; +import type { ExtendedValidationResult, IResponseError, IWorkflowSettings } from './Interfaces'; import { extractValue } from './ExtractValue'; import { getClientCredentialsToken } from './OAuth2Helper'; import { PLACEHOLDER_EMPTY_EXECUTION_ID } from './Constants'; @@ -1867,7 +1870,7 @@ function cleanupParameterData(inputData: NodeParameterValueType): void { } if (Array.isArray(inputData)) { - inputData.forEach((value) => cleanupParameterData(value)); + inputData.forEach((value) => cleanupParameterData(value as NodeParameterValueType)); return; } @@ -1886,6 +1889,103 @@ function cleanupParameterData(inputData: NodeParameterValueType): void { } } +const validateResourceMapperValue = ( + parameterName: string, + paramValues: { [key: string]: unknown }, + node: INode, + skipRequiredCheck = false, +): ExtendedValidationResult => { + const result: ExtendedValidationResult = { valid: true, newValue: paramValues }; + const paramNameParts = parameterName.split('.'); + if (paramNameParts.length !== 2) { + return result; + } + const resourceMapperParamName = paramNameParts[0]; + const resourceMapperField = node.parameters[resourceMapperParamName]; + if (!resourceMapperField || !isResourceMapperValue(resourceMapperField)) { + return result; + } + const schema = resourceMapperField.schema; + const paramValueNames = Object.keys(paramValues); + for (let i = 0; i < paramValueNames.length; i++) { + const key = paramValueNames[i]; + const resolvedValue = paramValues[key]; + const schemaEntry = schema.find((s) => s.id === key); + + if ( + !skipRequiredCheck && + schemaEntry?.required === true && + schemaEntry.type !== 'boolean' && + !resolvedValue + ) { + return { + valid: false, + errorMessage: `The value "${String(key)}" is required but not set`, + fieldName: key, + }; + } + + if (schemaEntry?.type) { + const validationResult = validateFieldType( + key, + resolvedValue, + schemaEntry.type, + schemaEntry.options, + ); + if (!validationResult.valid) { + return { ...validationResult, fieldName: key }; + } else { + // If it's valid, set the casted value + paramValues[key] = validationResult.newValue; + } + } + } + return result; +}; + +const validateValueAgainstSchema = ( + node: INode, + nodeType: INodeType, + inputValues: string | number | boolean | object | null | undefined, + parameterName: string, + runIndex: number, + itemIndex: number, +) => { + let validationResult: ExtendedValidationResult = { valid: true, newValue: inputValues }; + // Currently only validate resource mapper values + const resourceMapperField = nodeType.description.properties.find( + (prop) => + NodeHelpers.displayParameter(node.parameters, prop, node) && + prop.type === 'resourceMapper' && + parameterName === `${prop.name}.value`, + ); + + if (resourceMapperField && typeof inputValues === 'object') { + validationResult = validateResourceMapperValue( + parameterName, + inputValues as { [key: string]: unknown }, + node, + resourceMapperField.typeOptions?.resourceMapper?.mode !== 'add', + ); + } + + if (!validationResult.valid) { + throw new ExpressionError( + `Invalid input for '${ + String(validationResult.fieldName) || parameterName + }' [item ${itemIndex}]`, + { + description: validationResult.errorMessage, + failExecution: true, + runIndex, + itemIndex, + nodeCause: node.name, + }, + ); + } + return validationResult.newValue; +}; + /** * Returns the requested resolved (all expressions replaced) node parameters. * @@ -1947,6 +2047,16 @@ export function getNodeParameter( returnData = extractValue(returnData, parameterName, node, nodeType); } + // Validate parameter value if it has a schema defined + returnData = validateValueAgainstSchema( + node, + nodeType, + returnData, + parameterName, + runIndex, + itemIndex, + ); + return returnData; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 61cca27d8e..516c30282e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,6 +9,7 @@ export * from './Constants'; export * from './Credentials'; export * from './DirectoryLoader'; export * from './Interfaces'; +export * from './LoadMappingOptions'; export * from './LoadNodeParameterOptions'; export * from './LoadNodeListSearch'; export * from './NodeExecuteFunctions'; diff --git a/packages/design-system/src/components/N8nActionToggle/ActionToggle.stories.ts b/packages/design-system/src/components/N8nActionToggle/ActionToggle.stories.ts index f3f3c03a51..2cf094b4db 100644 --- a/packages/design-system/src/components/N8nActionToggle/ActionToggle.stories.ts +++ b/packages/design-system/src/components/N8nActionToggle/ActionToggle.stories.ts @@ -8,7 +8,7 @@ export default { argTypes: { placement: { type: 'select', - options: ['top', 'top-start', 'top-end', 'bottom', 'bottom-end'], + options: ['top', 'top-end', 'top-start', 'bottom', 'bottom-end', 'bottom-start'], }, size: { type: 'select', diff --git a/packages/design-system/src/components/N8nActionToggle/ActionToggle.vue b/packages/design-system/src/components/N8nActionToggle/ActionToggle.vue index c617ace762..471c1e3dc0 100644 --- a/packages/design-system/src/components/N8nActionToggle/ActionToggle.vue +++ b/packages/design-system/src/components/N8nActionToggle/ActionToggle.vue @@ -9,7 +9,10 @@ @visible-change="onVisibleChange" > - +