mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat: Recovery option for jsonParse helper (#10182)
Co-authored-by: Elias Meire <elias@meire.dev>
This commit is contained in:
parent
1718125c6d
commit
d165b33cea
|
@ -2,6 +2,7 @@ import { DateTime } from 'luxon';
|
||||||
import type { FieldType, INodePropertyOptions, ValidationResult } from './Interfaces';
|
import type { FieldType, INodePropertyOptions, ValidationResult } from './Interfaces';
|
||||||
import isObject from 'lodash/isObject';
|
import isObject from 'lodash/isObject';
|
||||||
import { ApplicationError } from './errors';
|
import { ApplicationError } from './errors';
|
||||||
|
import { jsonParse } from './utils';
|
||||||
|
|
||||||
export const tryToParseNumber = (value: unknown): number => {
|
export const tryToParseNumber = (value: unknown): number => {
|
||||||
const isValidNumber = !isNaN(Number(value));
|
const isValidNumber = !isNaN(Number(value));
|
||||||
|
@ -135,7 +136,8 @@ export const tryToParseObject = (value: unknown): object => {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const o = JSON.parse(String(value)) as object;
|
const o = jsonParse<object>(String(value), { acceptJSObject: true });
|
||||||
|
|
||||||
if (typeof o !== 'object' || Array.isArray(o)) {
|
if (typeof o !== 'object' || Array.isArray(o)) {
|
||||||
throw new ApplicationError('Value is not a valid object', { extra: { value } });
|
throw new ApplicationError('Value is not a valid object', { extra: { value } });
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,13 @@ import { ALPHABET } from './Constants';
|
||||||
import type { BinaryFileType, IDisplayOptions, INodeProperties, JsonObject } from './Interfaces';
|
import type { BinaryFileType, IDisplayOptions, INodeProperties, JsonObject } from './Interfaces';
|
||||||
import { ApplicationError } from './errors/application.error';
|
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']);
|
const readStreamClasses = new Set(['ReadStream', 'Readable', 'ReadableStream']);
|
||||||
|
|
||||||
// NOTE: BigInt.prototype.toJSON is not available, which causes JSON.stringify to throw an error
|
// NOTE: BigInt.prototype.toJSON is not available, which causes JSON.stringify to throw an error
|
||||||
|
@ -68,17 +75,74 @@ export const deepCopy = <T extends ((object | Date) & { toJSON?: () => string })
|
||||||
};
|
};
|
||||||
// eslint-enable
|
// 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> =
|
type MutuallyExclusive<T, U> =
|
||||||
| (T & { [k in Exclude<keyof U, keyof T>]?: never })
|
| (T & { [k in Exclude<keyof U, keyof T>]?: never })
|
||||||
| (U & { [k in Exclude<keyof T, keyof U>]?: 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 => {
|
export const jsonParse = <T>(jsonString: string, options?: JSONParseOptions<T>): T => {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(jsonString) as T;
|
return JSON.parse(jsonString) as T;
|
||||||
} catch (error) {
|
} 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 !== undefined) {
|
||||||
|
if (options.fallbackValue instanceof Function) {
|
||||||
|
return options.fallbackValue();
|
||||||
|
}
|
||||||
return options.fallbackValue;
|
return options.fallbackValue;
|
||||||
} else if (options?.errorMessage) {
|
} else if (options?.errorMessage) {
|
||||||
throw new ApplicationError(options.errorMessage);
|
throw new ApplicationError(options.errorMessage);
|
||||||
|
|
|
@ -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 = [
|
const VALID_OBJECTS = [
|
||||||
['{"a": 1}', { a: 1 }],
|
['{"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' } }],
|
||||||
|
["{\"a\": 1, b: { 'c': 10, d: 'test'}}", { a: 1, b: { c: 10, d: 'test' } }],
|
||||||
[{ name: 'John' }, { name: 'John' }],
|
[{ name: 'John' }, { name: 'John' }],
|
||||||
[
|
[
|
||||||
{ name: 'John', address: { street: 'Via Roma', city: 'Milano' } },
|
{ name: 'John', address: { street: 'Via Roma', city: 'Milano' } },
|
||||||
|
@ -162,19 +166,18 @@ describe('Type Validation', () => {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const INVALID_JSON = [
|
const INVALID_OBJECTS = [
|
||||||
['one', 'two'],
|
['one', 'two'],
|
||||||
'1',
|
'1',
|
||||||
'[1]',
|
'[1]',
|
||||||
'1.1',
|
'1.1',
|
||||||
1.1,
|
1.1,
|
||||||
'"a"',
|
'"a"',
|
||||||
'{a: 1}',
|
|
||||||
'["apples", "oranges"]',
|
'["apples", "oranges"]',
|
||||||
[{ name: 'john' }, { name: 'bob' }],
|
[{ name: 'john' }, { name: 'bob' }],
|
||||||
'[ { name: "john" }, { name: "bob" } ]',
|
'[ { name: "john" }, { name: "bob" } ]',
|
||||||
];
|
];
|
||||||
INVALID_JSON.forEach((value) =>
|
INVALID_OBJECTS.forEach((value) =>
|
||||||
expect(validateFieldType('json', value, 'object').valid).toEqual(false),
|
expect(validateFieldType('json', value, 'object').valid).toEqual(false),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue