From 001e3f931f6f7c877d8129009ee58da6944f8cb1 Mon Sep 17 00:00:00 2001 From: Charlie Kolb Date: Thu, 28 Nov 2024 16:29:36 +0100 Subject: [PATCH] feat(Execute Workflow Trigger Node): Add JSON-based input modes and dropdown (#11946) --- .../ExecuteWorkflowTrigger.node.ts | 283 ++++++++++++++---- .../test/ExecuteWorkflowTrigger.node.test.ts | 2 +- packages/nodes-base/package.json | 1 + packages/nodes-base/tsconfig.build.json | 3 +- packages/nodes-base/tsconfig.json | 8 +- .../nodes-base/types/generate-schema.d.ts | 27 ++ pnpm-lock.yaml | 83 +---- 7 files changed, 270 insertions(+), 137 deletions(-) create mode 100644 packages/nodes-base/types/generate-schema.d.ts diff --git a/packages/nodes-base/nodes/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.ts b/packages/nodes-base/nodes/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.ts index e3812c3d3b..34682242b0 100644 --- a/packages/nodes-base/nodes/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.ts +++ b/packages/nodes-base/nodes/ExecuteWorkflowTrigger/ExecuteWorkflowTrigger.node.ts @@ -1,3 +1,5 @@ +import { json as generateSchemaFromExample, type SchemaObject } from 'generate-schema'; +import type { JSONSchema7 } from 'json-schema'; import { type INodeExecutionData, NodeConnectionType, @@ -7,16 +9,125 @@ import { type INodeTypeDescription, validateFieldType, type FieldType, + jsonParse, } from 'n8n-workflow'; +const INPUT_SOURCE = 'inputSource'; const WORKFLOW_INPUTS = 'workflowInputs'; const INPUT_OPTIONS = 'inputOptions'; const VALUES = 'values'; - -type ValueOptions = { name: string; value: FieldType }; +const JSON_EXAMPLE = 'jsonExample'; +const JSON_SCHEMA = 'jsonSchema'; +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_SCHEMA) { + const schema = context.getNodeParameter(JSON_SCHEMA, 0, '{}') as string; + result = parseJsonSchema(jsonParse(schema)); + } 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); +} + +// This intentionally doesn't catch any potential errors, e.g. an invalid json example +// This way they correctly end up exposed to the user. +// Otherwise we'd have to return true on error here as we short-circuit on false +function hasFields(context: IExecuteFunctions): boolean { + const entries = getFieldEntries(context); + return entries.length > 0; +} + export class ExecuteWorkflowTrigger implements INodeType { description: INodeTypeDescription = { displayName: 'Execute Workflow Trigger', @@ -29,12 +140,36 @@ export class ExecuteWorkflowTrigger implements INodeType { eventTriggerDescription: '', maxNodes: 1, defaults: { - name: 'Execute Workflow Trigger', + name: 'Workflow Input Trigger', color: '#ff6d5a', }, - inputs: [], outputs: [NodeConnectionType.Main], + hints: [ + { + message: + 'We strongly recommend defining your input fields explicitly.
If no inputs are provided, all data from the calling workflow will be available, and issues will be more difficult to debug later on.', + // 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 + // For JSON_SCHEMA: We check if we have '"properties":{}' after removing all whitespace. Otherwise the schema is invalid anyway and we'll error out elsewhere + displayCondition: + `={{$parameter['${INPUT_SOURCE}'] === '${WORKFLOW_INPUTS}' && !$parameter['${WORKFLOW_INPUTS}'].keys().length ` + + `|| $parameter['${INPUT_SOURCE}'] === '${JSON_EXAMPLE}' && $parameter['${JSON_EXAMPLE}'].toString().replaceAll(' ', '').replaceAll('\\n', '') === '{}' ` + + `|| $parameter['${INPUT_SOURCE}'] === '${JSON_SCHEMA}' && $parameter['${JSON_SCHEMA}'].toString().replaceAll(' ', '').replaceAll('\\n', '').includes('"properties":{}') }}`, + whenToDisplay: 'always', + location: 'ndv', + }, + + { + message: + 'n8n does not support items types on Array fields. These entries will have no effect.', + // This is only best effort, but few natural use cases should trigger false positives here + displayCondition: `={{$parameter["${INPUT_SOURCE}"] === '${JSON_SCHEMA}' && $parameter["${JSON_SCHEMA}"].toString().includes('"items":') && $parameter["${JSON_SCHEMA}"].toString().includes('"array"') }}`, + whenToDisplay: 'always', + location: 'ndv', + }, + ], properties: [ { displayName: `When an ‘Execute Workflow’ node calls this workflow, the execution starts here.

@@ -59,6 +194,79 @@ If you don't provide fields, all data passed into the 'Execute Workflow' node wi ], 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', + }, + { + name: 'Using JSON Schema', + value: JSON_SCHEMA, + description: 'Provide JSON Schema', + }, + ], + default: WORKFLOW_INPUTS, + noDataExpression: true, + }, + { + 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: 'JSON Schema', + name: JSON_SCHEMA, + type: 'json', + default: JSON.stringify( + { + properties: { + aField: { type: 'number' }, + anotherField: { type: 'array' }, + thisFieldAcceptsAnyType: { type: 'any' }, + }, + }, + null, + 2, + ), + noDataExpression: true, + displayOptions: { + show: { '@version': [{ _cnd: { gte: 1.1 } }], inputSource: [JSON_SCHEMA] }, + }, + }, { displayName: 'Workflow Inputs', name: WORKFLOW_INPUTS, @@ -71,7 +279,7 @@ If you don't provide fields, all data passed into the 'Execute Workflow' node wi sortable: true, }, displayOptions: { - show: { '@version': [{ _cnd: { gte: 1.1 } }] }, + show: { '@version': [{ _cnd: { gte: 1.1 } }], inputSource: [WORKFLOW_INPUTS] }, }, default: {}, options: [ @@ -94,35 +302,7 @@ If you don't provide fields, all data passed into the 'Execute Workflow' node wi type: 'options', description: 'The field value type', // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items - options: [ - // This is not a FieldType type, but will - // hit the default case in the type check function - { - 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` - ] as ValueOptions[], + options: TYPE_OPTIONS, default: 'string', noDataExpression: true, }, @@ -184,15 +364,7 @@ If you don't provide fields, all data passed into the 'Execute Workflow' node wi if (this.getNode().typeVersion < 1.1) { return [inputData]; } else { - // Need to mask type due to bad `getNodeParameter` typing - const marker = Symbol() as unknown as object; - const hasFields = - inputData.length >= 0 && - inputData.some( - (_x, i) => this.getNodeParameter(`${WORKFLOW_INPUTS}.${VALUES}`, i, marker) !== marker, - ); - - if (!hasFields) { + if (!hasFields(this)) { return [inputData]; } @@ -229,28 +401,21 @@ If you don't provide fields, all data passed into the 'Execute Workflow' node wi pairedItem: { item: itemIndex }, }; try { - const newParams = this.getNodeParameter( - `${WORKFLOW_INPUTS}.${VALUES}`, - itemIndex, - [], - ) as Array<{ - name: string; - type: FieldType; - }>; + const newParams = getFieldEntries(this); + for (const { name, type } of newParams) { if (!item.json.hasOwnProperty(name)) { newItem.json[name] = DEFAULT_PLACEHOLDER; continue; } - // We always parse strings rather than blindly accepting anything as a string - // Which is the behavior of this function - // Also note we intentionally pass `any` in here for `type`, which hits a - // permissive default case in the function - const result = validateFieldType(name, item.json[name], type, { - strict: !attemptToConvertTypes, - parseStrings: true, - }); + 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) { diff --git a/packages/nodes-base/nodes/ExecuteWorkflowTrigger/test/ExecuteWorkflowTrigger.node.test.ts b/packages/nodes-base/nodes/ExecuteWorkflowTrigger/test/ExecuteWorkflowTrigger.node.test.ts index 8a4b1cc8d5..4e050de0aa 100644 --- a/packages/nodes-base/nodes/ExecuteWorkflowTrigger/test/ExecuteWorkflowTrigger.node.test.ts +++ b/packages/nodes-base/nodes/ExecuteWorkflowTrigger/test/ExecuteWorkflowTrigger.node.test.ts @@ -4,7 +4,7 @@ import type { IExecuteFunctions, INode, INodeExecutionData } from 'n8n-workflow' import { ExecuteWorkflowTrigger } from '../ExecuteWorkflowTrigger.node'; describe('ExecuteWorkflowTrigger', () => { - it('should return its input data', async () => { + it('should return its input data on V1', async () => { const mockInputData: INodeExecutionData[] = [ { json: { item: 0, foo: 'bar' } }, { json: { item: 1, foo: 'quz' } }, diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 1de487f7ad..007c48f6ea 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -864,6 +864,7 @@ "fast-glob": "catalog:", "fflate": "0.7.4", "get-system-fonts": "2.0.2", + "generate-schema": "2.6.0", "gm": "1.25.0", "html-to-text": "9.0.5", "iconv-lite": "0.6.3", diff --git a/packages/nodes-base/tsconfig.build.json b/packages/nodes-base/tsconfig.build.json index 3a26457c9c..d92417abdd 100644 --- a/packages/nodes-base/tsconfig.build.json +++ b/packages/nodes-base/tsconfig.build.json @@ -8,7 +8,8 @@ "credentials/**/*.ts", "nodes/**/*.ts", "nodes/**/*.json", - "credentials/translations/**/*.json" + "credentials/translations/**/*.json", + "types/**/*.ts" ], "exclude": ["nodes/**/*.test.ts", "credentials/**/*.test.ts", "test/**"] } diff --git a/packages/nodes-base/tsconfig.json b/packages/nodes-base/tsconfig.json index b5a7282ff9..2cbb72109a 100644 --- a/packages/nodes-base/tsconfig.json +++ b/packages/nodes-base/tsconfig.json @@ -10,7 +10,13 @@ "noImplicitReturns": false, "useUnknownInCatchVariables": false }, - "include": ["credentials/**/*.ts", "nodes/**/*.ts", "test/**/*.ts", "utils/**/*.ts"], + "include": [ + "credentials/**/*.ts", + "nodes/**/*.ts", + "test/**/*.ts", + "utils/**/*.ts", + "types/**/*.ts" + ], "references": [ { "path": "../@n8n/imap/tsconfig.build.json" }, { "path": "../workflow/tsconfig.build.json" }, diff --git a/packages/nodes-base/types/generate-schema.d.ts b/packages/nodes-base/types/generate-schema.d.ts new file mode 100644 index 0000000000..90e0e15b05 --- /dev/null +++ b/packages/nodes-base/types/generate-schema.d.ts @@ -0,0 +1,27 @@ +declare module 'generate-schema' { + export interface SchemaObject { + $schema: string; + title?: string; + type: string; + properties?: { + [key: string]: SchemaObject | SchemaArray | SchemaProperty; + }; + required?: string[]; + items?: SchemaObject | SchemaArray; + } + + export interface SchemaArray { + type: string; + items?: SchemaObject | SchemaArray | SchemaProperty; + oneOf?: Array; + required?: string[]; + } + + export interface SchemaProperty { + type: string | string[]; + format?: string; + } + + export function json(title: string, schema: SchemaObject): SchemaObject; + export function json(schema: SchemaObject): SchemaObject; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdcdd96714..3098779b1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1111,7 +1111,7 @@ importers: dependencies: '@langchain/core': specifier: 'catalog:' - version: 0.3.15(openai@4.69.0(zod@3.23.8)) + version: 0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8)) '@n8n/client-oauth2': specifier: workspace:* version: link:../@n8n/client-oauth2 @@ -1662,6 +1662,9 @@ importers: fflate: specifier: 0.7.4 version: 0.7.4 + generate-schema: + specifier: 2.6.0 + version: 2.6.0 get-system-fonts: specifier: 2.0.2 version: 2.0.2 @@ -1939,7 +1942,7 @@ importers: devDependencies: '@langchain/core': specifier: 'catalog:' - version: 0.3.15(openai@4.69.0) + version: 0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8)) '@types/deep-equal': specifier: ^1.0.1 version: 1.0.1 @@ -14657,38 +14660,6 @@ snapshots: transitivePeerDependencies: - openai - '@langchain/core@0.3.15(openai@4.69.0(zod@3.23.8))': - dependencies: - ansi-styles: 5.2.0 - camelcase: 6.3.0 - decamelize: 1.2.0 - js-tiktoken: 1.0.12 - langsmith: 0.2.3(openai@4.69.0(zod@3.23.8)) - mustache: 4.2.0 - p-queue: 6.6.2 - p-retry: 4.6.2 - uuid: 10.0.0 - zod: 3.23.8 - zod-to-json-schema: 3.23.3(zod@3.23.8) - transitivePeerDependencies: - - openai - - '@langchain/core@0.3.15(openai@4.69.0)': - dependencies: - ansi-styles: 5.2.0 - camelcase: 6.3.0 - decamelize: 1.2.0 - js-tiktoken: 1.0.12 - langsmith: 0.2.3(openai@4.69.0) - mustache: 4.2.0 - p-queue: 6.6.2 - p-retry: 4.6.2 - uuid: 10.0.0 - zod: 3.23.8 - zod-to-json-schema: 3.23.3(zod@3.23.8) - transitivePeerDependencies: - - openai - '@langchain/google-common@0.1.1(@langchain/core@0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8)))(zod@3.23.8)': dependencies: '@langchain/core': 0.3.15(openai@4.69.0(encoding@0.1.13)(zod@3.23.8)) @@ -20420,7 +20391,7 @@ snapshots: isstream: 0.1.2 jsonwebtoken: 9.0.2 mime-types: 2.1.35 - retry-axios: 2.6.0(axios@1.7.4(debug@4.3.7)) + retry-axios: 2.6.0(axios@1.7.4) tough-cookie: 4.1.3 transitivePeerDependencies: - supports-color @@ -21451,28 +21422,6 @@ snapshots: optionalDependencies: openai: 4.69.0(encoding@0.1.13)(zod@3.23.8) - langsmith@0.2.3(openai@4.69.0(zod@3.23.8)): - dependencies: - '@types/uuid': 10.0.0 - commander: 10.0.1 - p-queue: 6.6.2 - p-retry: 4.6.2 - semver: 7.6.0 - uuid: 10.0.0 - optionalDependencies: - openai: 4.69.0(zod@3.23.8) - - langsmith@0.2.3(openai@4.69.0): - dependencies: - '@types/uuid': 10.0.0 - commander: 10.0.1 - p-queue: 6.6.2 - p-retry: 4.6.2 - semver: 7.6.0 - uuid: 10.0.0 - optionalDependencies: - openai: 4.69.0(zod@3.23.8) - lazy-ass@1.6.0: {} ldapts@4.2.6: @@ -22807,22 +22756,6 @@ snapshots: - encoding - supports-color - openai@4.69.0(zod@3.23.8): - dependencies: - '@types/node': 18.16.16 - '@types/node-fetch': 2.6.4 - abort-controller: 3.0.0 - agentkeepalive: 4.2.1 - form-data-encoder: 1.7.2 - formdata-node: 4.4.1 - node-fetch: 2.7.0(encoding@0.1.13) - optionalDependencies: - zod: 3.23.8 - transitivePeerDependencies: - - encoding - - supports-color - optional: true - openapi-sampler@1.5.1: dependencies: '@types/json-schema': 7.0.15 @@ -23807,9 +23740,9 @@ snapshots: ret@0.1.15: {} - retry-axios@2.6.0(axios@1.7.4(debug@4.3.7)): + retry-axios@2.6.0(axios@1.7.4): dependencies: - axios: 1.7.4(debug@4.3.7) + axios: 1.7.4 retry-request@7.0.2(encoding@0.1.13): dependencies: