From 929315f9e4b56d975783b7d069bdb163218aa7d5 Mon Sep 17 00:00:00 2001 From: Mike Arvela Date: Tue, 27 Sep 2022 12:05:51 +0300 Subject: [PATCH] feat(n8nApi node): add core node for consuming the n8n API (#4076) * feat(n8n node): create n8n core node for consuming the n8n API Co-authored-by: Michael Kret --- .../credentials/N8nApi.credentials.ts | 43 +++ .../nodes/N8n/CredentialDescription.ts | 168 +++++++++ .../nodes/N8n/ExecutionDescription.ts | 252 ++++++++++++++ .../nodes-base/nodes/N8n/GenericFunctions.ts | 209 ++++++++++++ packages/nodes-base/nodes/N8n/N8n.node.json | 22 ++ packages/nodes-base/nodes/N8n/N8n.node.ts | 80 +++++ .../nodes/N8n/WorkflowDescription.ts | 323 ++++++++++++++++++ .../nodes-base/nodes/N8n/WorkflowLocator.ts | 106 ++++++ packages/nodes-base/nodes/N8n/n8n.svg | 1 + packages/nodes-base/package.json | 2 + 10 files changed, 1206 insertions(+) create mode 100644 packages/nodes-base/credentials/N8nApi.credentials.ts create mode 100644 packages/nodes-base/nodes/N8n/CredentialDescription.ts create mode 100644 packages/nodes-base/nodes/N8n/ExecutionDescription.ts create mode 100644 packages/nodes-base/nodes/N8n/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/N8n/N8n.node.json create mode 100644 packages/nodes-base/nodes/N8n/N8n.node.ts create mode 100644 packages/nodes-base/nodes/N8n/WorkflowDescription.ts create mode 100644 packages/nodes-base/nodes/N8n/WorkflowLocator.ts create mode 100644 packages/nodes-base/nodes/N8n/n8n.svg diff --git a/packages/nodes-base/credentials/N8nApi.credentials.ts b/packages/nodes-base/credentials/N8nApi.credentials.ts new file mode 100644 index 0000000000..72a9583b48 --- /dev/null +++ b/packages/nodes-base/credentials/N8nApi.credentials.ts @@ -0,0 +1,43 @@ +import { + IAuthenticateGeneric, + ICredentialTestRequest, + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class N8nApi implements ICredentialType { + name = 'n8nApi'; + displayName = 'n8n API'; + documentationUrl = 'n8nApi'; + properties: INodeProperties[] = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string', + default: '', + description: 'The API key for the n8n instance', + }, + { + displayName: 'Base URL', + name: 'baseUrl', + type: 'string', + default: '', + placeholder: 'https://.app.n8n.cloud/api/v1', + description: 'The API URL of the n8n instance', + }, + ]; + authenticate: IAuthenticateGeneric = { + type: 'generic', + properties: { + headers: { + 'X-N8N-API-KEY': '={{ $credentials.apiKey }}', + }, + }, + }; + test: ICredentialTestRequest = { + request: { + baseURL: '={{ $credentials.baseUrl }}', + url: '/workflows?limit=5', + }, + }; +} diff --git a/packages/nodes-base/nodes/N8n/CredentialDescription.ts b/packages/nodes-base/nodes/N8n/CredentialDescription.ts new file mode 100644 index 0000000000..fd61164f89 --- /dev/null +++ b/packages/nodes-base/nodes/N8n/CredentialDescription.ts @@ -0,0 +1,168 @@ +import { INodeProperties } from 'n8n-workflow'; +import { parseAndSetBodyJson } from './GenericFunctions'; + +export const credentialOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + default: 'create', + displayOptions: { + show: { + resource: ['credential'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + action: 'Create a credential', + routing: { + request: { + method: 'POST', + url: '/credentials', + }, + }, + }, + { + name: 'Delete', + value: 'delete', + action: 'Delete a credential', + routing: { + request: { + method: 'DELETE', + url: '=/credentials/{{ $parameter.credentialId }}', + }, + }, + }, + { + name: 'Get Schema', + value: 'getSchema', + action: 'Get credential data schema for type', + routing: { + request: { + method: 'GET', + url: '=/credentials/schema/{{ $parameter.credentialTypeName }}', + }, + }, + }, + ], + }, +]; + +const createOperation: INodeProperties[] = [ + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + placeholder: 'e.g. n8n account', + required: true, + displayOptions: { + show: { + resource: ['credential'], + operation: ['create'], + }, + }, + routing: { + request: { + body: { + name: '={{ $value }}', + }, + }, + }, + description: 'Name of the new credential', + }, + { + displayName: 'Credential Type', + name: 'credentialTypeName', + type: 'string', + placeholder: 'e.g. n8nApi', + default: '', + required: true, + displayOptions: { + show: { + resource: ['credential'], + operation: ['create'], + }, + }, + routing: { + request: { + body: { + type: '={{ $value }}', + }, + }, + }, + description: + "The available types depend on nodes installed on the n8n instance. Some built-in types include e.g. 'githubApi', 'notionApi', and 'slackApi'.", + }, + { + displayName: 'Data', + name: 'data', + type: 'json', + default: '', + placeholder: + '// e.g. for n8nApi \n{\n "apiKey": "my-n8n-api-key",\n "baseUrl": "https://.app.n8n.cloud/api/v1",\n}', + required: true, + typeOptions: { + alwaysOpenEditWindow: true, + }, + displayOptions: { + show: { + resource: ['credential'], + operation: ['create'], + }, + }, + routing: { + send: { + // Validate that the 'data' property is parseable as JSON and + // set it into the request as body.data. + preSend: [parseAndSetBodyJson('data', 'data')], + }, + }, + description: + "A valid JSON object with properties required for this Credential Type. To see the expected format, you can use 'Get Schema' operation.", + }, +]; + +const deleteOperation: INodeProperties[] = [ + { + displayName: 'Credential ID', + name: 'credentialId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: ['credential'], + operation: ['delete'], + }, + }, + }, +]; + +const getSchemaOperation: INodeProperties[] = [ + { + displayName: 'Credential Type', + name: 'credentialTypeName', + default: '', + placeholder: 'e.g. n8nApi', + required: true, + type: 'string', + displayOptions: { + show: { + resource: ['credential'], + operation: ['getSchema'], + }, + }, + description: + "The available types depend on nodes installed on the n8n instance. Some built-in types include e.g. 'githubApi', 'notionApi', and 'slackApi'.", + }, +]; + +export const credentialFields: INodeProperties[] = [ + ...createOperation, + ...deleteOperation, + ...getSchemaOperation, +]; diff --git a/packages/nodes-base/nodes/N8n/ExecutionDescription.ts b/packages/nodes-base/nodes/N8n/ExecutionDescription.ts new file mode 100644 index 0000000000..9b9aa791e8 --- /dev/null +++ b/packages/nodes-base/nodes/N8n/ExecutionDescription.ts @@ -0,0 +1,252 @@ +/* eslint-disable n8n-nodes-base/node-param-default-missing */ +import { getCursorPaginator } from './GenericFunctions'; +import { INodeProperties } from 'n8n-workflow'; +import { workflowIdLocator } from './WorkflowLocator'; + +export const executionOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + default: 'getAll', + displayOptions: { + show: { + resource: ['execution'], + }, + }, + options: [ + { + name: 'Get', + value: 'get', + action: 'Get an execution', + routing: { + request: { + method: 'GET', + url: '=/executions/{{ $parameter.executionId }}', + }, + }, + }, + { + name: 'Get Many', + value: 'getAll', + action: 'Get many executions', + routing: { + request: { + method: 'GET', + url: '/executions', + }, + send: { + paginate: true, + }, + operations: { + pagination: getCursorPaginator(), + }, + }, + }, + { + name: 'Delete', + value: 'delete', + action: 'Delete an execution', + routing: { + request: { + method: 'DELETE', + url: '=/executions/{{ $parameter.executionId }}', + }, + }, + }, + ], + }, +]; + +const deleteOperation: INodeProperties[] = [ + { + displayName: 'Execution ID', + name: 'executionId', + type: 'string', + required: true, + displayOptions: { + show: { + resource: ['execution'], + operation: ['delete'], + }, + }, + default: '', + }, +]; + +const getAllOperation: INodeProperties[] = [ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: false, + displayOptions: { + show: { + resource: ['execution'], + operation: ['getAll'], + }, + }, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 100, + typeOptions: { + minValue: 1, + maxValue: 250, + }, + displayOptions: { + show: { + resource: ['execution'], + operation: ['getAll'], + returnAll: [false], + }, + }, + routing: { + request: { + qs: { + limit: '={{ $value }}', + }, + }, + }, + description: 'Max number of results to return', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + displayOptions: { + show: { + resource: ['execution'], + operation: ['getAll'], + }, + }, + options: [ + { + // Use the common workflowIdLocator, but provide a custom routing + ...workflowIdLocator, + routing: { + send: { + type: 'query', + property: 'workflowId', + value: '={{ $value || undefined }}', + }, + }, + description: 'Workflow to filter the executions by', + }, + { + displayName: 'Status', + name: 'status', + type: 'options', + options: [ + { + name: 'Error', + value: 'error', + }, + { + name: 'Success', + value: 'success', + }, + { + name: 'Waiting', + value: 'waiting', + }, + ], + default: 'success', + routing: { + send: { + type: 'query', + property: 'status', + value: '={{ $value }}', + }, + }, + description: 'Status to filter the executions by', + }, + ], + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + default: {}, + placeholder: 'Add Option', + displayOptions: { + show: { + resource: ['execution'], + operation: ['getAll'], + }, + }, + options: [ + { + displayName: 'Include Execution Details', + name: 'activeWorkflows', + type: 'boolean', + default: false, + routing: { + send: { + type: 'query', + property: 'includeData', + value: '={{ $value }}', + }, + }, + description: 'Whether to include the detailed execution data', + }, + ], + }, +]; + +const getOperation: INodeProperties[] = [ + { + displayName: 'Execution ID', + name: 'executionId', + type: 'string', + required: true, + default: '', + displayOptions: { + show: { + resource: ['execution'], + operation: ['get'], + }, + }, + }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + default: {}, + placeholder: 'Add Option', + displayOptions: { + show: { + resource: ['execution'], + operation: ['get'], + }, + }, + options: [ + { + displayName: 'Include Execution Details', + name: 'activeWorkflows', + type: 'boolean', + default: false, + routing: { + send: { + type: 'query', + property: 'includeData', + value: '={{ $value }}', + }, + }, + description: 'Whether to include the detailed execution data', + }, + ], + }, +]; + +export const executionFields: INodeProperties[] = [ + ...deleteOperation, + ...getAllOperation, + ...getOperation, +]; diff --git a/packages/nodes-base/nodes/N8n/GenericFunctions.ts b/packages/nodes-base/nodes/N8n/GenericFunctions.ts new file mode 100644 index 0000000000..d40bd48490 --- /dev/null +++ b/packages/nodes-base/nodes/N8n/GenericFunctions.ts @@ -0,0 +1,209 @@ +import { + DeclarativeRestApiSettings, + IDataObject, + IExecuteFunctions, + IExecutePaginationFunctions, + IExecuteSingleFunctions, + IHookFunctions, + IHttpRequestOptions, + ILoadOptionsFunctions, + INodeExecutionData, + JsonObject, + NodeApiError, + NodeOperationError, + PreSendAction, +} from 'n8n-workflow'; +import { OptionsWithUri } from 'request'; + +/** + * A custom API request function to be used with the resourceLocator lookup queries. + */ +export async function apiRequest( + this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, + method: string, + endpoint: string, + body: object, + query?: IDataObject, +): Promise { + query = query || {}; + + type N8nApiCredentials = { + apiKey: string; + baseUrl: string; + }; + + const credentials = (await this.getCredentials('n8nApi')) as N8nApiCredentials; + const baseUrl = credentials.baseUrl; + + const options: OptionsWithUri = { + method, + body, + qs: query, + uri: `${baseUrl}/${endpoint}`, + json: true, + }; + + try { + return await this.helpers.requestWithAuthentication.call(this, 'n8nApi', options); + } catch (error) { + if (error instanceof NodeApiError) { + throw error; + } + throw new NodeApiError(this.getNode(), error as JsonObject); + } +} + +/** + * Get a cursor-based paginator to use with n8n 'getAll' type endpoints. + * + * It will look up a 'nextCursor' in the response and if the node has + * 'returnAll' set to true, will consecutively include it as the 'cursor' query + * parameter for the next request, effectively getting everything in slices. + * + * Prequisites: + * - routing.send.paginate must be set to true, for all requests to go through here + * - node is expected to have a boolean parameter 'returnAll' + * - no postReceive action setting the rootProperty, to get the items mapped + * + * @returns A ready-to-use cursor-based paginator function. + */ +export const getCursorPaginator = () => { + return async function cursorPagination( + this: IExecutePaginationFunctions, + requestOptions: DeclarativeRestApiSettings.ResultOptions, + ): Promise { + if (!requestOptions.options.qs) { + requestOptions.options.qs = {}; + } + + let executions: INodeExecutionData[] = []; + let responseData: INodeExecutionData[]; + let nextCursor: string | undefined = undefined; + const returnAll = this.getNodeParameter('returnAll', true) as boolean; + + do { + requestOptions.options.qs.cursor = nextCursor; + responseData = await this.makeRoutingRequest(requestOptions); + + // Check for another page of items + const lastItem = responseData[responseData.length - 1].json; + nextCursor = lastItem.nextCursor as string | undefined; + + responseData.forEach((page) => { + const items = page.json.data as IDataObject[]; + if (items) { + // Extract the items themselves + executions = executions.concat(items.map((item) => ({ json: item }))); + } + }); + + // If we don't return all, just return the first page + } while (returnAll && nextCursor); + + return executions; + }; +}; + +/** + * A helper function to parse a node parameter as JSON and set it in the request body. + * Throws a NodeOperationError is the content is not valid JSON or it cannot be set. + * + * Currently, parameters with type 'json' are not validated automatically. + * Also mapping the value for 'body.data' declaratively has it treated as a string, + * but some operations (e.g. POST /credentials) don't work unless it is set as an object. + * To get the JSON-body operations to work consistently, we need to parse and set the body + * manually. + * + * @param parameterName The name of the node parameter to parse + * @param setAsBodyProperty An optional property name to set the parsed data into + * @returns The requestOptions with its body replaced with the contents of the parameter + */ +export const parseAndSetBodyJson = ( + parameterName: string, + setAsBodyProperty?: string, +): PreSendAction => { + return async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, + ): Promise { + try { + const rawData = this.getNodeParameter(parameterName, '{}') as string; + const parsedObject = JSON.parse(rawData); + + // Set the parsed object to either as the request body directly, or as its sub-property + if (setAsBodyProperty === undefined) { + requestOptions.body = parsedObject; + } else { + requestOptions.body = Object.assign({}, requestOptions.body, { + [setAsBodyProperty]: parsedObject, + }); + } + } catch (err) { + throw new NodeOperationError( + this.getNode(), + `The '${parameterName}' property must be valid JSON, but cannot be parsed: ${err}`, + ); + } + return requestOptions; + }; +}; + +/** + * A helper function to prepare the workflow object data for creation. It only sets + * known workflow properties, for pre-emptively avoiding a HTTP 400 Bad Request + * response until we have a better client-side schema validation mechanism. + * + * NOTE! This expects the requestOptions.body to already be set as an object, + * so take care to first call parseAndSetBodyJson(). + */ +export const prepareWorkflowCreateBody: PreSendAction = async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const body = requestOptions.body as IDataObject; + const newBody: IDataObject = {}; + + newBody.name = body.name || 'My workflow'; + newBody.nodes = body.nodes || []; + newBody.settings = body.settings || {}; + newBody.connections = body.connections || {}; + newBody.staticData = body.staticData || null; + + requestOptions.body = newBody; + + return requestOptions; +}; + +/** + * A helper function to prepare the workflow object data for update. + * + * NOTE! This expects the requestOptions.body to already be set as an object, + * so take care to first call parseAndSetBodyJson(). + */ +export const prepareWorkflowUpdateBody: PreSendAction = async function ( + this: IExecuteSingleFunctions, + requestOptions: IHttpRequestOptions, +): Promise { + const body = requestOptions.body as IDataObject; + const newBody: IDataObject = {}; + + if (body.name) { + newBody.name = body.name; + } + if (body.nodes) { + newBody.nodes = body.nodes; + } + if (body.settings) { + newBody.settings = body.settings; + } + if (body.connections) { + newBody.connections = body.connections; + } + if (body.staticData) { + newBody.staticData = body.staticData; + } + + requestOptions.body = newBody; + + return requestOptions; +}; diff --git a/packages/nodes-base/nodes/N8n/N8n.node.json b/packages/nodes-base/nodes/N8n/N8n.node.json new file mode 100644 index 0000000000..4c2c48d838 --- /dev/null +++ b/packages/nodes-base/nodes/N8n/N8n.node.json @@ -0,0 +1,22 @@ +{ + "node": "n8n-nodes-base.n8n", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Development", "Core Nodes"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/api/authentication/" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.n8n/" + } + ] + }, + "alias": ["Workflow", "Execution"], + "subcategories": { + "Core Nodes": ["Helpers"] + } +} diff --git a/packages/nodes-base/nodes/N8n/N8n.node.ts b/packages/nodes-base/nodes/N8n/N8n.node.ts new file mode 100644 index 0000000000..2525e4ee5d --- /dev/null +++ b/packages/nodes-base/nodes/N8n/N8n.node.ts @@ -0,0 +1,80 @@ +import { INodeType, INodeTypeDescription } from 'n8n-workflow'; +import { credentialFields, credentialOperations } from './CredentialDescription'; +import { executionFields, executionOperations } from './ExecutionDescription'; +import { workflowFields, workflowOperations } from './WorkflowDescription'; +import { searchWorkflows } from './WorkflowLocator'; + +/** + * The n8n node provides access to the n8n API. + * + * See: https://docs.n8n.io/api/api-reference/ + */ +export class N8n implements INodeType { + description: INodeTypeDescription = { + displayName: 'n8n', + name: 'n8n', + icon: 'file:n8n.svg', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume n8n API', + defaults: { + name: 'n8n', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'n8nApi', + required: true, + }, + ], + requestDefaults: { + returnFullResponse: true, + baseURL: '={{ $credentials.baseUrl.replace(new RegExp("/$"), "") }}', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }, + properties: [ + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Credential', + value: 'credential', + }, + { + name: 'Execution', + value: 'execution', + }, + { + name: 'Workflow', + value: 'workflow', + }, + ], + default: 'workflow', + }, + + ...credentialOperations, + ...credentialFields, + + ...executionOperations, + ...executionFields, + + ...workflowOperations, + ...workflowFields, + ], + }; + + methods = { + listSearch: { + // Provide workflows search capability for the workflow resourceLocator + searchWorkflows, + }, + }; +} diff --git a/packages/nodes-base/nodes/N8n/WorkflowDescription.ts b/packages/nodes-base/nodes/N8n/WorkflowDescription.ts new file mode 100644 index 0000000000..0c36242919 --- /dev/null +++ b/packages/nodes-base/nodes/N8n/WorkflowDescription.ts @@ -0,0 +1,323 @@ +import { INodeProperties } from 'n8n-workflow'; +import { + getCursorPaginator, + parseAndSetBodyJson, + prepareWorkflowCreateBody, + prepareWorkflowUpdateBody, +} from './GenericFunctions'; +import { workflowIdLocator } from './WorkflowLocator'; + +export const workflowOperations: INodeProperties[] = [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + default: 'getAll', + displayOptions: { + show: { + resource: ['workflow'], + }, + }, + options: [ + { + name: 'Activate', + value: 'activate', + action: 'Activate a workflow', + }, + { + name: 'Create', + value: 'create', + action: 'Create a workflow', + routing: { + request: { + method: 'POST', + url: '/workflows', + }, + }, + }, + { + name: 'Deactivate', + value: 'deactivate', + action: 'Deactivate a workflow', + }, + { + name: 'Delete', + value: 'delete', + action: 'Delete a workflow', + }, + { + name: 'Get', + value: 'get', + action: 'Get a workflow', + }, + { + name: 'Get Many', + value: 'getAll', + action: 'Get many workflows', + routing: { + request: { + method: 'GET', + url: '/workflows', + }, + send: { + paginate: true, + }, + operations: { + pagination: getCursorPaginator(), + }, + }, + }, + { + name: 'Update', + value: 'update', + action: 'Update a workflow', + }, + ], + }, +]; + +const activateOperation: INodeProperties[] = [ + { + ...workflowIdLocator, + required: true, + displayOptions: { + show: { + resource: ['workflow'], + operation: ['activate'], + }, + }, + // The routing for resourceLocator-enabled properties currently needs to + // happen in the property block where the property itself is defined, or + // extractValue won't work when used with $parameter in routing.request.url. + routing: { + request: { + method: 'POST', + url: '=/workflows/{{ $value }}/activate', + }, + }, + }, +]; + +const createOperation: INodeProperties[] = [ + { + displayName: 'Workflow Object', + name: 'workflowObject', + type: 'json', + default: '{ "name": "My workflow", "nodes": [], "connections": {}, "settings": {} }', + placeholder: + '{\n "name": "My workflow",\n "nodes": [],\n "connections": {},\n "settings": {}\n}', + required: true, + typeOptions: { + alwaysOpenEditWindow: true, + }, + displayOptions: { + show: { + resource: ['workflow'], + operation: ['create'], + }, + }, + routing: { + send: { + preSend: [parseAndSetBodyJson('workflowObject'), prepareWorkflowCreateBody], + }, + }, + description: + "A valid JSON object with required fields: 'name', 'nodes', 'connections' and 'settings'. More information can be found in the documentation.", + }, +]; + +const deactivateOperation: INodeProperties[] = [ + { + ...workflowIdLocator, + required: true, + displayOptions: { + show: { + resource: ['workflow'], + operation: ['deactivate'], + }, + }, + routing: { + request: { + method: 'POST', + url: '=/workflows/{{ $value }}/deactivate', + }, + }, + }, +]; + +const deleteOperation: INodeProperties[] = [ + { + ...workflowIdLocator, + required: true, + displayOptions: { + show: { + resource: ['workflow'], + operation: ['delete'], + }, + }, + routing: { + request: { + method: 'DELETE', + url: '=/workflows/{{ $value }}', + }, + }, + }, +]; + +const getAllOperation: INodeProperties[] = [ + { + displayName: 'Return All', + name: 'returnAll', + type: 'boolean', + default: true, + displayOptions: { + show: { + resource: ['workflow'], + operation: ['getAll'], + }, + }, + description: 'Whether to return all results or only up to a given limit', + }, + { + displayName: 'Limit', + name: 'limit', + type: 'number', + default: 100, + typeOptions: { + minValue: 1, + maxValue: 250, + }, + displayOptions: { + show: { + resource: ['workflow'], + operation: ['getAll'], + returnAll: [false], + }, + }, + routing: { + request: { + qs: { + limit: '={{ $value }}', + }, + }, + }, + description: 'Max number of results to return', + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + default: {}, + displayOptions: { + show: { + resource: ['workflow'], + operation: ['getAll'], + }, + }, + options: [ + { + displayName: 'Return Only Active Workflows', + name: 'activeWorkflows', + type: 'boolean', + default: true, + routing: { + request: { + qs: { + active: '={{ $value }}', + }, + }, + }, + }, + { + displayName: 'Tags', + name: 'tags', + type: 'string', + default: '', + routing: { + // Only include the 'tags' query parameter if it's non-empty + send: { + type: 'query', + property: 'tags', + value: '={{ $value !== "" ? $value : undefined }}', + }, + }, + description: 'Include only workflows with these tags', + hint: 'Comma separated list of tags (empty value is ignored)', + }, + ], + }, +]; + +const getOperation: INodeProperties[] = [ + { + ...workflowIdLocator, + required: true, + displayOptions: { + show: { + resource: ['workflow'], + operation: ['get'], + }, + }, + routing: { + request: { + method: 'GET', + url: '=/workflows/{{ $value }}', + }, + }, + }, +]; + +const updateOperation: INodeProperties[] = [ + { + ...workflowIdLocator, + required: true, + displayOptions: { + show: { + resource: ['workflow'], + operation: ['update'], + }, + }, + routing: { + request: { + method: 'PUT', + url: '=/workflows/{{ $value }}', + }, + }, + }, + { + displayName: 'Workflow Object', + name: 'workflowObject', + type: 'json', + default: '', + placeholder: + '{\n "name": "My workflow",\n "nodes": [],\n "connections": {},\n "settings": {}\n}', + required: true, + typeOptions: { + alwaysOpenEditWindow: true, + }, + displayOptions: { + show: { + resource: ['workflow'], + operation: ['update'], + }, + }, + routing: { + send: { + preSend: [parseAndSetBodyJson('workflowObject'), prepareWorkflowUpdateBody], + }, + }, + description: + "A valid JSON object with required fields: 'name', 'nodes', 'connections' and 'settings'. More information can be found in the documentation.", + }, +]; + +export const workflowFields: INodeProperties[] = [ + ...activateOperation, + ...createOperation, + ...deactivateOperation, + ...deleteOperation, + ...getAllOperation, + ...getOperation, + ...updateOperation, +]; diff --git a/packages/nodes-base/nodes/N8n/WorkflowLocator.ts b/packages/nodes-base/nodes/N8n/WorkflowLocator.ts new file mode 100644 index 0000000000..ae3b278bf5 --- /dev/null +++ b/packages/nodes-base/nodes/N8n/WorkflowLocator.ts @@ -0,0 +1,106 @@ +import { ILoadOptionsFunctions, INodeListSearchResult, INodeProperties } from 'n8n-workflow'; +import { apiRequest } from './GenericFunctions'; + +type DataItemsResponse = { + data: T[]; +}; + +interface PartialWorkflow { + id: number; + name: string; +} + +/** + * A helper to populate workflow lists. It does a pseudo-search by + * listing available workflows and matching with the specified query. + */ +export async function searchWorkflows( + this: ILoadOptionsFunctions, + query?: string, +): Promise { + const searchResults = (await apiRequest.call( + this, + 'GET', + 'workflows', + {}, + )) as DataItemsResponse; + + // Map the workflows list against a simple name/id filter, and sort + // with the latest on top. + const workflows = (searchResults?.data as PartialWorkflow[]) + .map((w: PartialWorkflow) => ({ + name: `${w.name} (#${w.id})`, + value: w.id, + })) + .filter( + (w) => + !query || + w.name.toLowerCase().includes(query.toLowerCase()) || + w.value?.toString() === query, + ) + .sort((a, b) => b.value - a.value); + + return { + results: workflows, + }; +} + +/** + * A resourceLocator to enable looking up workflows by their ID. + * This object can be used as a base and then extended as needed. + */ +export const workflowIdLocator: INodeProperties = { + displayName: 'Workflow', + name: 'workflowId', + type: 'resourceLocator', + default: { mode: 'list', value: '' }, + description: 'Workflow to filter the executions by', + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a Workflow...', + initType: 'workflow', + typeOptions: { + searchListMethod: 'searchWorkflows', + searchFilterRequired: false, + searchable: true, + }, + }, + { + displayName: 'By URL', + name: 'url', + type: 'string', + placeholder: 'https://myinstance.app.n8n.cloud/workflow/1', + validation: [ + { + type: 'regex', + properties: { + regex: '.*/workflow/([0-9]{1,})', + errorMessage: 'Not a valid Workflow URL', + }, + }, + ], + extractValue: { + type: 'regex', + regex: '.*/workflow/([0-9]{1,})', + }, + }, + { + displayName: 'ID', + name: 'id', + type: 'string', + validation: [ + { + type: 'regex', + properties: { + regex: '[0-9]{1,}', + errorMessage: 'Not a valid Workflow ID', + }, + }, + ], + placeholder: '1', + }, + ], +}; diff --git a/packages/nodes-base/nodes/N8n/n8n.svg b/packages/nodes-base/nodes/N8n/n8n.svg new file mode 100644 index 0000000000..e2e67c20fb --- /dev/null +++ b/packages/nodes-base/nodes/N8n/n8n.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 9365c09f45..fee3c0a749 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -208,6 +208,7 @@ "dist/credentials/Mqtt.credentials.js", "dist/credentials/Msg91Api.credentials.js", "dist/credentials/MySql.credentials.js", + "dist/credentials/N8nApi.credentials.js", "dist/credentials/NasaApi.credentials.js", "dist/credentials/NetlifyApi.credentials.js", "dist/credentials/NextCloudApi.credentials.js", @@ -556,6 +557,7 @@ "dist/nodes/MQTT/MqttTrigger.node.js", "dist/nodes/Msg91/Msg91.node.js", "dist/nodes/MySql/MySql.node.js", + "dist/nodes/N8n/N8n.node.js", "dist/nodes/N8nTrainingCustomerDatastore/N8nTrainingCustomerDatastore.node.js", "dist/nodes/N8nTrainingCustomerMessenger/N8nTrainingCustomerMessenger.node.js", "dist/nodes/N8nTrigger/N8nTrigger.node.js",