feat(Execute Workflow Trigger Node): Add JSON-based input modes and dropdown (#11946)

This commit is contained in:
Charlie Kolb 2024-11-28 16:29:36 +01:00 committed by GitHub
parent 698f7d0ae1
commit 001e3f931f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 270 additions and 137 deletions

View file

@ -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<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_SCHEMA) {
const schema = context.getNodeParameter(JSON_SCHEMA, 0, '{}') as string;
result = parseJsonSchema(jsonParse<JSONSchema7>(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.<br>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.<br><br>
@ -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.<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: '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) {

View file

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

View file

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

View file

@ -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/**"]
}

View file

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

View file

@ -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<SchemaObject | SchemaArray | SchemaProperty>;
required?: string[];
}
export interface SchemaProperty {
type: string | string[];
format?: string;
}
export function json(title: string, schema: SchemaObject): SchemaObject;
export function json(schema: SchemaObject): SchemaObject;
}

View file

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