Skip to content

Commit 60a9cbf

Browse files
authored
fix: Improve json repair logic (#22088)
1 parent 214a7e9 commit 60a9cbf

File tree

10 files changed

+237
-52
lines changed

10 files changed

+237
-52
lines changed

packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -712,22 +712,12 @@ export const configureToolFunction = (
712712
if (value) {
713713
let parsedValue;
714714
try {
715-
parsedValue = jsonParse<IDataObject>(value);
715+
parsedValue = jsonParse<IDataObject>(value, { repairJSON: true });
716716
} catch (error) {
717-
let recoveredData = '';
718-
try {
719-
recoveredData = value
720-
.replace(/'/g, '"') // Replace single quotes with double quotes
721-
.replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2":') // Wrap keys in double quotes
722-
.replace(/,\s*([\]}])/g, '$1') // Remove trailing commas from objects
723-
.replace(/,+$/, ''); // Remove trailing comma
724-
parsedValue = jsonParse<IDataObject>(recoveredData);
725-
} catch (err) {
726-
throw new NodeOperationError(
727-
ctx.getNode(),
728-
`Could not replace placeholders in ${key}: ${error.message}`,
729-
);
730-
}
717+
throw new NodeOperationError(
718+
ctx.getNode(),
719+
`Could not replace placeholders in ${key}: ${error.message}`,
720+
);
731721
}
732722
options[key as 'qs' | 'headers' | 'body'] = parsedValue;
733723
}

packages/frontend/editor-ui/src/app/App.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ watch(
127127
setLanguage(newLocale);
128128
129129
axios.defaults.headers.common['Accept-Language'] = newLocale;
130+
130131
void locale.use(newLocale);
131132
},
132133
{ immediate: true },

packages/nodes-base/nodes/Set/test/v2/raw.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ describe('test Set2, rawMode/json Mode', () => {
129129
const output = await execute.call(fakeExecuteFunction, item, 0, options, {}, node);
130130

131131
expect(output).toEqual({
132-
json: { error: "The 'JSON Output' in item 0 contains invalid JSON" },
132+
json: { error: "The 'JSON Output' in item 0 does not contain a valid JSON object" },
133133
pairedItem: { item: 0 },
134134
});
135135
});

packages/nodes-base/nodes/Set/v2/helpers/__tests__/utils.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ import type {
99
} from 'n8n-workflow';
1010
import { NodeOperationError } from 'n8n-workflow';
1111

12-
import { prepareReturnItem } from '../utils';
1312
import type { SetNodeOptions } from '../interfaces';
1413
import { INCLUDE } from '../interfaces';
14+
import { parseJsonParameter, prepareReturnItem } from '../utils';
1515

1616
describe('prepareReturnItem', () => {
1717
const mockNode = mock<INode>({
@@ -448,3 +448,44 @@ describe('prepareReturnItem', () => {
448448
});
449449
});
450450
});
451+
452+
describe('parseJsonParameter', () => {
453+
const mockNode = mock<INode>({
454+
name: 'Set Node',
455+
typeVersion: 3.4,
456+
});
457+
458+
describe('Valid JSON input', () => {
459+
it('should parse valid JSON string', () => {
460+
const result = parseJsonParameter('{"key": "value"}', mockNode, 0);
461+
expect(result).toEqual({ key: 'value' });
462+
});
463+
464+
it('should parse valid JSON object directly', () => {
465+
const input = { key: 'value' };
466+
const result = parseJsonParameter(input, mockNode, 0);
467+
expect(result).toEqual(input);
468+
});
469+
470+
it('should parse complex valid JSON', () => {
471+
const json = '{"nested": {"key": "value"}, "array": [1, 2, 3]}';
472+
const result = parseJsonParameter(json, mockNode, 0);
473+
expect(result).toEqual({ nested: { key: 'value' }, array: [1, 2, 3] });
474+
});
475+
});
476+
477+
describe('JSON repair', () => {
478+
it('should repair JSON string', () => {
479+
const result = parseJsonParameter('{key: "value"}', mockNode, 0);
480+
expect(result).toEqual({ key: 'value' });
481+
});
482+
});
483+
484+
describe('Error cases', () => {
485+
it('should throw error for invalid JSON that cannot be recovered', () => {
486+
expect(() => parseJsonParameter('{key1: "value",, key2: "value2"}', mockNode, 0)).toThrow(
487+
"The 'JSON Output' in item 0 contains invalid JSON",
488+
);
489+
});
490+
});
491+
});

packages/nodes-base/nodes/Set/v2/helpers/utils.ts

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -137,23 +137,11 @@ export const parseJsonParameter = (
137137

138138
if (typeof jsonData === 'string') {
139139
try {
140-
returnData = jsonParse<IDataObject>(jsonData);
140+
returnData = jsonParse<IDataObject>(jsonData, { repairJSON: true });
141141
} catch (error) {
142-
let recoveredData = '';
143-
try {
144-
recoveredData = jsonData
145-
.replace(/'/g, '"') // Replace single quotes with double quotes
146-
.replace(/(['"])?([a-zA-Z0-9_]+)(['"])?:/g, '"$2":') // Wrap keys in double quotes
147-
.replace(/,\s*([\]}])/g, '$1') // Remove trailing commas from objects
148-
.replace(/,+$/, ''); // Remove trailing comma
149-
returnData = jsonParse<IDataObject>(recoveredData);
150-
} catch (err) {
151-
const description =
152-
recoveredData === jsonData ? jsonData : `${recoveredData};\n Original input: ${jsonData}`;
153-
throw new NodeOperationError(node, `The ${location} in item ${i} contains invalid JSON`, {
154-
description,
155-
});
156-
}
142+
throw new NodeOperationError(node, `The ${location} in item ${i} contains invalid JSON`, {
143+
description: jsonData,
144+
});
157145
}
158146
} else {
159147
returnData = jsonData;

packages/workflow/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"title-case": "3.0.3",
6868
"transliteration": "2.3.5",
6969
"xml2js": "catalog:",
70-
"zod": "catalog:"
70+
"zod": "catalog:",
71+
"jsonrepair": "catalog:"
7172
}
7273
}

packages/workflow/src/utils.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ApplicationError } from '@n8n/errors';
22
import { parse as esprimaParse, Syntax } from 'esprima-next';
33
import type { Node as SyntaxNode, ExpressionStatement } from 'esprima-next';
44
import FormData from 'form-data';
5+
import { jsonrepair } from 'jsonrepair';
56
import merge from 'lodash/merge';
67

78
import { ALPHABET } from './constants';
@@ -109,7 +110,7 @@ type MutuallyExclusive<T, U> =
109110
| (T & { [k in Exclude<keyof U, keyof T>]?: never })
110111
| (U & { [k in Exclude<keyof T, keyof U>]?: never });
111112

112-
type JSONParseOptions<T> = { acceptJSObject?: boolean } & MutuallyExclusive<
113+
type JSONParseOptions<T> = { acceptJSObject?: boolean; repairJSON?: boolean } & MutuallyExclusive<
113114
{ errorMessage?: string },
114115
{ fallbackValue?: T }
115116
>;
@@ -120,6 +121,7 @@ type JSONParseOptions<T> = { acceptJSObject?: boolean } & MutuallyExclusive<
120121
* @param {string} jsonString - The JSON string to parse.
121122
* @param {Object} [options] - Optional settings for parsing the JSON string. Either `fallbackValue` or `errorMessage` can be set, but not both.
122123
* @param {boolean} [options.acceptJSObject=false] - If true, attempts to recover from common JSON format errors by parsing the JSON string as a JavaScript Object.
124+
* @param {boolean} [options.repairJSON=false] - If true, attempts to repair common JSON format errors by repairing the JSON string.
123125
* @param {string} [options.errorMessage] - A custom error message to throw if the JSON string cannot be parsed.
124126
* @param {*} [options.fallbackValue] - A fallback value to return if the JSON string cannot be parsed.
125127
* @returns {Object} - The parsed object, or the fallback value if parsing fails and `fallbackValue` is set.
@@ -136,6 +138,14 @@ export const jsonParse = <T>(jsonString: string, options?: JSONParseOptions<T>):
136138
// Ignore this error and return the original error or the fallback value
137139
}
138140
}
141+
if (options?.repairJSON) {
142+
try {
143+
const jsonStringCleaned = jsonrepair(jsonString);
144+
return JSON.parse(jsonStringCleaned) as T;
145+
} catch (e) {
146+
// Ignore this error and return the original error or the fallback value
147+
}
148+
}
139149
if (options?.fallbackValue !== undefined) {
140150
if (options.fallbackValue instanceof Function) {
141151
return options.fallbackValue();

packages/workflow/test/utils.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,136 @@ describe('jsonParse', () => {
109109
it('optionally returns a `fallbackValue`', () => {
110110
expect(jsonParse('', { fallbackValue: { foo: 'bar' } })).toEqual({ foo: 'bar' });
111111
});
112+
113+
describe('JSON repair', () => {
114+
describe('Recovery edge cases', () => {
115+
it('should handle simple object with single quotes', () => {
116+
const result = jsonParse("{name: 'John', age: 30}", { repairJSON: true });
117+
expect(result).toEqual({ name: 'John', age: 30 });
118+
});
119+
120+
it('should handle nested objects with single quotes', () => {
121+
const result = jsonParse("{user: {name: 'John', active: true},}", { repairJSON: true });
122+
expect(result).toEqual({ user: { name: 'John', active: true } });
123+
});
124+
125+
it('should handle empty string values', () => {
126+
const result = jsonParse("{key: ''}", { repairJSON: true });
127+
expect(result).toEqual({ key: '' });
128+
});
129+
130+
it('should handle numeric string values', () => {
131+
const result = jsonParse("{key: '123'}", { repairJSON: true });
132+
expect(result).toEqual({ key: '123' });
133+
});
134+
135+
it('should handle multiple keys with trailing comma', () => {
136+
const result = jsonParse("{a: '1', b: '2', c: '3',}", { repairJSON: true });
137+
expect(result).toEqual({ a: '1', b: '2', c: '3' });
138+
});
139+
140+
it('should recover single quotes around strings', () => {
141+
const result = jsonParse("{key: 'value'}", { repairJSON: true });
142+
expect(result).toEqual({ key: 'value' });
143+
});
144+
145+
it('should recover unquoted keys', () => {
146+
const result = jsonParse("{myKey: 'value'}", { repairJSON: true });
147+
expect(result).toEqual({ myKey: 'value' });
148+
});
149+
150+
it('should recover trailing commas in objects', () => {
151+
const result = jsonParse("{key: 'value',}", { repairJSON: true });
152+
expect(result).toEqual({ key: 'value' });
153+
});
154+
155+
it('should recover trailing commas in nested objects', () => {
156+
const result = jsonParse("{outer: {inner: 'value',},}", { repairJSON: true });
157+
expect(result).toEqual({ outer: { inner: 'value' } });
158+
});
159+
160+
it('should recover multiple issues at once', () => {
161+
const result = jsonParse("{key1: 'value1', key2: 'value2',}", { repairJSON: true });
162+
expect(result).toEqual({ key1: 'value1', key2: 'value2' });
163+
});
164+
165+
it('should recover numeric values with single quotes', () => {
166+
const result = jsonParse("{key: '123'}", { repairJSON: true });
167+
expect(result).toEqual({ key: '123' });
168+
});
169+
170+
it('should recover boolean values with single quotes', () => {
171+
const result = jsonParse("{key: 'true'}", { repairJSON: true });
172+
expect(result).toEqual({ key: 'true' });
173+
});
174+
175+
it('should handle urls', () => {
176+
const result = jsonParse('{"key": "https://example.com",}', { repairJSON: true });
177+
expect(result).toEqual({ key: 'https://example.com' });
178+
});
179+
180+
it('should handle ipv6 addresses', () => {
181+
const result = jsonParse('{"key": "2a01:c50e:3544:bd00:4df0:7609:251a:f6d0",}', {
182+
repairJSON: true,
183+
});
184+
expect(result).toEqual({ key: '2a01:c50e:3544:bd00:4df0:7609:251a:f6d0' });
185+
});
186+
187+
it('should handle single quotes containing double quotes', () => {
188+
const result = jsonParse('{key: \'value with "quotes" inside\'}', { repairJSON: true });
189+
expect(result).toEqual({ key: 'value with "quotes" inside' });
190+
});
191+
192+
it('should handle escaped single quotes', () => {
193+
const result = jsonParse("{key: 'it\\'s escaped'}", { repairJSON: true });
194+
expect(result).toEqual({ key: "it's escaped" });
195+
});
196+
197+
it('should handle keys containing hyphens', () => {
198+
const result = jsonParse("{key-with-dash: 'value'}", { repairJSON: true });
199+
expect(result).toEqual({ 'key-with-dash': 'value' });
200+
});
201+
202+
it('should handle keys containing dots', () => {
203+
const result = jsonParse("{key.name: 'value'}", { repairJSON: true });
204+
expect(result).toEqual({ 'key.name': 'value' });
205+
});
206+
207+
it('should handle unquoted string values', () => {
208+
const result = jsonParse('{key: value}', { repairJSON: true });
209+
expect(result).toEqual({ key: 'value' });
210+
});
211+
212+
it('should handle unquoted multi-word values', () => {
213+
const result = jsonParse('{key: some text}', { repairJSON: true });
214+
expect(result).toEqual({ key: 'some text' });
215+
});
216+
217+
it('should handle input with double quotes mixed with single quotes', () => {
218+
const result = jsonParse('{key: "value with \'single\' quotes"}', { repairJSON: true });
219+
expect(result).toEqual({ key: "value with 'single' quotes" });
220+
});
221+
222+
it('should handle keys starting with numbers', () => {
223+
const result = jsonParse("{123key: 'value'}", { repairJSON: true });
224+
expect(result).toEqual({ '123key': 'value' });
225+
});
226+
227+
it('should handle nested objects containing quotes', () => {
228+
const result = jsonParse("{outer: {inner: 'value with \"quotes\"', other: 'test'},}", {
229+
repairJSON: true,
230+
});
231+
expect(result).toEqual({ outer: { inner: 'value with "quotes"', other: 'test' } });
232+
});
233+
234+
it('should handle complex nested quote conflicts', () => {
235+
const result = jsonParse("{key: 'value with \"quotes\" inside', nested: {inner: 'test'}}", {
236+
repairJSON: true,
237+
});
238+
expect(result).toEqual({ key: 'value with "quotes" inside', nested: { inner: 'test' } });
239+
});
240+
});
241+
});
112242
});
113243

114244
describe('jsonStringify', () => {

0 commit comments

Comments
 (0)