Skip to content

Commit

Permalink
feat: Recovery option for jsonParse helper (#10182)
Browse files Browse the repository at this point in the history
Co-authored-by: Elias Meire <elias@meire.dev>
  • Loading branch information
michael-radency and elsmr authored Jul 26, 2024
1 parent 1718125 commit d165b33
Show file tree
Hide file tree
Showing 3 changed files with 75 additions and 6 deletions.
4 changes: 3 additions & 1 deletion packages/workflow/src/TypeValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -135,7 +136,8 @@ export const tryToParseObject = (value: unknown): object => {
return value;
}
try {
const o = JSON.parse(String(value)) as object;
const o = jsonParse<object>(String(value), { acceptJSObject: true });

if (typeof o !== 'object' || Array.isArray(o)) {
throw new ApplicationError('Value is not a valid object', { extra: { value } });
}
Expand Down
66 changes: 65 additions & 1 deletion packages/workflow/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -68,17 +75,74 @@ export const deepCopy = <T extends ((object | Date) & { toJSON?: () => 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, U> =
| (T & { [k in Exclude<keyof U, keyof T>]?: never })
| (U & { [k in Exclude<keyof T, keyof U>]?: never });

type JSONParseOptions<T> = MutuallyExclusive<{ errorMessage: string }, { fallbackValue: T }>;
type JSONParseOptions<T> = { 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 = <T>(jsonString: string, options?: JSONParseOptions<T>): 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);
Expand Down
11 changes: 7 additions & 4 deletions packages/workflow/test/TypeValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' } },
Expand All @@ -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),
);
});
Expand Down

0 comments on commit d165b33

Please sign in to comment.