diff --git a/packages/workflow/src/TypeValidation.ts b/packages/workflow/src/TypeValidation.ts index 606c3d201ed3d..a2c299303fe0a 100644 --- a/packages/workflow/src/TypeValidation.ts +++ b/packages/workflow/src/TypeValidation.ts @@ -2,6 +2,7 @@ import { DateTime } from 'luxon'; import type { FieldType, INodePropertyOptions, ValidationResult } from './Interfaces'; import isObject from 'lodash/isObject'; import { ApplicationError } from './errors'; +import { jsonParse } from './utils'; export const tryToParseNumber = (value: unknown): number => { const isValidNumber = !isNaN(Number(value)); @@ -135,7 +136,8 @@ export const tryToParseObject = (value: unknown): object => { return value; } try { - const o = JSON.parse(String(value)) as object; + const o = jsonParse(String(value), { acceptJSObject: true }); + if (typeof o !== 'object' || Array.isArray(o)) { throw new ApplicationError('Value is not a valid object', { extra: { value } }); } diff --git a/packages/workflow/src/utils.ts b/packages/workflow/src/utils.ts index e043b18ad0e6f..c0330955c3508 100644 --- a/packages/workflow/src/utils.ts +++ b/packages/workflow/src/utils.ts @@ -5,6 +5,13 @@ import { ALPHABET } from './Constants'; import type { BinaryFileType, IDisplayOptions, INodeProperties, JsonObject } from './Interfaces'; import { ApplicationError } from './errors/application.error'; +import { + parse as esprimaParse, + Syntax, + type Node as SyntaxNode, + type ExpressionStatement, +} from 'esprima-next'; + const readStreamClasses = new Set(['ReadStream', 'Readable', 'ReadableStream']); // NOTE: BigInt.prototype.toJSON is not available, which causes JSON.stringify to throw an error @@ -68,17 +75,74 @@ export const deepCopy = string }) }; // eslint-enable +function syntaxNodeToValue(expression?: SyntaxNode | null): unknown { + switch (expression?.type) { + case Syntax.ObjectExpression: + return Object.fromEntries( + expression.properties + .filter((prop) => prop.type === Syntax.Property) + .map(({ key, value }) => [syntaxNodeToValue(key), syntaxNodeToValue(value)]), + ); + case Syntax.Identifier: + return expression.name; + case Syntax.Literal: + return expression.value; + case Syntax.ArrayExpression: + return expression.elements.map((exp) => syntaxNodeToValue(exp)); + default: + return undefined; + } +} + +/** + * Parse any JavaScript ObjectExpression, including: + * - single quoted keys + * - unquoted keys + */ +function parseJSObject(objectAsString: string): object { + const jsExpression = esprimaParse(`(${objectAsString})`).body.find( + (node): node is ExpressionStatement => + node.type === Syntax.ExpressionStatement && node.expression.type === Syntax.ObjectExpression, + ); + + return syntaxNodeToValue(jsExpression?.expression) as object; +} + type MutuallyExclusive = | (T & { [k in Exclude]?: never }) | (U & { [k in Exclude]?: never }); -type JSONParseOptions = MutuallyExclusive<{ errorMessage: string }, { fallbackValue: T }>; +type JSONParseOptions = { acceptJSObject?: boolean } & MutuallyExclusive< + { errorMessage?: string }, + { fallbackValue?: T } +>; +/** + * Parses a JSON string into an object with optional error handling and recovery mechanisms. + * + * @param {string} jsonString - The JSON string to parse. + * @param {Object} [options] - Optional settings for parsing the JSON string. Either `fallbackValue` or `errorMessage` can be set, but not both. + * @param {boolean} [options.parseJSObject=false] - If true, attempts to recover from common JSON format errors by parsing the JSON string as a JavaScript Object. + * @param {string} [options.errorMessage] - A custom error message to throw if the JSON string cannot be parsed. + * @param {*} [options.fallbackValue] - A fallback value to return if the JSON string cannot be parsed. + * @returns {Object} - The parsed object, or the fallback value if parsing fails and `fallbackValue` is set. + */ export const jsonParse = (jsonString: string, options?: JSONParseOptions): T => { try { return JSON.parse(jsonString) as T; } catch (error) { + if (options?.acceptJSObject) { + try { + const jsonStringCleaned = parseJSObject(jsonString); + return jsonStringCleaned as T; + } catch (e) { + // Ignore this error and return the original error or the fallback value + } + } if (options?.fallbackValue !== undefined) { + if (options.fallbackValue instanceof Function) { + return options.fallbackValue(); + } return options.fallbackValue; } else if (options?.errorMessage) { throw new ApplicationError(options.errorMessage); diff --git a/packages/workflow/test/TypeValidation.test.ts b/packages/workflow/test/TypeValidation.test.ts index eb50b434376b5..f01af4cedcc9d 100644 --- a/packages/workflow/test/TypeValidation.test.ts +++ b/packages/workflow/test/TypeValidation.test.ts @@ -145,10 +145,14 @@ describe('Type Validation', () => { ); }); - it('should validate and cast JSON properly', () => { + it('should validate and cast JSON & JS objects properly', () => { const VALID_OBJECTS = [ ['{"a": 1}', { a: 1 }], + ['{a: 1}', { a: 1 }], + ["{'a': '1'}", { a: '1' }], + ["{'\\'single quoted\\' \"double quoted\"': 1}", { '\'single quoted\' "double quoted"': 1 }], ['{"a": 1, "b": { "c": 10, "d": "test"}}', { a: 1, b: { c: 10, d: 'test' } }], + ["{\"a\": 1, b: { 'c': 10, d: 'test'}}", { a: 1, b: { c: 10, d: 'test' } }], [{ name: 'John' }, { name: 'John' }], [ { name: 'John', address: { street: 'Via Roma', city: 'Milano' } }, @@ -162,19 +166,18 @@ describe('Type Validation', () => { }), ); - const INVALID_JSON = [ + const INVALID_OBJECTS = [ ['one', 'two'], '1', '[1]', '1.1', 1.1, '"a"', - '{a: 1}', '["apples", "oranges"]', [{ name: 'john' }, { name: 'bob' }], '[ { name: "john" }, { name: "bob" } ]', ]; - INVALID_JSON.forEach((value) => + INVALID_OBJECTS.forEach((value) => expect(validateFieldType('json', value, 'object').valid).toEqual(false), ); });